Combinators
Some types already map onto a representation with a codec: a newtype over String, an opaque type, a value that is really an Int. Build their MCodec from that one instead of deriving.
transform — map an existing codec
transform turns an MCodec[T] into an MCodec[U] given both directions of the conversion. It is inline, so nothing extra survives to runtime:
import halotukozak.mcodec.*
opaque type Email = String
given MCodec[Email] = MCodec[String].transform(identity, identity)
case class UserId(value: Int)
given MCodec[UserId] = MCodec[Int].transform(_.value, UserId(_))
The arguments are (onWrite: U => T, onRead: T => U), write first.
transformed — map with failure handling
The same map, run inside a try/catch. A NonFatal throw from onRead surfaces as a ReadFailure, from onWrite as a WriteFailure. Use it when the conversion can fail, such as parsing or validation. The arguments are read-first and curried:
import halotukozak.mcodec.*
final case class Tagged(raw: String)
given MCodec[Tagged] = MCodec[String].transformed(Tagged(_))(_.raw)
nullable — allow an explicit null
mcodec compiles with -Yexplicit-nulls, so nullability shows in the types. nullable widens MCodec[T] to MCodec[T | Null]. It writes null for the null case and reads a JSON null back to it:
import halotukozak.mcodec.*
val nullableInt: MCodec[Int | Null] = MCodec[Int].nullable
given MCodec[String | Null] = MCodec[String].nullable
makeLazy — break a derivation cycle
MCodec.makeLazy holds off on the wrapped codec until first use. derives MCodec already ties the knot for self-referential types, so this is only for cycles you wire by hand:
import halotukozak.mcodec.*
final case class Expr(sub: Option[Expr])
given exprCodec: MCodec[Expr] = MCodec.makeLazy(MCodec.derived[Expr])
create — a codec from two functions
MCodec.create takes a read function and a write function against the raw Input/Output:
import halotukozak.mcodec.*
final case class Wrap(n: Int)
given MCodec[Wrap] = MCodec.create[Wrap](
in => Wrap(MCodec.read[Int](in)),
(out, w) => MCodec.write(out, w.n),
)
For common shapes there are typed variants: createSimple (one scalar via in.readInt() / out.writeInt(…)), createList, and createObject. They let you drive the streaming API without routing through another MCodec.
forceTransientDefaults
Wraps a codec so it writes every @transientDefault field even when it matches the default, recursing into nested products. Reads are unaffected.
import halotukozak.mcodec.*
import halotukozak.mcodec.annotation.transientDefault
case class Config(@transientDefault verbose: Boolean = false, name: String) derives MCodec
val verbose = MCodec.forceTransientDefaults(MCodec[Config])