Auto-derive Guide (Plan 126, D109 amend + D230)

Status: ✅ landed 2026-06-05. D-blocks: D109 amend + D230 NEW.

Nova supports auto-derive for five built-in protocols via an #impl(P) annotation on a user-defined type. An analog of Rust’s #[derive(...)] with no separate keyword — the same #impl(P) mechanism is reused (D186).

TL;DR

#impl(Equal + Hash + Clone + Compare + Display)
type Vec3 {
    x f64
    y f64
    z f64
}

ro a = Vec3 { x: 1.0, y: 2.0, z: 3.0 }
ro b = Vec3 { x: 1.0, y: 2.0, z: 3.0 }
assert(a == b)             // auto-derived @equal
ro c = a.clone()           // auto-derived @clone
ro h = a.hash()            // auto-derived @hash
ro cmp = a.compare(b)      // auto-derived @compare

The compiler synthesizes method bodies memberwise, recursively, based on the type’s fields.

Supported protocols

ProtocolMethodSynthesis strategy
Equal@equal(other) -> boolmemberwise && chain
Hash@hash() -> u64XOR + rotate FxHash-style combine
Clone@clone() -> Self (D230)a record literal with .clone() per field
Compare@compare(other) -> intlexicographic if-chain (memcmp-style)
Display@display(sb) -> ()sb.append("TypeName { f: v, ... }") chain

All 5 are single-method built-in protocols, declared in std/prelude/protocols.nv.

When the compiler synthesizes

  1. The type is marked #impl(P) where P is one of the 5 built-in protocols.
  2. The type does not provide an explicit fn T @method(...) — otherwise the user’s version wins.
  3. All of the type’s fields are eligible — primitive OR have #impl(P) OR have an explicit fn FieldType @method.

If even one condition is violated — a diagnostic from the E_AUTO_DERIVE_* family (see below).

When the compiler does NOT synthesize

  • The protocol isn’t built-in (a user-defined protocol) — auto-derive is only for the 5 known built-ins. User-defined protocols → the user writes the body by hand.
  • The type provides an explicit methodfn T @equal(other) -> bool => ... wins over auto-derive (a manual override).
  • The field type doesn’t implement the required protocol → E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL.

Field eligibility

Every field of the type must be one of:

Field categoryWhat the synthesizer does
Primitive (int/f64/bool/char/byte/str/u*/i*)Inline copy/compare/hash via built-in routines
#impl(P) annotated record/tupleRecursive call @field.method(...)
Explicit fn FieldType @methodDirect dispatch to the user-provided method
[]T arrayRecursive over T
Tuple (A, B, ...)Recursive over element types

What’s not eligiblefn(...) types, pointers *T, opaque types, protocol types (they require an explicit user impl).

Examples

A simple record

#impl(Equal)
type Money {
    cents int
}

ro a = Money { cents: 100 }
ro b = Money { cents: 100 }
assert(a == b)  // → @a.cents == b.cents → true

Recursive auto-derive

#impl(Clone)
type Inner {
    name str
    code int
}

#impl(Clone)
type Outer {
    inner Inner       // ← Inner has #impl(Clone) — eligible
    count int
}

ro o = Outer { inner: Inner { name: "x", code: 1 }, count: 5 }
ro p = o.clone()
// synthesized:
//   Outer { inner: @inner.clone(), count: @count }
// → Outer { inner: Inner { name: @name, code: @code }, count: 5 }

Manual override (user wins)

#impl(Equal)
type CaseInsensitive {
    text str
}

// User implements @equal — wins over auto-derive.
fn CaseInsensitive @equal(other CaseInsensitive) -> bool =>
    @text.to_lower() == other.text.to_lower()

ro a = CaseInsensitive { text: "Hello" }
ro b = CaseInsensitive { text: "HELLO" }
assert(a == b)  // → user-defined logic

Named tuple (Plan 120 D215)

#impl(Equal + Clone)
type Pair(left int, right int)

ro p = Pair(1, 2)
ro q = Pair(1, 2)
assert(p == q)
ro r = p.clone()

Heap-record == override

Before Plan 126, on a heap record a == b was identity-eq (pointer comparison). After Plan 126:

// Without #impl(Equal) — identity-eq preserved (backward compat).
type Account {
    id int
    balance f64
}
ro a = Account { id: 1, balance: 100.0 }
ro b = Account { id: 1, balance: 100.0 }
assert(a != b)  // ← different allocations, identity doesn't match

// With #impl(Equal) — structural eq.
#impl(Equal)
type AccountStruct {
    id int
    balance f64
}
ro x = AccountStruct { id: 1, balance: 100.0 }
ro y = AccountStruct { id: 1, balance: 100.0 }
assert(x == y)  // ← memberwise structural eq

Diagnostics (Plan 126)

CodeWhen it triggers
E_AUTO_DERIVE_CYCLECyclic recursion through fields doesn’t terminate
E_AUTO_DERIVE_FIELD_LACKS_PROTOCOLField type doesn’t implement the required protocol
E_AUTO_DERIVE_UNKNOWN_PROTOCOLProtocol isn’t in the built-in list (Equal/Hash/Clone/Compare/Display)
E_AUTO_DERIVE_UNSUPPORTED_KINDType kind (Newtype/Alias/Effect/Protocol/Opaque) doesn’t support derive

Example: E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL

type Plain {
    n int
}

#impl(Equal)
type Wrapper {
    inner Plain    // ← Plain doesn't #impl(Equal)
}
// ❌ E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL:
//   type `Wrapper` claims `#impl(Equal)` but field `inner`
//   (type `Plain`) does not implement `Equal`.
//   Either add `#impl(Equal)` to `Plain`, or provide explicit
//   `fn Wrapper @equal(...)`.

Fix: add #impl(Equal) to Plain:

#impl(Equal)   // ← Fix: now Plain eligible
type Plain {
    n int
}

#impl(Equal)
type Wrapper {
    inner Plain
}

Cycle detection

The compiler maintains a visited set (type, protocol) during synthesis. If synthesis for type T is already underway and a recursive path back to T is encountered — E_AUTO_DERIVE_CYCLE:

#impl(Clone)
type A { b B }

#impl(Clone)
type B { a A }
// ❌ E_AUTO_DERIVE_CYCLE: cyclic recursion through fields doesn't terminate.
//    Provide explicit `fn A @clone(...)` or `fn B @clone(...)`.

Fix: an explicit impl on one of the types breaks the recursion:

#impl(Clone)
type A { b B }

fn A @clone() -> A => A { b: @b }   // ← manual; the synthesizer for B will keep working

Composition with Plan 124.x semantics

Auto-derive is compatible with:

  • The priv field modifier (Plan 124.1/D220 §3.3.1): the synthesizer runs in type-method scope — it has access to priv fields.
  • The mut field modifier (D33): mut fields are copied like ordinary fields, mutability is preserved in the new value.
  • The ro binding (D33, D175): synthesized methods receive a ro Self receiver — read-only access.
  • Value record type X value { ... } (Plan 124.8 D228): full support, synthesis works identically to a heap record.
  • Named tuple type X(a int, b str) (Plan 120 D215): fields are processed through NamedTupleField exactly like RecordField.

Sum-type rich synthesis (Plan 180, D345 — ✅ landed)

All six built-in protocols are synthesized for sum types via match @ { … } with one arm per variant (SumVariantKind::Unit/Tuple/Record). Payload elements are bound in the arm pattern and recurse exactly like record fields.

MarkerFormStatus
[M-126-sum-equal-rich]same-variant + payload-wise == (nested match, cross-variant → false)✅ CLOSED
[M-126-sum-hash-rich]variant-index seed ⊕ payload-hash (rotate-XOR combine)✅ CLOSED
[M-126-sum-clone-rich]match-arm-per-variant reconstruction (payload primitives shallow, composites .clone())✅ CLOSED
[M-126-sum-compare-rich]variant-index order, then payload lexicographic✅ CLOSED
[M-126-sum-fmt-rich]variant-aware @display/@debug (V / V(x, y) / V { f: x })✅ CLOSED

Ergonomics note: a method call on a bare unit variant (Nought.hash()) mis-infers to the variant’s type; annotate it via a local ro n Colour = Nought (the same bidirectional-inference boundary as for Empty collisions, D141).

Serialize/Deserialize (Plan 180, externally-tagged — ✅ landed). #impl(Serialize + Deserialize) on a sum → an externally-tagged wire (Q4): unit → "V"; single-payload → {"V": x}; tuple → {"V": [a, b]}; record → {"V": {fields}}. Deser reads the tag (is_str → bare string / single object key), an unknown tag → DeError{UnknownVariant}. Internal/adjacent/untagged tagging → followup [M-180-serde-tagging-modes] (gated on #serde attributes). Example: nova_tests/serde/sum_autoderive.nv.

What’s NOT supported in V1 (followup)

MarkerDescription
[M-126-codegen-method-table]V1: the synthesized FnDecl isn’t registered in method_table. Codegen wiring for full a == b runtime semantics — V2 expansion

V1 focuses on the type-check level — auto-derive correctly suppresses E_IMPL_MISSING_METHODS, which unblocks pattern usage in downstream type-checked code. Full == wiring through method_table is Plan 126 V2 (once it’s needed in the production stdlib).

Method-level #impl(P) — opt-in conformance (D268, Plan 154.1)

#impl(P) as a leading attribute works not only on a type (auto-derive above), but also on an individual method declaration — it’s an optional marker meaning “this method implements protocol P’s method”:

#impl(Display)
fn int @display(mut sb StringBuilder) -> () { sb.append(@) }
  • Opt-in, not required. Conformance remains structural — a type with a matching method satisfies the bound [T Display] even without #impl. #impl only adds a signature check against P + explicitly binds P to the receiver type (type_impl_protocols), as if P had been listed on the type declaration.
  • Three error codes (checker): E_IMPL_UNKNOWN_PROTOCOL (P isn’t a protocol), E_IMPL_NOT_A_PROTOCOL_METHOD (@m isn’t declared in P), E_IMPL_SIGNATURE_MISMATCH (the signature/receiver-mut doesn’t match).
  • Where it’s used in the stdlib: all 6 primitives (int/f64/bool/char/str/f32) got concrete #impl(Display) + #impl(Debug) in protocols.nv — this fixes the mis-dispatch of Vec[T].debug(sb) on a primitive element (Plan 154.1 / D269).

Details — D268 and Plan 154.1.

See also

Last updated August 4, 2026