Annotations

mcodec reads annotations off the derived M&DE mirror at compile time. They shift the wire shape and cost nothing at runtime. @name and @transparent live in halotukozak.made.annotation; the rest are in halotukozak.mcodec.annotation.

@name — rename on the wire

Overrides a field label, or an ADT case label in the discriminator:

import halotukozak.mcodec.*
import halotukozak.made.annotation.name

case class User(@name("user_name") name: String, age: Int) derives MCodec

enum Shape derives MCodec:
  @name("circ") case Circle(radius: Double)

Json.write(User("bob", 30))            // {"user_name":"bob","age":30}
Json.write[Shape](Shape.Circle(2.0))   // {"circ":{"radius":2.0}}

Reads accept the renamed label. The Scala-side name stays as written.

@transparent — unwrap a single-field case class

A @transparent wrapper serializes as its bare inner value, with no object around it. Reach for it when you want a newtype but a real case class rather than an opaque type:

import halotukozak.mcodec.*
import halotukozak.made.annotation.transparent

@transparent case class Email(value: String) derives MCodec

Json.write(Email("a@b.com"))    // "a@b.com"
Json.read[Email]("\"a@b.com\"") // Email("a@b.com")

@flatten — inline discriminator for ADTs

@flatten drops the nested-discriminator object and puts a discriminator field next to the case's own fields. The key defaults to _case; pass a string to change it:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.flatten

@flatten enum FlatShape derives MCodec:
  case Circle(radius: Double)
  case Rectangle(w: Double, h: Double)
  case Point

Json.write[FlatShape](FlatShape.Circle(2.0))   // {"_case":"Circle","radius":2.0}
Json.write[FlatShape](FlatShape.Point)         // {"_case":"Point"}

@flatten("type") enum TypedShape derives MCodec:
  case Square(side: Double)

Json.write[TypedShape](TypedShape.Square(3.0)) // {"type":"Square","side":3.0}

On read the discriminator can sit anywhere in the object, not only first. A @flatten key that clashes with a case's own field name fails the read.

@defaultCase — fallback for an unknown discriminator

Marks one case to decode into when the discriminator is missing or unrecognised, instead of failing. Two of them in one hierarchy is a compile error. It works with the nested encoding and the flat one:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.defaultCase

enum NestedCmd derives MCodec:
  case Start(n: Int)
  @defaultCase case Unknown

Json.read[NestedCmd]("{}")            // NestedCmd.Unknown
Json.read[NestedCmd]("{\"Nope\":{}}") // NestedCmd.Unknown

@stringEnum — encode a C-style enum as a string

For an enum whose cases are all singletons, @stringEnum writes the bare case name in place of the default {"Red":{}}:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.stringEnum

@stringEnum enum Color derives MCodec:
  case Red, Green, Blue

Json.write[Color](Color.Red)  // "Red"
Json.read[Color]("\"Green\"") // Color.Green

An unknown string fails the read. Without the annotation the enum keeps the empty-object encoding.

@transientDefault — omit a field that equals its default

When a field's value is == to its declared default, mcodec leaves it out of the output:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.transientDefault

case class TD(@transientDefault x: Int = 7, keep: String) derives MCodec

Json.write(TD(7, "a")) // {"keep":"a"}
Json.write(TD(9, "a")) // {"x":9,"keep":"a"}

The check is structural == against the declared default, so a collection or Option default has to compare equal (Nil == Nil, None == None). To write every @transientDefault field back in, for a debug dump say, wrap the codec:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.transientDefault

case class TD(@transientDefault x: Int = 7, keep: String) derives MCodec

val forced = MCodec.forceTransientDefaults(MCodec[TD])
Json.write(TD(7, "a"))(using forced) // {"x":7,"keep":"a"}

forceTransientDefaults recurses into nested products.

@outOfOrder — accept a field in any wire position

Fields read in declaration order by default. @outOfOrder lets one be picked up wherever it lands in the input. Write order does not change:

import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.outOfOrder

case class OOO(@outOfOrder tag: String, value: Int) derives MCodec

Json.read[OOO]("{\"value\":5,\"tag\":\"hello\"}") // OOO("hello", 5)