Generic Deriving
Saga lets you write traits that users can derive on their own types, without modifying the compiler. If you're building a JSON library, a CSV codec, a database row mapper, or anything else where users would otherwise write tedious boilerplate, this is the mechanism you reach for.
This guide is written for library authors. If you just want to use derives on your own types, the Traits page covers that side.
The Payoff
A user of your library writes this:
import MyJsonLibrary (ToJson)
record Person { name: String, age: Int } deriving (ToJson)
main () = to_json (Person { name: "Alice", age: 30 })To make that work, you write a few small impls (one per generic shape) plus
instances for whatever primitive types you want users to be able to put in
their fields. Using the Generic trait, we can accomplish this without macros,
compiler patches, or a code generation step.
How It Works
At a high level, deriving has three moving parts:
- The compiler converts the user's type into a structural
Rep. - Your library provides trait impls for the generic building blocks.
- The compiler synthesizes the user-facing impl by routing values to and
from their
Rep.
The rest of this guide fills in the details of each step.
The Generic Trait
Every record and ADT can be mechanically translated into a small set of
generic shapes. The compiler does this translation automatically for every
type a user defines, producing a Rep type and an impl Generic that
converts between the user's type and its Rep. There's no
deriving (Generic) syntax. Every type already has a Generic
representation available.
The Building Blocks
A Rep is built from these wrappers:
Leaf a: a single field's value (anInt, aString, a nested record).Labeled (n : Symbol) a: a record field. The namenlives at the type level; the value is the only runtime data.Variant (n : Symbol) a: a sum constructor. The constructor namenlives at the type level; the payload is the only runtime data.And l r: two things side by side (multiple fields in a record, multiple arguments to a constructor).Or l r: a choice between two things (which variant of a sum type).Record a: the outer frame around a whole record. Carries the runtime type name as aString.Adt a: the outer frame around a whole sum type. Carries the runtime type name as aString.U1: the empty case (a constructor with no arguments, likeTriangleorNothing).
A Concrete Rep
For record Point { x: Int, y: Int }, the structural shape (type-level)
is:
Record (And (Labeled 'x (Leaf Int))
(Labeled 'y (Leaf Int)))Field names live at the type level as symbol literals ('x, 'y).
At runtime, the value the compiler produces for Point { x: 3, y: 4 }
is:
Record "Point" (And (Labeled (Leaf 3))
(Labeled (Leaf 4)))Read outside in: it's a record (with a runtime type name), containing
two things joined by And, each a labeled field wrapping a leaf value.
The field names (x, y) aren't in the runtime value — they're in
the type, available to library code via type-class reflection.
Type-level names
The n parameter on Labeled and Variant has kind Symbol, a kind
for type-level interned names. Symbols are written 'name in Saga
source — 'admin, 'x, 'first_name are all valid symbol literals.
A library impl that needs the runtime string for a symbol takes a
KnownSymbol constraint and uses symbol_name:
impl ToJson for Labeled n a where {n: KnownSymbol, a: ToJson} {
to_json (Labeled value) = {
let name = symbol_name (Proxy : Proxy n)
"\"" <> name <> "\": " <> to_json value
}
}Proxy n is a phantom type whose only purpose is to carry n to the
call site; symbol_name reads off the source name and returns it as a
String.
This pattern — names at the type level, reflected when needed — is what makes sum-type from-direction derives correct. The library compares the expected symbol against the input's tag and selects the right variant. (See "From-Direction Traits" below.)
What This Means For You
A library only has to implement your trait for these eight public building
blocks. The compiler handles the generated per-type Rep__... wrappers
itself — you never write impls for those.
Generic itself is a two-parameter trait relating a user type to its
structural representation. You'll see Generic Person r in generated code;
read it as "Person's Rep is r."
Designing Your Trait
The derive machinery has to either consume the user type (walk it apart, field by field) or produce one (assemble it from input), but not both. Two rules follow from that:
Each method has the user type on the parameter side or the return side,
but not both. Methods that mention a only in their parameters are
to-direction; methods that mention a only in their return type are
from-direction. Multiple occurrences on the same side are fine, so
fun compare : a -> a -> Ordering and fun decode : Input -> (a, a)
both derive. The compiler decides per method by inspecting the signature,
so a single trait can freely mix the two. A JsonCodec with both
encode : a -> String and decode : String -> Result a Error derives in
one go.
A method with a on both sides (fun roundtrip : a -> a) or with a on
neither side (fun zero : Unit -> Int) doesn't fit; the derive aborts with
a diagnostic naming the offending method.
From-direction return types put a at a direct field position. The
compiler inspects the wrapper's structure, finds positions where the
user type a appears, and threads the conversion through them. So all
of these work:
a(bare:fun decode : Input -> a)Result a efor any error typeeMaybe a- Any user-defined wrapper whose
TypeDefis in scope. Examples: a three-state result typeDbResult a = DbOk a | DbErr DbError | DbNoRows, a validation typeValidated e a = Valid a | Invalid (List e), or a record wrapperBoxed a = { value: a, meta: String }.
"Direct field position" means a has to appear as one of the constructor's
own fields, not buried inside another type and not as a phantom parameter.
A few concrete failures:
- Non-leaf
a.Wrapped a = Yep (List a) | Nopedoesn't fit becausealives insideList. The compiler can't thread the conversion through an arbitrary container. - Phantom
a.Schema a = Schema Stringmentionsain the type parameter list but no constructor carries an actualafield. The compiler reports there's "nothing forfromto thread through." - Opaque wrappers. If the wrapper's
TypeDefisn't visible (defined in another module without exposed constructors), the compiler can't inspect its structure.
If your trait doesn't fit, users can still write impl YourTrait for ... by
hand. Deriving is a convenience for the common shape, not the only path.
Worked Example: A ToJson Library
The trait and primitives:
trait ToJson a {
fun to_json : a -> String
}
impl ToJson for Int { to_json n = show n }
impl ToJson for Bool { to_json b = if b then "true" else "false" }
impl ToJson for String { to_json s = "\"" <> s <> "\"" }The JSON rendering here is deliberately minimal to keep the example small. A real library would escape strings, handle Unicode, and emit valid JSON for nested structures.
Now the building-block instances. These are what make the trait derivable:
impl ToJson for U1 {
to_json U1 = ""
}
impl ToJson for Leaf a where {a: ToJson} {
to_json (Leaf x) = to_json x
}
impl ToJson for Labeled n a where {n: KnownSymbol, a: ToJson} {
to_json (Labeled value) = {
let name = symbol_name (Proxy : Proxy n)
"\"" <> name <> "\": " <> to_json value
}
}
impl ToJson for And l r where {l: ToJson, r: ToJson} {
to_json (And l r) = to_json l <> ", " <> to_json r
}
impl ToJson for Or l r where {l: ToJson, r: ToJson} {
to_json (Or_Left l) = to_json l
to_json (Or_Right r) = to_json r
}
impl ToJson for Variant n a where {n: KnownSymbol, a: ToJson} {
to_json (Variant payload) = {
let name = symbol_name (Proxy : Proxy n)
"{\"" <> name <> "\": " <> to_json payload <> "}"
}
}
impl ToJson for Record a where {a: ToJson} {
to_json (Record _ inner) = "{" <> to_json inner <> "}"
}
impl ToJson for Adt a where {a: ToJson} {
to_json (Adt _ inner) = to_json inner
}That's the library. Users can now derive ToJson on any record or ADT whose
fields are themselves ToJson. Note that the user wrote deriving (ToJson)
without mentioning Generic! Generic is implied automatically whenever a
user-defined derive is requested.
Record and Adt are top-level framing wrappers. They give your codec a
place to emit per-type prefixes/suffixes (here, the outer JSON braces).
Variant carries the constructor name at the type level, which lets you
render Some 7 as {"Some": 7} while record fields (which use Labeled)
render as "name": "Alice".
Under the Hood
When the user writes deriving (ToJson) on Person, the compiler generates
the following decls invisibly:
# The structural representation: a single-variant wrapper type
type Rep__Person = Rep__Person (Record (And (Labeled 'name (Leaf String))
(Labeled 'age (Leaf Int))))
# How to convert a Person to/from its Rep
impl Generic Person Rep__Person {
to p = Rep__Person (Record "Person"
(And (Labeled (Leaf p.name))
(Labeled (Leaf p.age))))
from (Rep__Person (Record _ (And (Labeled (Leaf n))
(Labeled (Leaf a))))) = { name: n, age: a }
}
# Bridge: ToJson for the Rep type unwraps and forwards
impl ToJson for Rep__Person {
to_json (Rep__Person inner) = to_json inner
}
# Delegating: ToJson for Person goes through Generic
impl ToJson for Person where {Generic Person r, ToJson r} {
to_json p = to_json (to p)
}When to_json alice is called:
- The delegating impl converts
aliceto its Rep, wrapped inRecord "Person" (...). - The bridge impl unwraps the
Rep__Personnewtype. - Your
ToJson for Recordadds the outer{/}and recurses. - Your
ToJson for Andwalks the inner tree, callingToJson for Labeledon each piece. - Each
Labeledimpl reflects its symbol parameter viaKnownSymbolto recover the field name ("name","age") and renders it.
The same handful of impls handle every user record and ADT.
ADTs and Variants
Sum types fold into Or chains, with each variant wrapped in Variant to
carry the constructor name at the type level:
type Shape =
| Circle Float
| Rect Float Float
| Triangle
deriving (ToJson)Schematically, the Rep looks like:
Adt (Or (Variant 'Circle (Leaf Float))
(Or (Variant 'Rect (And (Leaf Float) (Leaf Float)))
(Variant 'Triangle U1)))The runtime value carries the type name "Shape" inside Adt and the
chosen variant's payload (no constructor name at the value level — that
lives in the type as the Symbol parameter on Variant).
Your Adt instance wraps the whole tree (you'll usually pass through, but
the type name is there if you want it); your Or instance gets called
twice (once per nesting level); your Variant instance reflects the
symbol via KnownSymbol to get the constructor name as a String; And
handles multi-field variants; U1 is the empty contribution for nullary
constructors like Triangle.
Compare this to records, which use Record (outer) and Labeled (inner).
That split is why your codec can give record fields and sum constructor
names different output formats.
From-Direction Traits
Traits that produce the user type from some input (parsing, deserialization,
database row decoding) work the same way: write instances for the building
blocks, deriving handles the rest. A FromJson trait pairs naturally with the
ToJson example above:
trait FromJson a {
fun from_json : String -> Result a JsonError
}Most of the impls are mechanical mirrors of their to-direction counterparts —
Leaf wraps a decoded value, And decodes both sides, Record and Adt
pass through. The one subtle case is sum-type decoding, which is worth
calling out:
# Variant compares its expected constructor name against the input's tag
# and fails if they don't agree.
impl FromJson for Variant n a where {n: KnownSymbol, a: FromJson} {
from_json input = {
let expected = symbol_name (Proxy : Proxy n)
case parse_tag input {
Ok (actual, payload) when actual == expected ->
from_json payload |> Result.map Variant
Ok _ -> Err (JsonError $"expected variant '{expected}'")
Err e -> Err e
}
}
}
# Or tries the left branch; if it rejects, falls back to the right.
impl FromJson for Or l r where {l: FromJson, r: FromJson} {
from_json input = case from_json input {
Ok l -> Ok (Or_Left l)
Err _ -> from_json input |> Result.map Or_Right
}
}This pair is what makes sum-type decoding correct. Each Variant impl knows
its expected constructor name from its type-level Symbol and rejects
mismatched input. Or walks the variant chain by trying the left branch and
falling back on failure — and because branches genuinely fail on tag
mismatch, the right branch gets a real chance to match. If the constructor
name lived at the value level instead, the synthesized from would have to
wildcard it and Or would always pick the first branch regardless of input.
The type-level representation fixes that structurally.
Recursive Types
Recursive types Just Work:
type IntList = Nil | Cons Int IntList deriving (ToJson)
main () = {
let xs = Cons 1 (Cons 2 (Cons 3 Nil))
dbg (to_json xs)
}The generated Rep is:
Adt (Or (Variant 'Nil U1)
(Variant 'Cons (And (Leaf Int) (Leaf IntList))))Notice the recursive position: Leaf IntList, not an unfolded Or ....
The Rep stops at the type boundary and trusts the ToJson IntList instance
to handle it from there. That dispatch happens at runtime through the
normal trait machinery, so library authors don't need to do anything
special. The building-block impls already written above handle this case.
Parameterized Types
Type parameters in the user's type propagate naturally through the Rep:
record Box a { value: a } deriving (ToJson)The generated impl is roughly:
impl ToJson for Box a where {a: ToJson, Generic (Box a) r, ToJson r}The user gets to_json (Box { value: 42 }) for free as long as ToJson Int
exists.
Customizing Output
Each building block is a hook for a specific kind of formatting:
Record a: top-level wrapper on records. Outer framing (e.g. JSON{}, an XML element, a SQL row tuple). Carries the runtime type name.Adt a: top-level wrapper on ADTs. Same idea for sum types; often a passthrough, sometimes you want type-name-aware output here.Labeled (n : Symbol) a: record field. The namenlives at the type level; reflect it viaKnownSymbolto format key/value pairs.Variant (n : Symbol) a: sum constructor. Same reflection pattern asLabeled. Distinct fromLabeled, so you can render"name": "Alice"for fields and{"Some": 7}for variants without conflict.And l r/Or l r: joiners. Separators between fields, branches between variants.
If you don't care about a particular hook, write a one-line passthrough:
impl ToJson for Adt a where {a: ToJson} {
to_json (Adt _ inner) = to_json inner
}When To Use Hand-Written Impls Instead
Reach for hand-written impl YourTrait for SpecificType when:
- You need to rename or skip specific fields (no attribute system exists yet for per-field configuration).
- The type doesn't fit the supported shapes (functions, opaque types,
third-party types you can't add
derivingto). - Performance matters and you want to avoid the routing overhead.
Symbols Beyond Derive
The Symbol kind isn't just for derive impls — it's a general type-level
tagging mechanism. The canonical use pairs a symbol-parameterized wrapper
with type aliases to give many roles distinct
identities without one wrapper per role:
type Id (k : Symbol) = Id Int
type alias UserId = Id 'user
type alias PostId = Id 'post
let u : UserId = Id 1
let p : PostId = Id 2
# let bad : UserId = (p : PostId) # rejected — 'user ≠ 'postId 'user and Id 'post are distinct types because the symbols differ,
even though both wrap an Int. At runtime there's no overhead beyond the
single-constructor wrapper — the symbol is erased.
Reach for this when several "primitive-flavored" types should not be mixed (user IDs, post IDs, session tokens, currency codes) and one underlying representation serves many semantic roles.
Summary
To make your trait derivable:
- Every method has the user type on the parameter side or the return side, but not both.
- From-direction methods return
adirectly, or any wrapper type (Result a e,Maybe a, your ownDbResult a, etc.) withaat a direct field position. - Provide instances for
U1,Leaf a,Labeled n a,Variant n a,And l r,Or l r,Record a, andAdt a(fromStd.Generic). TheLabeledandVariantimpls take aKnownSymbolbound onnto recover the type-level name viasymbol_name (Proxy : Proxy n). - Provide instances for the primitive types you want users to be able to
include as fields (
Int,String,Bool, etc.).
Users then write deriving (YourTrait) on their types, and the compiler
generates everything needed to wire it up.