SagaSaga
Guide

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:

  1. The compiler converts the user's type into a structural Rep.
  2. Your library provides trait impls for the generic building blocks.
  3. 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 (an Int, a String, a nested record).
  • Labeled (n : Symbol) a: a record field. The name n lives at the type level; the value is the only runtime data.
  • Variant (n : Symbol) a: a sum constructor. The constructor name n lives 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 a String.
  • Adt a: the outer frame around a whole sum type. Carries the runtime type name as a String.
  • U1: the empty case (a constructor with no arguments, like Triangle or Nothing).

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 e for any error type e
  • Maybe a
  • Any user-defined wrapper whose TypeDef is in scope. Examples: a three-state result type DbResult a = DbOk a | DbErr DbError | DbNoRows, a validation type Validated e a = Valid a | Invalid (List e), or a record wrapper Boxed 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) | Nope doesn't fit because a lives inside List. The compiler can't thread the conversion through an arbitrary container.
  • Phantom a. Schema a = Schema String mentions a in the type parameter list but no constructor carries an actual a field. The compiler reports there's "nothing for from to thread through."
  • Opaque wrappers. If the wrapper's TypeDef isn'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:

  1. The delegating impl converts alice to its Rep, wrapped in Record "Person" (...).
  2. The bridge impl unwraps the Rep__Person newtype.
  3. Your ToJson for Record adds the outer {/} and recurses.
  4. Your ToJson for And walks the inner tree, calling ToJson for Labeled on each piece.
  5. Each Labeled impl reflects its symbol parameter via KnownSymbol to 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 name n lives at the type level; reflect it via KnownSymbol to format key/value pairs.
  • Variant (n : Symbol) a: sum constructor. Same reflection pattern as Labeled. Distinct from Labeled, 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 deriving to).
  • 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 ≠ 'post

Id '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:

  1. Every method has the user type on the parameter side or the return side, but not both.
  2. From-direction methods return a directly, or any wrapper type (Result a e, Maybe a, your own DbResult a, etc.) with a at a direct field position.
  3. Provide instances for U1, Leaf a, Labeled n a, Variant n a, And l r, Or l r, Record a, and Adt a (from Std.Generic). The Labeled and Variant impls take a KnownSymbol bound on n to recover the type-level name via symbol_name (Proxy : Proxy n).
  4. 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.