Skip to main content

Comment DSL

We have a comment DSL to help annotate the output code beyond what is possible just with CDDL.

@name

For example in an array-encoded group you can give explicit names just by the keys e.g.:

foo = [
bar: uint,
baz: text
]

but with map-encoded structs the keys are stored and for things like integer keys this isn't very helpful e.g.:

tx = {
? 0: [* input],
? 1: [* outputs],
}

we would end up with two fields: key_0 and key_1. We can instead end up with fields named inputs and outputs by doing:

tx = {
? 0: [* input], ; @name inputs
? 1: [* outputs], ; @name outputs
}

Note: the parsing can be finicky. For struct fields you must put the comment AFTER the comma, and the comma must exist even for the last field in a struct.

It is also possible to use @name with type choices:

foo = 0 ; @name mainnet
/ 1 ; @name testnet

and also for group choices:

script = [
; @name native
tag: 0, script: native_script //
; @name plutus_v1
tag: 1, script: plutus_v1_script //
; @name plutus_v2
tag: 2, script: plutus_v2_script
]

Note: a group-choice arm that keeps more than one non-fixed field is emitted as a struct of its own, named after the arm. Such an arm therefore competes for a type name with every rule and every other such arm, and a spec that gives one name to two different shapes is rejected with a clean error naming both claimants — rename either with @name. Two arms that spell out the identical shape are one type written twice and simply share a single generated struct, so reusing generic arm names (first/second, key/value) across rules stays fine. An arm slim enough to be inlined into its enum variant (at most one non-fixed field) emits no struct at all and so never competes: credential's ; @name Script arm may sit alongside a script rule, giving Credential::Script and Script independently.

Note: separately from the type name above, every arm of one group choice names a variant of the same generated enum, so within a single rule the arms' names must be distinct — including two arms whose shapes are identical, and including two spellings that produce the same name (my_arm and myArm). Giving two arms of one rule the same @name is rejected with a clean error naming both; rename either. Arms you do not name are named for you (from the arm's member key, its type, or its position), and such a derived name simply takes a numeric suffix when it collides — foo = [ x: uint // x: text ] gives Foo::X and Foo::X2. A name you wrote always wins over a derived one, whichever arm comes first.

Note: @name is also the remedy for a field name the generator refuses. A field whose emitted identifier is a Rust keyword ({ if: uint }) or a reserved generated-local name (raw, len, read, … — see Reserved field names for the set and where each applies) would emit a crate that does not compile, so it is rejected at parse time; ; @name <other> renames the Rust field and leaves the CBOR wire key untouched (a bareword/text key stays the same text, and an array position never puts the name on the wire). The refusal reads the resolved name, so ; @name raw is itself refused while raw: uint ; @name payload is accepted.

Note: @name does not rename a top-level rule or group — the rule identifier itself is the emitted type name, so foo = uint ; @name bar is rejected with a clean error. To change the emitted type name, rename the foo identifier. The same rejection covers the two rule shapes that look like they have something to name and do not: a T / null rule (which collapses to an Option<T> alias, so its arms are not variants) and the tag-258 set idiom (foo = #6.258([* uint]) / [* uint], which collapses to a set wrapper for the same reason).

On a plain group rule the trailing comment belongs to the last group entry, not the rule: grp = (a: uint) ; @name other renames the field a to other (exactly like grp = (a: uint ; @name other)) — it neither renames the group nor errors, provided some rule splices the group. A group nothing splices emits neither a struct nor a field, so nothing there can be renamed and the rule-position rejection applies (see Plain groups nothing splices).

Note: @name also names an anonymous inline array used as a member's type, which is the remedy the "Anonymous groups not allowed" error advertises:

t = [0, [1, bytes] ; @name inner
]

mints struct Inner and holds it in a field named inner — the trailing comment is the member's own slot, so the single directive names both the struct and the field, which is what makes the struct referenceable at all. The naming door is scoped to the case where the anonymous array is the member's whole type: behind a control operator (bytes .cbor [1, bytes] ; @name inner) the array belongs to the operator rather than the member, and the anonymous-group error stands. An inline map in the same position has no naming door at all — its own error points at the named form (m = { a: int, b: uint }, then reference m) and does not advertise @name.

@rust_name

@rust_name <Ident> pins the emitted Rust type name of a rule. This is the deliberate opposite of @name: where @name renames a field/variant and never touches the top-level type (above), @rust_name renames the top-level type — but only across the crate boundary, and only on a rule in an extern-dependency (_CDDL_CODEGEN_EXTERN_DEPS_DIR_) scope. On a normally-generated (exported) rule it is rejected with a clean error (there, the identifier already is the emitted type name — rename the identifier, exactly as for @name).

Its purpose is cross-crate name fidelity. When a consumer references a dependency's type, it otherwise re-derives the Rust name from the CDDL identifier using its own codegen version's naming rules — which can differ from the version that actually built the dependency (naming rules evolve). @rust_name records the dependency's final name once, at the point it is knowable, so the consumer reads it instead of guessing:

plutus_data = _CDDL_CODEGEN_EXTERN_TYPE_ ; @rust_name PlutusData
coin = uint ; @rust_name Coin

Honoring is a boundary-only translation — every internal spelling keeps the consumer-derived name, and only the import seam differs, emitting an alias:

use plutus_dep::PlutusData as PlutusDatum; // when the consumer would derive `PlutusDatum`

so the rest of the generated code (and the wasm↔rust conversions) keep referring to the derived name. A pin-less rule falls back to today's consumer-side derivation (hand-stub compatibility). A pin that camel-cases to a reserved Rust std/prelude type (Option, Box, …) or a CDDL keyword is rejected, exactly as a derived rule name by that spelling would be — a dependency could never have emitted a type by that name, so the pin could never be honored.

@newtype

With code like foo = uint this creates an alias e.g. pub type Foo = u64; in rust. When we use foo = uint ; @newtype it instead creates a pub struct Foo(pub(crate) u64); (the wrapped field is crate-visible so hand-written modules in the same crate can reach it — see the wrapper-field contract; external crates go through the getter and new).

The wrapper always emits an inner-value getter, named get by default:

impl Foo {
pub fn get(&self) -> u64 {
self.0
}
}

@newtype can optionally rename that getter e.g. foo = uint ; @newtype custom_getter emits custom_getter instead of get. Every wrapper struct (bare tag, @newtype, and bounded/range wrappers) exposes this getter plus a new(inner) constructor in both the rust and wasm bindings.

Exception — nominal set rules (#6.258 sets). A named non-generic tag-258 set rule is itself a nominal wrapper owning its encodings (see current capacities), so @newtype on it is about the getter only. A bare @newtype on such a rule emits no inherent get() — a 0-argument get(&self) would shadow the inner collection's get(index) reached through Deref (a compile error at every indexed read), so it is deliberately suppressed; inner access goes through Deref/DerefMut and IntoIterator instead. A custom @newtype <name> getter (; @newtype entries) IS emitted (a custom name does not shadow get(index)). So on a set nominal, bare @newtype is a documented no-op and @newtype <name> adds a named accessor.

@newtype is applied for you on a recursive collection rule. A named collection whose cycle contains no nominal type cannot be emitted as a pub type alias at all (rustc E0391), so the generator marks every collection-backed rule of such a cycle @newtype itself and says so on stderr — see recursive types. Writing the directive yourself produces byte-identical output and silences the notice.

@newtype is redundant on a top-level tag rule (foo = #6.42(text), and equally on its collection and T / null bodies — #6.24([* uint]), #6.11({* tstr => uint}), #6.10(uint / null)) and on a top-level bytes .cbor T rule (foo_bytes = bytes .cbor foo): both already auto-wrap — into the tag-writing wrapper and the byte-string-framing wrapper respectively — so their standalone to/from_cbor_bytes API does not drop the tag, or write the payload unwrapped (see current capacities). Adding ; @newtype there produces the same wrapper, byte for byte, not a double wrapper — since the wrapper already has a default get, its only remaining use on such a rule is to rename that getter. Note the boundary on the T / null case: it is the tag that forces the wrapper, so an UNTAGGED T / null rule (opt = uint / null ; @newtype) still collapses to a transparent Option<T> and still rejects the directive loudly.

@no_alias

foo = uint
bar = [
field: foo
]

This would normally result in:

pub type Foo = u64;
pub struct Bar {
field: Foo,
}

but if we use @no_alias it skips generating an alias and uses it directly e.g.:

foo = uint ; @no_alias
bar = [
field: foo
]

to

pub struct Bar {
field: u64,
}

@used_as_key

foo = [
x: uint,
y: uint,
] ; @used_as_key

cddl-codegen derives the comparison/hash traits a type needs to serve as a map/set key. For any type used as a CBOR map key in the spec it infers this automatically; @used_as_key forces the same derives onto a type even when the spec never keys a map on it — useful when your own utility code puts the type in a map and you want the generated code to already implement the traits (and, because it lives in the spec, your hand-written mod.rs files stay untouched across regenerations).

Flavors

Bare @used_as_key derives the tool's full internal key bundle: Eq, PartialEq, Ord, PartialOrd — plus Hash under --preserve-encodings (that mode's maps are hash-ordered). This bundle is mode-dependent and matches exactly what an auto-detected CBOR map key gets.

When your downstream code only needs one family, name it — the flavor is mode-independent (an external HashMap/BTreeMap requirement exists regardless of the encoding flags):

transaction_output = alonzo_format_tx_out / conway_format_tx_out ; @used_as_key hash

On a multi-choice type rule the tag must sit on the LAST arm. That arm's trailing comment is the rule-position slot; on any other arm the tag is read as that variant's own metadata and rejected. The one-line spelling above cannot get this wrong — a ; comment runs to end of line, so it is always the last arm's — but a multi-line spelling can, and a reorder can move the wrong arm into last place. See @custom_json for the general rule.

TagDerives
@used_as_key (bare)Eq, PartialEq, Ord, PartialOrd (+ Hash under --preserve-encodings)
@used_as_key hashHash, Eq, PartialEq
@used_as_key ordOrd, PartialOrd, Eq, PartialEq
@used_as_key hash ordthe union of both

Naming a flavor lets a type that keys a HashMap downstream avoid an Ord it can never supply (e.g. a hand-written extern with no total order), instead of failing to compile far from the cause.

Demand is a union. Flavors and the internal bundle can only add derives, never remove them: tagging a type hash cannot strip the Ord it gets from also being an auto-detected CBOR map key. The flavor propagates transitively to every type the tagged type contains, exactly like the bare tag.

Strict vocabulary. Only hash and ord may follow @used_as_key. Any other word — a typo, or trailing prose like @used_as_key marks the output — is a hard error; put prose in @doc.

Compile-time assertions. Every @used_as_key-tagged type — flavored or bare — gets a named _demand_<rule> function in the generated crate (generated/key_demand_assertions.rs) that asks the compiler to prove the type supplies the traits its tag demands (for bare, the mode-dependent full bundle). Beyond turning a distant trait error into a near, named one, the file is the in-crate breadcrumb from a failing derive back to its cause: demand propagates transitively, so "why does this struct need Ord?" is answered by the demand roots this file enumerates, each citing its tag. (Auto-detected CBOR map keys emit no assertion — the generated containers' own bounds already enforce them.)

Version-skew hazard. A cddl-codegen older than this feature silently ignores the flavor word and falls back to the full bundle, so a spec relying on a narrow flavor to avoid Ord (etc.) regenerates the original failure under an old tool. Pin your tool version in-repo. (Requires cddl-codegen with @used_as_key flavor support.)

Rule kinds. The tag demands derives on the struct a rule mints, so it belongs on a rule that mints one — including a plain group spliced into a rule that materializes it (foo = (a: uint, b: uint) ; @used_as_key with holder = [foo]), which honors it on the record struct the splice produces. On a generic definition (foo<T> = [x: T] ; @used_as_key) it is rejected: a definition names no concrete type, so there is nothing to derive on — put it on the instantiating rule (inst = foo<uint> ; @used_as_key). On a transparent alias (foo = uint, foo = (uint), a named binding to a set nominal) it is accepted and adds nothing: the alias has no struct of its own, and each of those targets already supplies the key traits.

@used_as_elem

bootstrap_witness = [
vkey: bytes,
signature: bytes,
] ; @used_as_elem

When you generate WASM bindings, a list used inline in the spec — [* bootstrap_witness] — mints a loose-list wrapper class (BootstrapWitnessList) so the elements can cross the WASM boundary safely. But if no rule in your spec actually contains that list, the wrapper is never generated, even though hand-written downstream code (or another crate) may still want to construct one.

@used_as_elem forces the generator to mint that loose-list wrapper for the tagged type exactly as if the spec contained an inline [* bootstrap_witness] usage: the structural class BootstrapWitnessList, its entry in wasm/src/generated/collections.rs, and its registration as an own-spec-produced shape (so a downstream --wrapper-requests consumer asking for [* bootstrap_witness] is satisfied by your crate's class instead of minting its own).

The idiomatic use case is being the canonical host of a wrapper class for a type your crate owns: a downstream crate points --extern-wrapper-index/--workspace-dep at you and imports BootstrapWitnessList from your collections module rather than re-minting a colliding #[wasm_bindgen] class. Before this tag you had to add a throwaway "fake" rule (bootstrap_witness_list = [* bootstrap_witness]) purely to force the class into existence; the tag expresses the intent directly on the element type and avoids the extra rust-side pub type alias the fake rule would emit.

Notes:

  • It is a no-op without --wasm (the wrapper is a WASM-boundary concern only).
  • It is rejected if the element is directly WASM-exposable (e.g. a transparent uint/text alias): such a list lowers to a bare Vec<..> at the boundary with no wrapper class, so there is nothing to mint.
  • It only mints the loose [* x] list wrapper. [+ x] (NonEmpty) and map wrappers are out of scope (a map wrapper cannot be named by a tag on a single element rule).
  • It is rule-scoped: the tag names the type whose wrapper to mint, so it belongs on a rule. On a field/member's trailing comment (f: bw, ; @used_as_elem, inside a holder = [ … ]) it is a hard error — put it on the rule that defines the element type instead (bw = [...] ; @used_as_elem).
  • On a generic definition (foo<T> = [x: T] ; @used_as_elem) it is rejected: a definition names no concrete type, so there is no element type for a wrapper to hold. Put it on the instantiating rule (inst = foo<uint> ; @used_as_elem), which is where the concrete type is minted.

@duplicates

Status

@duplicates reject is live for set/array collection rules ([* a] / [+ a], including the tag-258 set idiom), across the Rust, JSON, and wasm boundaries: the rule's transparent alias becomes an order-preserving, duplicate-free set on every target. @duplicates preserve is live for table rules ({ * k => v } and { + k => v }) across the Rust, JSON, and wasm boundaries: the alias becomes a byte-exact, duplicate-keyed pair-map — PairMap<K, V>, or NonEmptyPairMap<K, V> for the non-empty {+ …} flavor (its single try_from door composes the min-1 check). A tag-258 set now defaults to reject (see Defaults below); every other set defaults to preserve and tables to reject. Writing the effective default explicitly is accepted for self-documentation (a no-op). A preserve table and a non-preserve map/table of the identical key/value coexist under --wasm: the synthesized wasm class name encodes the backing container, so the preserve flavor is PairMap<K>To<V> (and NonEmptyPairMap<K>To<V> for {+ …}) while the default flavor keeps Map<K>To<V> — two distinct classes, each wrapping its own inner type.

Selects the per-rule policy for how a collection handles duplicate entries on the CBOR wire. CBOR permits duplicate map keys and duplicate set entries; which stance a rule wants depends on the data (a validating writer rejects them; a multi-era reader must accept and re-emit historical duplicates byte-exactly).

strict_set = #6.258([* pool_id]) / [* pool_id] ; @duplicates reject
historical = { * metadatum => metadatum } ; @duplicates preserve

The directive takes one required argument:

valuemeaning
preserveaccept duplicates on the wire and re-emit them byte-exactly (the contract is preservation, not merely "allow")
rejectduplicates are a decode error (DeserializeFailure::DuplicateKey) and unconstructable through the generated API

A missing or unknown argument is a hard error.

reject requires the element type to have a total order (the uniqueness door checks duplicates with an Ord-based scan — linear for small sets, sorted-index O(n log n) for large ones, so a large adversarial input cannot buy quadratic decode work). Every generated and standard element type satisfies this automatically except floats: a float-containing element under reject (or in any tag-258 set nominal, whose always-on comparison derives need Ord too) is rejected at generation time with a clear error, mirroring the float-map-key rejection. Floats stay fully supported in collections without the uniqueness requirement.

Defaults depend on the tag. Tag 258 is the IANA set tag, so a tag-258 set rule — the #6.258([* a]) / [* a] idiom, a single-arm #6.258([* a]), or an inline #6.258 occurrence — defaults to reject: the well-known-tag registry supplies set semantics (uniqueness) as the default. Every other set/array rule (a non-258 tagged idiom, a plain [* a] / [+ a]) still defaults to preserve, and tables still default to reject. Writing the effective default explicitly is accepted as self-documentation (a no-op, no warning); the opposite value is the live opt-out. In particular, @duplicates preserve on a tag-258 set is the opt-out back to plain Vec<T>/NonEmptyVec<T> (today's wire behavior verbatim) for a rule that must accept and re-emit historical duplicate-bearing data.

Breaking change (decode-time)

A tag-258 set previously defaulted to preserve; it now defaults to reject. Generated code still compiles, but a decoder built from a no-directive 258 set now fails with DuplicateKey on loose historical bytes that carried duplicate elements. The migration is one line of CDDL you control: add ; @duplicates preserve to the rule. For an inline #6.258 occurrence that has no rule-level comment slot, hoist it to a named rule to carry the directive (input_set = #6.258([* input]) / [* input] ; @duplicates preserve, then reference input_set) — CDDL rule references are transparent, so the hoist is semantically identical.

Under @duplicates reject a set rule's transparent alias targets an order-preserving, duplicate-free set type (OrderedSet<T>, or NonEmptyOrderedSet<T> for the [+] flavor) instead of Vec<T>/NonEmptyVec<T>. Its single TryFrom door is the only way to build one, so a duplicate is refused identically whether it arrives on the wire or through the generated API — the decode error is DeserializeFailure::DuplicateKey(Key::Uint(i)), where i is the zero-based index of the offending element. Rejection only narrows which inputs are accepted; an accepted (duplicate-free) value re-emits byte-exactly in its original wire order (the set is never sorted).

Under @duplicates preserve a table rule's transparent alias targets an entry-ordered, duplicate-permitting pair-map (PairMap<K, V>, or NonEmptyPairMap<K, V> for the {+} flavor) instead of the loose BTreeMap/OrderedHashMap. A loose map keyed by key value is structurally incapable of holding two entries with the same key, so preserve is the only way to accept AND re-emit duplicate-keyed maps byte-exactly — the load-bearing property for Cardano transaction_metadata, whose auxiliary-data hash is computed over the original bytes. The pair-map's read surface is map-flavored but honest about duplicates: get returns the first match, get_all every match in entry order, and insert appends (it never replaces an existing key). Under --canonical-form the entries are stable-sorted by encoded key bytes (duplicates kept adjacent in first-appearance order) — a deterministic best-effort, since duplicate-carrying data has no RFC 8949 canonical form. In the JSON representation a PairMap is an array of [k, v] pairs, not an object (a JSON object would silently collapse duplicate keys).

The policy is a per-rule knob (not a CLI flag) because mixed-era specs need both stances in one document — a Conway-strict set next to a historical preserve-mode set.

@duplicates on an inline table's row

An anonymous inline table written in type position — a union arm, a field type, an array element type — carries the directive in its own row-entry slot: a trailing comment on the * k => v row, inside the braces, with the closing brace on the next line.

tmd = { * transaction_metadatum => transaction_metadatum ; @duplicates preserve
} / [* transaction_metadatum] / int / bytes / text

preserve there swaps that inline table to the same PairMap<K, V> twin a named preserve table gets (here the union's map variant holds a PairMap), and an explicit reject is the accepted default. The slot is inline shapes only: a table written as its own rule and referenced by name takes its policy from that rule, never from the reference site, so one shared type can never end up with two duplicate stances depending on who embeds it.

The named spelling remains available and is the better one whenever the same table is used twice: metadata_map = { * metadatum => metadatum } ; @duplicates preserve, referenced from the union. It works for a self-referential union too (md = md_map / [* md] / … with md_map = { * md => md } ; @duplicates preserve, the transaction-metadatum shape) — in either rule order, whether the cycle is written starting from the union or from the map.

Exactly one spelling per shape, in both directions. A NAMED table's row is not a second place to write the policy: tbl = { * k => v ; @duplicates preserve is a loud rejection pointing at the rule slot. The asymmetry is the point — an anonymous table has no rule slot, so its row is the only place the policy can live; a named table has one, so a second honored spelling would only let the two drift apart.

The row-entry slot of an anonymous inline table honors @duplicates and nothing else. Every other directive written there is a loud rejection naming the spelling that works — a row entry declares no rule, field or type of its own, so @name, @doc, @ignore, the @custom_serialize/@custom_deserialize pair and its @custom_encodings/@custom_wire_major declarations have nothing to attach to. @name on a union's map arm belongs after the closing brace, where it names the variant — and since a comment runs to end of line, the next arm has to start on the next line:

tmd = { * uint => text } ; @name captured
/ int

A parenthesized row ({ * (k => v) }) has no entry slot of its own — write the row unparenthesized to carry a directive on it.

The directive is otherwise rule-level scoped: on a type-choice rule (the tag-set idiom's two arms) it applies to the whole collapsed rule, and it must be written as a trailing comment after the last arm — a rule-level directive placed after an earlier arm is a loud misplacement rejection naming the directive and the last-arm remedy, never silently ignored.

It applies only to set/array collection rules and table rules. On any other placement — a text/int alias, a struct/group/record rule, a union rule, an extern marker, or a field/member position — it is a graceful placement rejection (never silently ignored).

@name and @duplicates on an open-map rest row

An open struct-map mixes fixed members with a trailing * K => V "rest row" that captures every unknown map entry ({ 1: uint, 2: text, * uint => any }) — see Open struct-maps for the generated rest field and its surfaces. Two directives read from the rest row's own entry-trailing comment slot:

metadata = {
1: uint,
* uint => any ; @name extra @duplicates preserve
}
  • @name renames the generated capture field (default rest) — here to extra. It renames only the field; the containing type keeps its rule name.
  • @duplicates selects the rest row's duplicate-key policy, exactly as for a standalone table: the default is reject (a repeated captured key is a DuplicateKey decode error), and @duplicates preserve switches the capture container to the byte-exact PairMap<K, V> twin so duplicate captured keys round-trip verbatim.

The policy is orthogonal to the key domain: K may be any type the row accepts (see Open struct-maps for the domain rules), and the two compose as they do on a table — a default/reject row's container demands the full key bundle of K, while a preserve row's PairMap compares keys by scan and so demands only the @used_as_key ord family. The row places that demand for you, including across the workspace seam: when K belongs to a dependency crate, the row contributes a row to this crate's borrowed_key_types.rs sidecar — in the flavor its own container needs — exactly as a declared map field would.

Marker-slot trap

The directives must trail the whole entry (after the V type), not the * occurrence marker's own comment slot. A directive glued to the marker —

metadata = {
1: uint,
* ; @name extra
uint => any
}

— is silently not honored (the field stays rest): cddl-codegen ignores the occurrence marker's comment slot everywhere, so the rest-row reads only the entry-trailing slot.

A rule-position directive (on the same line as the closing }) is read at rule level, not as the rest row's — the two slots are disjoint, so neither steals from the other.

The same entry-trailing slot serves an open-array rest tail ([uint, tstr, * bytes] — see Open arrays), with one asymmetry:

  • @name renames the captured tail Vec field (default rest) exactly as it renames the map capture field — * bytes ; @name extras yields pub extras: Vec<Vec<u8>>.
  • @duplicates does not apply to a rest tail and is rejected gracefully: an array tail is positional (Vec in every mode), so there are no keys for a duplicate policy to govern.

The marker-slot trap above applies identically — the directive must trail the whole entry (* bytes ; @name extras), never the * marker's own comment slot.

@ignore

@ignore on an open struct-map rest row or an open-array rest tail selects the tolerate-and-drop flavor: unknown trailing data is still typed-deserialized (for a map, key and value; for an array, each element — so the stream advances past nested containers) but then discarded — no capture field is generated, and serialize re-emits only the declared members.

view = {
1: uint,
* uint => any ; @ignore
}

view_list = [
uint,
* any ; @ignore
]

Use it for view types that must accept forward-compatible data without retaining it. The default (no @ignore) is capture (map rest rows, array rest tails), which keeps the generated Serialize/Deserialize honest (nothing dropped, byte round-trips hold).

  • Placement. @ignore is bare (no argument) and reads from the trailing entry's own comment slot, exactly like @name/@duplicates (above). It is valid only on a recognized rest row of a map-rep record or a rest tail of an array-rep record; anywhere else (a rule, a field, a non-final or bounded occurrence) is a loud graceful rejection, never silently dropped. The same marker-slot trap applies: a directive glued to the * occurrence marker's comment slot is silently not honored, so keep it after the whole entry (* K => V ; @ignore, * t ; @ignore).
  • The end-of-line trap. A CDDL comment runs to the end of the line, so a directive on the same line as the container's closing token swallows that token: ; @ignore } eats the map's closing brace and ; @ignore ] eats the array's closing bracket. Put the directive on its own line before the closer, as in both examples above — never [ uint, * any ; @ignore ] on one line.
  • Deliberate lossiness. Byte round-trips do not hold for wire data carrying unknown entries or trailing elements — they are dropped, and re-serialization emits the declared members / prefix only. The generated type and its serialize fn carry a rustdoc breadcrumb saying so.
  • Rejected combinations, each a graceful generation error naming the remedy:
    • --preserve-encodings (and --canonical-form, which implies it): a preserve crate's contract is byte-exact round-trips, which a silently-lossy type would undermine crate-wide. Use capture (drop the directive), or @custom_serialize/@custom_deserialize for a genuine view type.
    • @duplicates on the same entry: a duplicates policy governs a capture container, which @ignore does not create. (On an array rest tail @duplicates is rejected for both flavors — a positional Vec tail has no keys for a duplicate policy to govern.)
    • @name on the same entry: there is no capture field to rename.
  • Typing is still enforced. * uint => any ; @ignore still errors on a text key (the spec said uint labels), and an ignored * text tail still errors on a non-text trailing element; full looseness is spelled * any => any / * any. For a map, a declared key wins over the rest row, so a fixed key whose wire value mismatches its type errors rather than falling through to the drop arm.

The JSON and wasm surfaces of an @ignore type are a plain closed struct's (unknown JSON keys / trailing array elements are tolerated on read and dropped; there is no rest() wasm getter, since nothing is stored).

@custom_json

foo = uint ; @newtype @custom_json

Avoids generating and/or deriving json-related traits under the assumption that the user will supply their own implementation to be used in the generated library.

The generated crate does not compile until you write them. That is the trade the directive makes, not an oversight: the tool stops deriving the JSON traits but keeps emitting every JSON surface over the type, because those surfaces — a containing type's own derives, the wasm wrapper's to_json/to_json_value/from_json, the --component guest's to-json/from-json members, and the --json-schema-export registration row — are the whole point of owning the JSON form by hand. Until your impls exist, each of them is an E0277 naming the trait and your type: serde::Serialize and serde::Deserialize under --json-serde-derives (which is the flag the wasm and component doors ride too), plus schemars::JsonSchema under --json-schema-export. Those three are the complete list; the two sections below cover the serde pair and the schema impl in turn.

It works on any rule shape that mints a type of its own — newtypes, record structs (map/array group rules), and sum types. On a type-choice rule, rule-level directives attach via the trailing comment of the LAST variant:

my_sum =
uint ; @name integer
/ bytes ; @name raw @custom_json

The last arm's trailing comment is the rule-position slot — that is the whole of the mechanism, not a convenience. A type-choice VARIANT position consumes only @name (which names the variant) and @doc (which documents it), so a rule-level directive written on any other arm is a hard error: generation stops and names the directive, the rule, and the remedy. Keep the directive on whichever arm is last, and if you reorder the arms, move it with them.

That rejection exists because the alternative was worse than a wrong build. Before it, a reorder turned the directive off in silence — the type quietly got its JSON derives back and they collided with the impls you wrote by hand, in a compile error nowhere near the spec edit that caused it. And the slot is shared: every rule-level directive reads from that same last-arm comment, so one reorder could switch off several unrelated contracts at once. A single arm annotated ; @name bytes @custom_json @used_as_key carries both a JSON-suppression contract and a key-demand contract, and a reorder dropped both — the JSON half silently, the key half as a missing derive that generated/key_demand_assertions.rs at least names.

The suppression covers serde::Serialize/serde::Deserialize and schemars::JsonSchema together — one decision, not two — so --json-schema-export never leaves a JsonSchema derive behind on a type whose JSON form you own. With --preserve-encodings, the #[serde(skip)] attributes on the type's encoding fields are omitted along with the derives (either alone would not compile).

Rules that cannot carry it: the transparent-alias family

A rule that lowers to a transparent aliaspub type Foo = u64; — is refused, with generation stopping and naming the rule and the remedy. The refusal fires regardless of the JSON flags, because where a directive may sit is a property of the spec, not of the build profile. The family is:

  • a plain alias rule (foo = uint ; @custom_json, and the foo = bar typename form);
  • a T / null rule, which collapses to an Option<T> alias rather than an enum;
  • a table rule (t = { * k => v }) and a named array rule (al = [* uint]) — these do mint a struct for the wasm wrapper, but the rust rule is still the transparent alias.

There is nothing there for the directive to do: an alias has no attribute site for the derives to be suppressed on, and it is not a nominal type, so the orphan rule forbids the hand-written Serialize/JsonSchema impls the directive promises. Add @newtypefoo = uint ; @newtype @custom_json, al = [* uint] ; @newtype @custom_json — and the rule mints a real wrapper struct that carries both.

On a _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule (including a generic extern base) the directive is refused. The named type is hand-written in full, so this crate emits no JSON derives to suppress and the impls the directive promises belong beside that type, not here. Give the rule a real CDDL body and put @custom_json there, or drop it and write the impls next to the externally-defined type.

A named binding to a generic set nominal (foo = gset<uint>, where gset<T> = #6.258([* T]) / [* T]) is refused too, and for a different reason: the binding emits a transparent pub type Foo = GsetU64; and mints no type of its own, while the derives it would suppress belong to GsetU64, whose config comes from the generic definition. The @newtype remedy does not apply here — a set nominal already is a wrapper. Put @custom_json on the definition instead (gset<T> = #6.258([* T]) / [* T] ; @custom_json).

Two rule kinds mint a struct from metadata that is not their own, and do honor the directive: a generic instance binding (foo = base<uint>, whose struct config comes from the definition) and a plain group spliced into a rule that materializes it (foo = (a: uint, b: uint) ; @custom_json with holder = [foo]). Both suppress the derives on the struct the rule mints.

Writing the Serialize impl the directive promises

The impl you supply must be honest in the serde data model, not merely produce the right JSON string — the wasm to_json_value() hands your type to serde-wasm-bindgen, not to serde_json. The trap is that the most natural way to write one of these impls (build a serde_json::Value, hand it to the serializer) is dishonest whenever the serde_json/arbitrary_precision cargo feature is on anywhere in your build graph: every number then serializes as a private token struct that only serde_json's own serializer collapses. Generated crates ship a json_value_ser runtime module for exactly this, and the full explanation and recipe are in Wasm differences § A hand-written Serialize must be honest in the serde data model.

Writing the JsonSchema impl the directive promises

Under --json-schema-export the impl you supply is not optional decoration: the json-gen crate checks, before writing anything, that every $ref in the finished document resolves inside that same document (see Command line flags § --json-schema-export). A hand-written impl that returns a bare Schema::new_ref("SomeType") where a body belongs fails that check by name. So json_schema() has to return the real body.

Author that body as a JSON file and let the custom_schema_impl! macro write the impl around it:

// rust/src/plutus.rs — a hand-owned module of the generated rust crate
crate::custom_schema_impl!(PlutusData, "custom_schemas/PlutusData.json");

…where rust/src/custom_schemas/PlutusData.json is the body itself:

{ "type": "object", "properties": { "constr": { "$ref": "#/$defs/ConstrPlutusData" } } }

That is the whole impl: schema_name() (derived from the type token), json_schema() (the file, with its references pointed at the document's real definitions namespace) and inline_schema() returning false.

Where the invocation may be written. Each of these is a compile error to get wrong, so the list is short by necessity:

  1. It is a crate-root macro of the crate hosting the json_schema_gen module. In-crate that is the generated rust crate itself, so crate::custom_schema_impl!(…). Under --common-import-override / --export-static-crate the module lives in your common crate, so it is <common>::custom_schema_impl!(…) — never <common>::json_schema_gen::custom_schema_impl!, since #[macro_export] hoists it to that crate's root.
  2. That hosting crate must have json_schema_gen reachable from its root, because the expansion reaches back into it. Under --export-static-crate that is the pub mod json_schema_gen; you hand-declare (the tool's new-file notice names it). In-crate the tool declares the module inside src/generated/mod.rs, so root reachability comes from the seed-once src/lib.rs's pub use generated::*; — a line you own after the first export. Narrowing that glob to a name list makes every invocation an E0433 for json_schema_gen, reported at the macro rather than at the edit that caused it.
  3. The invocation must live in the crate that DEFINES the type, because schemars::JsonSchema is a foreign trait and the orphan rule allows the impl nowhere else. For a generated type carrying @custom_json that means a hand-owned module of the generated rust crate, declared from its seed-once src/lib.rs — outside src/generated/**, which every run clobbers. Under an override this is the one place the macro's crate and the invocation's crate differ, which is fine.
  4. include_str! resolves the path relative to the INVOKING file, not to the macro's. Keep the JSON inside the invoking crate's own directory: a published crate ships only files under its own directory, so a path reaching into a sibling crate compiles locally and breaks at cargo publish.
  5. The invoking crate needs schemars reachable under that name, and only that — the document is parsed inside the hosting crate, so the invocation site needs no JSON dependency of its own. Under --json-schema-export the generated rust crate already declares schemars.

Write internal references as #/$defs/<Name> — that is the authoring convention, and the macro retargets them onto whatever namespace the generator actually uses before handing the body back, so the string is not a runtime fact encoded by hand. It matters because add_schemas takes the generator as a parameter: a consumer composing several crates' rows can supply one whose definitions live elsewhere. References the convention does not cover are left exactly as written — a bare "PlutusData", an http(s):// URL, a pointer into another document — because those are precisely what the closure check exists to report, and rewriting one would turn a named failure into a differently-dangling reference.

inline_schema() returning false is what makes the type referable: schemars then registers the body as a definition and hands out a $ref to it, so one hand-authored schema can point at another's entry ({"$ref": "#/$defs/ConstrPlutusData"}) and each shape is declared once. What that does not do is create the entry — a type reached only through a hand-written $ref is never visited by schemars at all, so it needs a registration row of its own (its own CDDL rule, or --json-schema-root) or the closure check fails on the dangling pointer.

Stating the published name. The two-argument form derives it from the type token, so a hand-authored type's published name follows the same rule as every generated sibling in the same document by construction. A three-argument form takes it as an expression instead, for the two shapes that derivation cannot serve — a type that is not a bare identifier at the invocation, and a deliberate published name that differs from the Rust one:

crate::custom_schema_impl!(Ext<u64>, "custom_schemas/ExtU64.json", "ExtU64");

An expression rather than a string literal, because schema_name() doubles as schema_id() by default and therefore as an identity: a generic whose instantiations all report one constant name is the silent merge the injectivity guard panics on, and you are the only party who can vary it (format!("Base_{}", <T as schemars::JsonSchema>::schema_name())).

Writing the impl by hand

Two shapes the macro cannot serve, both of which want a hand-written impl block:

  • the body is not a static file — it is computed, assembled from several files, or varies with a type parameter;
  • the impl needs a member the macro does not write — a schema_id() stated separately from schema_name(), or an inline_schema() of true (which makes the type un-referable, so no other hand-authored file can point at it).

Write the impl yourself and call custom_schema_body(generator, origin, source) for the body — the same function the macro's json_schema() member calls, so you keep the reference retarget and the two file-naming failure messages without reimplementing either:

impl schemars::JsonSchema for PlutusData {
fn schema_name() -> std::borrow::Cow<'static, str> { "PlutusData".into() }
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
crate::json_schema_gen::custom_schema_body(
generator,
"PlutusData.json",
include_str!("custom_schemas/PlutusData.json"),
)
}
fn inline_schema() -> bool { false }
}

origin is the file's name as it appears in those two messages (… is not valid JSON, … is not a valid JSON schema) and is otherwise unused — it is the author's only handle on WHICH hand-authored file broke, since the impl has no other identity at run time. The module path here is the ordinary one (crate::json_schema_gen::…, or <common>::json_schema_gen::… under an override); the crate-root spelling in fact 1 applies to the macro alone.

Worth adding on your side: a round-trip test validating serde_json::to_value of real values against the same files, composed the way the document composes them ({"$defs": {…every file…}, "$ref": "#/$defs/<Name>"}). Nothing in this tool can check that a hand-authored schema describes the type's actual serialization — that is the whole meaning of @custom_json — so the assertion has to live where the impls do.

@no_json_schema_export

plutus_map = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export
constr_plutus_data = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export

Declares that a type is not a published JSON-schema root. Its only effect: under --json-schema-export the json-gen crate's add_schemas emits no registration row for the type, so the type is not one of the crate's declared schema roots.

A root, not a definition — this is the part to read twice. The crate emits one schema document whose $defs holds every type reached from a registration row, so dropping a type's row removes it from the declared roots and nothing else. A type you suppress that a published type still references stays in $defs, and therefore still appears in the emitted TypeScript. The only way to keep a type out of the surface entirely is to stop published types from referencing it — which is @custom_json on the parent, the same pairing the sharp edge below describes. This is not a policy choice: a reference that survives into the document has to resolve, or the shipped .d.ts names a type nothing declares.

A suppressed type also still claims its schema_name() in the document — at whatever point a published type first reaches it. If a later registration row publishes the same name, that row is the one the json-gen run rejects (… but the document assigned it "<name>2" …), so a suppressed type's name must be unique within the crate exactly like a published one's.

This exists because a type having a derivable JSON schema is not evidence that the derived shape is that type's published encoding. A serde/schemars derive can exist as an artifact while the real encoding is produced by a parent type's hand-written impl; a JsonSchema impl can be a deliberate stub; and a hand-written _CDDL_CODEGEN_EXTERN_TYPE_ may have no schemars::JsonSchema impl at all — in which case its row is an E0277 inside a generated file. The tool cannot tell those apart from a genuine schema root. The spec author can, so this is the spec author's say-so.

The boundary is deliberately narrow. @no_json_schema_export does not:

  • remove the serde::Serialize/serde::Deserialize or schemars::JsonSchema derives on a generated type. A parent that embeds the type still needs JsonSchema on it (and that reference is what keeps the type in $defs, per above). Removing the derives is @custom_json, which is orthogonal — combining the two on one rule is legal and meaningful ("I supply the JSON impls, and this type is not a published schema root");
  • change CBOR serialization, the wasm surface, the extern-interface export, or the extern self-check;
  • do anything at all when --json-schema-export is off. The directive is then simply inert, so one spec can be generated under several flag sets without editing.

The opposite direction — adding a row for a published type the CDDL never describes — is the --json-schema-root flag, which takes a Rust type path rather than a rule name. It consults no IR, so naming a type whose rule carries this directive re-registers it. The three ways to adjust the document's contents, and which to reach for, are listed under Command line flags § --json-schema-export.

An extern with no JsonSchema impl also needs @custom_json on the types that embed it

This is the one sharp edge, and the error it produces does not point at it. @no_json_schema_export removes the extern's own row, but a generated type that embeds the extern still derives schemars::JsonSchema over that field — so the missing impl resurfaces as an E0277 on the parent, in a different file, naming a type you did not annotate:

quiet_ext = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export
plain_parent = [n: quiet_ext] ; E0277 — derives JsonSchema over quiet_ext
guarded_parent = [n: quiet_ext] ; @custom_json ; compiles — derives nothing JSON-related

The rule: @no_json_schema_export on the extern, plus @custom_json on every generated type that embeds it (and hand-written JSON impls for those parents, which is what @custom_json promises). That pairing is the normal shape for this case anyway — a type whose published JSON is produced by a hand-written parent impl is exactly the situation the directive exists for.

If instead the extern does implement schemars::JsonSchema and you only want it off the published surface, no parent annotation is needed.

Two embedding positions are exempt, because their schema never asks the field type for one, and both are the same position for the same reason: a map row's KEY domain. An open struct-map rest row's key, and both rows' keys in an open table. A row key is a JSON object member name, so the flattened region publishes an open object over the RANGE(s) — a shape that mentions the key type nowhere. holder = { 1: uint, * quiet_ext => uint } and tbl = { * quiet_ext => uint, * md => md } therefore compile with no @custom_json on them, and quiet_ext stays absent from the published document. The RANGES are not exempt — an open region names its value type, so a @no_json_schema_export value still needs the @custom_json pairing above.

It is valid on any rule that produces a rust type — an extern, a record, a sum type, a @newtype wrapper, a collection typedef, a generic instantiation, and a plain group that some rule splices:

grp = (a: uint, b: uint) ; @no_json_schema_export

On a rule that registers no rust type there is no row to suppress, so the directive would be silently dead and generation is rejected with an error naming the rule. That covers a plain transparent alias (foo = uint), a @no_alias alias, a named binding to a generic instantiation, a plain group nothing splices, and a generic definition — only its instantiations are types, and they do not inherit the directive, so annotate the instance:

gset<T> = [* T]
inst = gset<uint> ; @no_json_schema_export ; correct — the instance is the type

On a plain group, write the directive on the same line as the closing paren (grp = (a: uint) ; @no_json_schema_export). The MULTI-LINE spelling — closing paren on its own line — silently loses the directive (like @rust_name): the pinned CDDL parser binds that trailing comment to the following rule's leading-comment slot (or drops it as an orphan when the group rule is last), a position nothing reads, so the directive has no effect and nothing is reported. Use the single-line form. (There is no third lossy spelling: a comment runs to end of line, so putting a field-position comment before the closing paren on one line comments the paren out and the spec does not parse at all — a loud failure, not a drop.) A parser-side fix exists but is not adopted; the tracking entry in tests/TESTING_ROADMAP.md ("A rule-position directive is still silently LOST in the multi-line group-rule spelling") records its state.

A suppressed type still has a wasm to_json_value(), so the JSON → TypeScript scripts fail on it

The "does not change the wasm surface" bullet above is exact, and its consequence is worth spelling out because the failure it produces surfaces two build steps later. Under --wasm --json-serde-derives, a rule that mints a generated wasm class — a record, a type or group choice, a @newtype wrapper — declares to_json_value(): any in the wasm-pack .d.ts whether or not it carries this directive: that method is emitted off the derives, not off the schema row. (Rules that mint no class of their own are untouched by this: an _CDDL_CODEGEN_EXTERN_TYPE_ — the examples opening this section — has a hand-written wasm face, and a collection wrapper or a c-style enum declares no JSON method at all.)

That only bites when the type is also absent from the document's $defs, which takes the recipe in Command line flags § --json-schema-export: @custom_json on every rule that would reference it. A suppressed type that something published still references stays in $defs and its class is typed as usual. But suppressed and unreferenced is the directive's primary use case — a type whose real published JSON comes from a parent's hand-written impl — and there json-ts-types.js finds a class declaring to_json_value(): any with no <Class>JSON to splice in, and fails the run naming it — see Command line flags § --json-schema-scripts.

The remedy is --allow-untyped=<Class> on that script. Spelling it per class is what makes the duplication informative rather than noise: it is checked in both directions, so if the type later becomes reachable from a published root it is typed, and the stale-entry error tells you to drop the exception.

@custom_serialize / @custom_deserialize

custom_bytes = bytes ; @custom_serialize custom_serialize_bytes @custom_deserialize custom_deserialize_bytes

struct_with_custom_serialization = [
custom_bytes,
field: bytes, ; @custom_serialize custom_serialize_bytes @custom_deserialize custom_deserialize_bytes
overridden: custom_bytes, ; @custom_serialize write_hex_string @custom_deserialize read_hex_string
tagged1: #6.9(custom_bytes),
tagged2: #6.9(uint), ; @custom_serialize write_tagged_uint_str @custom_deserialize read_tagged_uint_str
]

This allows the overriding of serialization and/or deserialization for when a specific format must be maintained. This works even with primitives where CDDL_CODEGEN_EXTERN_TYPE would require making a wrapper type to use.

The string after @custom_serialize/@custom_deserialize will be directly called as a function in place of regular serialization/deserialization code. As such it must either be specified using fully qualified paths e.g. @custom_serialize crate::utils::custom_serialize_function, or post-generation it will need to be imported into the serialization code by hand e.g. adding import crate::utils::custom_serialize_function;.

With --preserve-encodings=true the encoding variables must be passed in in the order they are used in cddl-codegen with regular serialization. They are passed in as Option<cbor_event::Sz> for integers/tags, LenEncoding for lengths and StringEncoding for text/bytes. These are the same types as are stored in the *Encoding structs generated. The same must be returned for deserialization. When there are no encoding variables the deserialized value should be directly returned, and if not a tuple with the value and its encoding variables should be returned. When the custom wire's framing is not the replaced type's — most sharply when the replaced type carries its own encodings and so demands none — declare it instead: see Declaring the wire's encoding variables below.

There are two ways to attach this comment DSL:

  • Type level: e.g. custom_bytes. This will replace the (de)serialization everywhere you use this type.
  • Field level: e.g. struct_with_custom_serialization.field. This will entirely replace the (de)serialization logic for the entire field, including other encoding operations like tags, .cbor, etc.

Example function signatures for --preserve-encodings=false for custom_serialize_bytes / custom_deserialize_bytes above:

pub fn custom_serialize_bytes<'se>(
serializer: &'se mut cbor_event::se::Serializer,
bytes: &[u8],
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>

pub fn custom_deserialize_bytes(
raw: &mut cbor_event::de::Deserializer,
) -> Result<Vec<u8>, DeserializeError>

Example function signatures for --preserve-encodings=true for write_tagged_uint_str / read_tagged_uint_str above:

pub fn write_tagged_uint_str<'se>(
serializer: &'se mut cbor_event::se::Serializer,
uint: &u64,
tag_encoding: Option<cbor_event::Sz>,
text_encoding: Option<cbor_event::Sz>,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>

pub fn read_tagged_uint_str(
raw: &mut cbor_event::de::Deserializer,
) -> Result<(u64, Option<cbor_event::Sz>, Option<cbor_event::Sz>), DeserializeError>

Note that as this is at the field-level it must handle the tag as well as the uint.

With --canonical-form=true every serialize function takes one further trailing argument, force_canonical: bool, which must be passed on to the encoding helpers it calls (StringEncoding::to_str_len_sz(len, force_canonical), fit_sz(len, sz, force_canonical), …) so that the canonical output re-minimizes the same way generated code does. The deserialize signature is unchanged.

Declaring the wire's encoding variables (@custom_encodings)

The paragraph above derives the encoding variables a codec is handed from the replaced type. That only produces the right list when the custom wire's framing happens to match the replaced type's own, slot for slot — and when the replaced type is self-carrying (a _CDDL_CODEGEN_EXTERN_TYPE_, a named record, bool, any, a null-fixed) it demands none at all, so the custom wire's framing would be recorded nowhere and the round trip would silently normalize it.

@custom_encodings lets the pair declare what its own wire needs, and the declaration then drives the signatures and the *Encoding slots everywhere, instead of inference:

an = _CDDL_CODEGEN_EXTERN_TYPE_
an_v1 = an ; @custom_serialize write_asset_utf8 @custom_deserialize read_asset_utf8 @custom_encodings str

The argument is a comma-separated list of kinds with no whitespace, or the keyword none:

kindrust typewhat it records
szOption<cbor_event::Sz>how an integer — or a tag head — was sized
strStringEncodinghow a text/bytes header was written (definite width, or the indefinite chunk lengths)
lenLenEncodinghow a container's length header was written
nonethe explicit empty list: this wire has no framing

The list is the codec-visible tuple, positionally: serialize receives the declared variables as trailing arguments in declared order (before force_canonical), and deserialize returns (value, declared…) in the same order. So the pair above is written:

pub fn write_asset_utf8<'se>(
serializer: &'se mut cbor_event::se::Serializer,
asset: &An,
name_encoding: &StringEncoding,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>

pub fn read_asset_utf8(
raw: &mut cbor_event::de::Deserializer,
) -> Result<(An, StringEncoding), DeserializeError>

Rules to keep in mind:

  • It requires both halves, in the same position. A declaration beside one half (or none) is a graceful rejection: the other direction would be generated code deriving the replaced type's inferred demand, which the declared slots contradict slot for slot.
  • It binds to the pair written beside it. A field-level pair overrides a type-level one, and each pair's declaration travels with it — a field-level pair without its own declaration gets inference over the field's type, never the shadowed alias's declaration, because that one describes a different codec's wire.
  • The declaration wins wherever it is present, including over a non-empty inference: an alias of bytes whose codec writes #6.42(text) declares sz,str and gets exactly those two slots.
  • Argument mode stays position-derived. sz and len are Copy and passed by value; str is passed by reference at a record field and by value at a table entry, exactly as the two positions rule already states. The declaration fixes the count and the types, never the mode.
  • Some slots are not yours to declare. A member's <field>_default_present flag and a map-record member's <field>_key_encoding are generated-code-owned and are never part of the codec's tuple; the declaration describes only what crosses the call.
  • Without --preserve-encodings it is inert — no encoding variable exists to declare — so one spec can serve both flag sets.

Stated limit: the aggregate kinds (the per-element Vec<…> / BTreeMap<…> sidecars a container's interior fidelity needs) and TagPresenceEncoding are not declarable, and the parser names the supported kinds when it rejects an unknown one. If your custom wire is a container whose per-entry encodings inference cannot supply, say so — that is the observation that would make us add them.

Because the declaration makes the framing-carrying spelling available, the un-declarable state is now refused rather than silently normalized: under --preserve-encodings, a pair over a type that demands no encoding variables and that carries no declaration is a graceful rejection. Write @custom_encodings <kinds> for a wire with framing, @custom_encodings none for one without, or — if the replaced type has one wire and needs no override at all — drop the pair and let the type's own impls own it. Without --preserve-encodings the same spec generates exactly as before.

Declaring the wire's major type (@custom_wire_major)

@custom_encodings declares what a codec's wire records; @custom_wire_major declares what it starts with — which of the eight CBOR major types its first data item is. It is the second member of the same wire-facts family, and it exists for the same reason: the pair owns the wire, so the generator's inference over the type the codec replaced answers about a wire nobody writes.

One reader consumes it: an open table's typed row, whose dispatch must know the claimed major before any deserializer runs.

policy_id = _CDDL_CODEGEN_RAW_BYTES_TYPE_
; the v1 wire writes the same value as lowercase hex TEXT
policy_id_v1 = policy_id ; @custom_serialize write_hex @custom_deserialize read_hex @custom_wire_major text
labels_v1 = { * policy_id_v1 => details, * metadatum => metadatum }

Without the declaration the generator would read bytes off the raw-bytes marker and dispatch major 2, so every policy_id_v1 key on the wire — text, major 3 — would fall through to the catch-all. That is the silent wrong answer the directive removes, which is why its absence at that position is a graceful rejection rather than a guess.

The argument is exactly one of the eight major-type tokens: uint, nint, bytes, text, array, map, tag, simple. An unknown token is a loud parse failure naming the set.

Rules to keep in mind:

  • It requires both halves of the pair, in the same position — the @custom_encodings contract verbatim, and for the same reason.
  • It is REQUIRED where a custom codec keys an open table's typed row, and a graceful rejection where nothing consumes it: a rule carrying the declaration that no typed row keys declares a fact about a wire no dispatch reads. Consumed somewhere is enough, so one alias may key an open table and also appear at an ordinary field.
  • It is refused on a rule that mints a struct. The declared major is read through the rule's transparent alias entry, which a struct-minting rule does not have.
  • A tagged typed key claims ALL tags. @custom_wire_major tag (like a plain tagged key type) claims major 6 in full — the dispatch is by major, not by tag number.
  • Unlike @custom_encodings it is not preserve-gated. The major is a fact about the wire under every flag set.

Open tables

A named rule of exactly two * k => v rows and nothing else is an open table:

t = { * K_t => V_t, * K_r => V_r }

One typed table row plus one trailing typed catch-all rest row. Entries are routed by wire major type, peeked before any deserializer runs: the typed row claims exactly its key's single statically-known major, and the catch-all sees only the complement. Once the major routes an entry, that row's K/V decide — a key or value its row refuses is a hard parse error, never a fall-through to the other row. (That is what makes the shape compose with a backtracking type choice: the error fails the enclosing arm, which rewinds.)

It lowers to a struct with two containersentries (the typed row) and rest (the catch-all), each @name-renameable, each carrying its own @duplicates policy and its own encoding sidecars. Neither is a new() argument; both default empty.

Spelling the typed row + (or 1*) makes it a NonEmpty open table:

t = { + K_t => V_t, * K_r => V_r }

The minimum of 1 counts typed entries — a map of purely captured entries is not a non-empty table — and it is enforced at exactly the doors that can break it: new(first_key, first_value) takes the first typed entry (so no constructed value violates the bound), and the CBOR and JSON readers each re-check after their loop, raising the same refusal NonEmptyMap's door raises. The catch-all keeps * in both flavors: + there would demand an entry the rule says nothing about, and is a graceful rejection saying so. * and 0* are the same unbounded row; every other marker (?, n*m, *n, n* with n≥2) is a real bounded cardinality this shape does not honor and is refused rather than widened.

K_t's major must be statically knowable, by one of two routes:

  • if the key's alias chain carries a @custom_serialize/@custom_deserialize pair, its @custom_wire_major declaration is the answer — and is required;
  • otherwise the key's type must admit exactly one major. Primitives, primitive-bodied aliases, raw-bytes markers and aliases of them, .size-constrained bytes and tagged types qualify. A plain extern, Int, a multi-major union, any and an optionally-tagged type admit more than one and are refused, with the message naming both routes.

A catch-all whose admissible majors the typed row already exhausts is refused too — it could never capture an entry.

Its JSON face is one flattened object holding both rows, read back typed-first — worth reading before choosing a K_t, because a key type admitting every member name leaves the catch-all unreachable through JSON. Where that is decidable the tool refuses rather than ships it: a K_t transparently resolving to String (bare text, or an alias of it) is a graceful rejection under --json-serde-derives/--json-schema-export, and a supported shape without them.

Not supported (each a graceful rejection naming the remedy): more than two rows, or two rows mixed with fixed keys; an inline anonymous spelling (f: { * k1 => v1, * k2 => v2 } — give it its own named rule); any on the typed row (it would claim all eight majors, leaving the catch-all nothing — any belongs on the catch-all); a null-admitting key on either row; @ignore on either row; and + on the catch-all row (the min-1 counts typed entries).

Overriding the wire of a type this crate does not define

A type-level pair is honored wherever the alias resolves — and an alias may resolve to a type this crate does not define. Writing the pair on an alias whose body references a _CDDL_CODEGEN_RAW_BYTES_TYPE_ or _CDDL_CODEGEN_EXTERN_TYPE_ rule is therefore how you say "this rule is that type, written differently on the wire":

policy_id = _CDDL_CODEGEN_RAW_BYTES_TYPE_
; the CIP-25 v1 rendering: the same 28-byte policy id, written as hex text
policy_id_v1 = policy_id ; @custom_serialize write_policy_hex @custom_deserialize read_policy_hex

t = { * policy_id_v1 => uint }

emits pub type PolicyIdV1 = PolicyId; — no wrapper type is minted, so a value is the hand-written type itself, in memory and across every API — while the pair owns the wire at every position the alias reaches (a record field, both halves of a table, a rest row's key domain). The division of labor is the point: the CDDL body states what the thing is (the semantic identity, and the Rust type you get), and the pair states how it is written here. Two spellings of one wire are then two aliases of one rule.

This is why the pair is refused on the marker rule itself: that rule names a type whose own impls own its wire. The alias is the spelling that leaves that type alone.

Which signature the codec gets follows the encoding-variable rules above, applied to the type the alias resolves to:

  • a raw-bytes marker demands exactly one StringEncoding — the same slot a string-framed custom wire (hex text, bech32, base64) needs — so this flavor infers its signature and needs no declaration;
  • a plain extern is self-carrying and demands nothing, so under --preserve-encodings its alias must declare its wire with @custom_encodings (an undeclared pair there is a graceful rejection, not a silent normalization);
  • without --preserve-encodings no encoding variable exists at all and both flavors get the plain signatures.

Two further consequences worth knowing before you reach for this:

  • The two positions rule applies unchanged: a record field passes its stored encoding by reference and a table entry's by value, so an alias reached from both needs two codec functions. Sharing one is a compile error in the generated crate (an E0308 naming &StringEncoding against StringEncoding), not a silently different wire.
  • Such an alias does not travel across the extern-interface seam: like any transparent alias carrying @custom_serialize, it is recorded as an ; unexported: row, because a consumer given the plain alias would emit default wire logic. The marker rule itself exports normally.

For executed vectors see tests/alias-of-marker-e2e (the raw-bytes flavor, both table positions plus a record field, byte-exact under --preserve-encodings --canonical-form) and tests/custom-encodings-e2e (the extern flavor, with its declaration).

Table key and value positions

A type-level pair is honored wherever the alias resolves, which includes both halves of a table:

hex_table_str = bytes ; @custom_serialize write_hex_table_string @custom_deserialize read_hex_table_string

custom_table_positions = [
keyed: { * hex_table_str => uint },
valued: { * uint => hex_table_str },
]

Here the key domain and the value range each go through the custom pair, so a bytes key can be written as hex text on the wire while staying decoded bytes in memory. Only the type-level spelling reaches these positions — a table row has no field to hang a field-level directive on (see the un-honored positions below).

An open struct-map rest row ({ 1: uint, * k => v }) is the same two positions, honored the same way in both directions. A custom key codec is in fact what decides how the row reads its keys: a row whose domain carries one is read by that codec rather than rebuilt from the decode loop's own key dispatch, which is what keeps the row's write and read halves symmetric.

Two details specific to a table under --preserve-encodings=true:

  • The per-entry encoding is looked up out of the table's sidecar map and passed by value (StringEncoding), where a record field passes its stored encoding by reference (&StringEncoding). The two positions therefore need separate functions if you want to share one codec.
  • Both sidecars (<field>_key_encodings and <field>_value_encodings) are BTreeMaps keyed by the decoded key. A custom key deserializer's returned encoding is filed under the value it decoded to, not under the bytes it consumed, and the value sidecar is likewise keyed by the entry's key rather than by the value.

Under --canonical-form=true a table's keys are additionally serialized into a scratch buffer to sort by their encoded bytes, so a custom key serializer is called twice per entry (once for the sort key, once for the write) and must be deterministic. A custom value serializer is only on the write path.

Positions that are rejected

The pair parses in the positions below but cannot be honored there, so each one is a graceful generation error naming the spelling that works. The common cause is that the pair is a type-level override: it replaces the codec of the rust type the rule resolves to. Each of these positions either deletes the node the override is keyed on, or mints a type whose generated impls the override does not displace.

  • On a _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule. Either marker names a type this crate does not define, so that type owns its own (de)serialization impls and there is nothing here for the pair to displace. Two spellings work instead, and which one you want depends on whether the Rust type is to change: give the rule a real CDDL body and put the pair there (that also keeps the wire type stated in the spec), or keep this rule as the marker and put the pair on an alias of it (policy_id_v1 = policy_id ; @custom_serialize …), which keeps the marker's Rust type and overrides only how it is written — see Overriding the wire of a type this crate does not define.
  • On a table row, an open-map rest row, or an open-array rest tail entry — a trailing comment on the * k => v / * t row itself. What a row slot carries is row-scoped (an open-map rest row takes @name, @duplicates and @ignore; an anonymous inline table's row takes @duplicates; a NAMED table's row takes nothing — its policy lives at the rule slot), and the pair names a type the row does not declare. Put it on the row's key, value, or element rule instead.
  • Together with @no_alias. @no_alias removes the alias node the override is keyed on, so the pair would go with it and both directions would fall back to the default wire format.
  • Together with @newtype. A @newtype wrapper writes through its own generated Serialize impl while the deserialize call sites do route through the custom reader, so the pair would make the wrapper read one wire format and write another. Use the plain alias spelling, or declare the type _CDDL_CODEGEN_EXTERN_TYPE_ and hand-write it in full.
  • On a rule that mints a TAGGED wrapper without the directive — a tag-head rule (foo = #6.42(uint)) and the tag-258 set idiom (foo = #6.258([* uint]) / [* uint], which nominalizes into a set wrapper). Structurally the @newtype case above, minus the directive: the same generated-Serialize/custom-reader asymmetry, so either half is refused. Put the pair on the alias the tag rule wraps (inner = uint ; @custom_serialize … beside foo = #6.42(inner)), which keeps the tag framing generated and overrides only the payload — see Reaching an annotated alias through another rule below. Or, to own the whole wire yourself, declare the rule _CDDL_CODEGEN_EXTERN_TYPE_ and hand-write the type, or give it a body that resolves to a transparent alias and write the wire framing (the tag included) in your own codec.
  • On a rule that binds a generic instantiationfoo = base<uint>, and the named-binding-to-a-set-nominal form of the same shape. The type is built during generic resolution from the definition's config, so a pair written on the binding never reaches generation and both directions keep the definition's generated codec. Moving it to the definition does not work either (a generic definition names no concrete type and is refused for that reason): give the rule a CDDL body of its own, or declare it _CDDL_CODEGEN_EXTERN_TYPE_.
  • On an enum rule — a type choice (a / b), a group choice ({ … } // { … }), or a fixed-value C-style enum (0 / 1) — in every spelling, one half or both. The enum's serialize side is generated unconditionally while the deserialize call sites do route through the custom reader: the same read-one-format/write-another asymmetry as @newtype. Put the pair on the rule of the variant type that needs the custom format.
  • A single half on a named record rule. @custom_serialize alone emits no Serialize impl and never calls the named function, so the generated crate does not compile; @custom_deserialize alone keeps the type's own generated Deserialize impl while rewriting every embed site, so Foo::from_cbor_bytes and a field of type Foo decode the same bytes differently — a divergence that also travels to consumers, since such a rule projects opaquely across the extern-interface seam. Move the pair to the field, or to the type rule of the member that needs it.
  • On a table rulet = { * k => v } ; @custom_serialize …, the pair on the rule rather than on the row (which is the separate rejection above). A table lowers to a transparent map alias that owns no codec for the directive to override, so — unlike a record rule, where both halves together suppress the generated impls for you to hand-own — there is nothing for either half to displace and any presence is refused, one half or both. Put it on the key or value rule (see Table key and value positions) instead, or declare the rule _CDDL_CODEGEN_EXTERN_TYPE_ and hand-write the whole type. On a generic table def the refusal names the instantiated type (ptbl<uint, bytes>PtblU64Bytes), since that is where the struct materializes.

The @custom_encodings declaration is refused in three further places, each because it would otherwise be read and then dropped:

  • Without both halves of the pair, in the same position — see the rule above. This covers every slot the declaration can be written in: a rule, a field, and the row-entry slots the pair itself is refused in.
  • On a rule that mints a struct — including the one rule-position pair that is accepted, both halves on a record rule. A struct carries its encoding metadata inside itself (its encodings member) and hands no encoding tuple across the call, so there is nothing for a declaration to describe. Put it beside a field's pair, or on a transparent alias rule's pair.
  • Everywhere the pair is already refused (the list above), where the pair's own rejection fires and names the spelling that works.

@custom_wire_major is refused in the same three places, plus one of its own: on a rule nothing consumes it from — the declared major is read only where a rule keys an open table's typed row, so a declaration no such row reaches states a fact about a wire no dispatch reads.

Reaching an annotated alias through another rule

An annotated alias has one wire form however it is reached. Every context that reaches it — a plain member, a rule that wraps it, or another name for it — routes through the pair in both directions, and owns only the framing it declares itself:

inner = uint ; @custom_serialize my_ser @custom_deserialize my_deser
tagged = #6.42(inner) ; wraps it in a tag head
payload = [f: bytes .cbor inner] ; wraps it in a CBOR byte string
renamed = inner ; another name for it
via_renamed = [r: renamed]
direct = [d: inner]

Tagged writes tag 42 and then calls my_ser; it reads tag 42 and then calls my_deser. payload.f writes the byte string whose contents my_ser produced, and reads my_deser from a reader over those contents. via_renamed.r and direct.d call the same pair with no framing at all. The wrapping rules add exactly the framing their own rule declares, and nothing else. This is why the tagged-wrapper rejection above names the tag spelling as the remedy for putting the pair on the tag rule itself. (A bytes .cbor inner rule body is a wrapper too — body = bytes .cbor inner mints a struct whose codec writes the byte string around my_ser's output — so it behaves exactly like Tagged here rather than like renamed.)

The alias's own standalone codec is the target type's, not the pair

The pair replaces the codec of the type the alias resolves to, at every position that reaches the alias — a member, an arm, a wrapping rule. It does not give the alias a type of its own, so inner above stays a transparent pub type Inner = u64; and Inner::to_cbor_bytes() / Inner::from_cbor_bytes() are u64's built-in codec, not my_ser / my_deser. Every embed site of inner uses the pair, so a crate can hold both wire forms for one CDDL name, selected by whether you called the standalone entry point or went through a holder. The transparent spelling is deliberate — it is what leaves the target's Rust type alone, and it is the remedy this page prescribes for several refusals above — so if you need one wire form everywhere including standalone, declare the rule _CDDL_CODEGEN_EXTERN_TYPE_ and hand-write the type, or give the pair a wrapping rule to sit under and use that name. (The same reason makes such an alias an ; unexported: row across the extern-interface seam.)

The declared spelling follows the same ownership line, as output_format states in full: Tagged wraps an Inner, and payload.f is typed Inner — the .cbor there belongs to the member's own type expression, so the alias still names the value inside the byte string.

renamed inherits inner's wire facts — the pair and any @custom_encodings/@custom_wire_major written beside it — because a rule that is just another name for a type is a name for its wire too. A rule that writes its own pair inherits nothing: its declaration describes its whole wire.

Under --preserve-encodings the split follows the same line: the framing a wrapping rule declares — the tag's Sz, or the byte string's own length encoding — stays a generated-code-owned slot on the owner's encoding struct, and the payload's encoding variables are the ones that cross the call — declared by the alias's @custom_encodings if it carries one, inferred from the aliased type otherwise.

Positions that are still silent

The positions below are accepted and unhonored. Honoring or refusing each needs a design decision rather than a call-site fix, so treat the current silence as unspecified rather than as a guarantee.

  • On the row of an inline anonymous map — the pair in the row-entry comment slot of a { * k => v } map written directly in a member position (f: { … }) rather than as a named table rule. The named-rule form of the same slot is rejected (above); the inline form is not reached and stays silent. (Spelling it inline would also trip the end-of-line comment trap — the comment swallows the closing } — one more reason the slot has no valid use.) Name the map as its own rule to get the diagnostic, and put the pair on its key or value rule.
  • BOTH halves on a named record rule. Unlike the single-half spellings (rejected, above), this one is accepted and does something specific: the type's generated Serialize and Deserialize impls are suppressed for you to write by hand, while every embed site's deserialize is rewritten to call the named reader and every embed site's serialize goes through your hand-written impl — so the serialize function you named is never called by generated code. This is unspecified and may change; _CDDL_CODEGEN_EXTERN_TYPE_ is the supported road for hand-owning a whole type.

For more examples see tests/custom_serialization (used in the core and core_no_wasm tests), tests/custom_serialization_preserve (used in the preserve-encodings test, including the table key/value positions above) and tests/custom_serialization_canonical (used in the custom-serialize-canonical-e2e test, for the force_canonical signatures).

@doc

This can be placed at field-level, struct-level, variant-level or rule-level (including plain alias rules and @newtype rules) to specify a comment to be placed as a rust doc-comment.

docs = [
foo: text, ; @doc this is a field-level comment
bar: uint, ; @doc bar is a u64
] ; @doc struct documentation here

docs_groupchoice = [
; @name first @doc comment-about-first
0, uint //
; @doc comments about second @name second
text
] ; @doc type-level comment

Will generate:

/// struct documentation here
#[derive(Clone, Debug)]
pub struct Docs {
/// this is a field-level comment
pub foo: String,
/// bar is a u64
pub bar: u64,
}

impl Docs {
/// * `foo` - this is a field-level comment
/// * `bar` - bar is a u64
pub fn new(foo: String, bar: u64) -> Self {
Self { foo, bar }
}
}

/// type-level comment
#[derive(Clone, Debug)]
pub enum DocsGroupchoice {
/// comment-about-first
First(u64),
/// comments about second
Second(String),
}

On an alias rule the doc lands on the emitted pub type, composed with any note the generator synthesizes itself (e.g. the non-empty [+ T] bound note) — user doc first, mechanical note after:

positive_coin = coin ; @doc Does not enforce > 0: plain u64 alias for API convenience.
/// Does not enforce > 0: plain u64 alias for API convenience.
pub type PositiveCoin = u64;

A dataless (C-style) enum — a type choice whose arms are all fixed values — carries docs the same way. On any type-choice rule the rule-level doc slot is the last arm's trailing comment, so a single @doc there documents both the enum and that arm's variant:

network = 0 ; @name mainnet @doc the production network
/ 1 ; @name testnet @doc the test network
/// the test network
#[derive(Copy, Eq, PartialEq, Ord, PartialOrd, Clone, Debug)]
pub enum Network {
/// the production network
Mainnet,
/// the test network
Testnet,
}

Due to the comment dsl parsing this doc comment cannot contain the character @.

Known limitation: on a type choice of fixed values only (a dataless enum, e.g. foo = 0 / 1), per-variant @doc is currently not emitted; it works on data-carrying variants (e.g. uint / tstr).

Plain groups nothing splices

A plain group rule (foo = (a: uint, b: uint)) becomes a Rust type only when some rule splices it — holder = [foo], holder = {foo}. A group no rule splices emits neither a struct nor a field, so every rule-position directive written on it is inert, and writing one is a graceful generation error naming each directive it found:

foo = (a: uint, b: uint) ; @custom_json
holder = [z: uint]

@custom_json on 'foo': the plain group 'foo' is never spliced into any rule, so it materializes no rust type and no fields …

Splice the group into a rule that materializes it, or remove the directive. Once spliced, the same slot is where a rule-position directive on a group is read: @custom_json and @used_as_key land on the record struct the splice mints, @doc documents it, @name renames the last field, and a directive that is invalid on a record (@copy, @extern_companions, …) gets its own specific rejection naming its own remedy.

Two directives keep their own messages instead of joining this one, so a single misplacement is reported once: @name (its long-standing "does not rename a top-level rule or group" rejection) and @no_json_schema_export (whose own rejection already names this shape). @rust_name is left alone — it is honored on a rule in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_ scope, spliced or not.

CDDL_CODEGEN_EXTERN_TYPE

While not as a comment, this allows you to compose in hand-written structs into a cddl spec.

foo = _CDDL_CODEGEN_EXTERN_TYPE_
bar = [
x: uint,
y: foo,
]

This will treat Foo as a type that will exist and that has implemented the Serialize and Deserialize traits, so the (de)serialization logic in Bar here will call Foo::serialize() and Foo::deserialize().

Under --json-serde-derives the type must additionally implement serde::Serialize/serde::Deserialize, and under --json-schema-export also schemars::JsonSchema, since the generated code delegates its JSON representation to the user type (and --json-schema-export emits a schema-registration row for each such extern). Whenever the extern is embedded in a generated struct, that struct's derive(JsonSchema) already forces the impl, so this is no new burden in the common case. The generic BASE of an extern generic (Foo in foo<T>) gets no schema row — a bare Foo names no concrete type — while each concrete instance (FooU64 from foo<uint>) and every plain extern does.

That impl's schema_name() must be unique within the crate, and for a GENERIC extern it must vary with the parameters:

impl<T: schemars::JsonSchema> schemars::JsonSchema for Foo<T> {
fn schema_name() -> std::borrow::Cow<'static, str> {
format!("Foo_{}", T::schema_name()).into()
}
// …
}

The reason is that schema_id() defaults to schema_name(), and schemars keys its whole identity decision on the id: a hand-written impl returning a constant name makes Foo<u64> and Foo<String> the same type, so the document emits one definition and every reference to the other instantiation silently resolves to it. The minimal correct impl — implement the two required members and nothing else — is exactly the spelling that hits this. Violating it is a hard failure of the json-gen run (cddl-codegen --json-schema-export: two distinct Rust types both publish the JSON schema name "Foo": …), naming both offenders; see --json-schema-export for the second message, which fires when a registered type is pushed onto an order-dependent <name>2 by a type that has no row of its own. This can also be useful when you have a spec that is either very awkward to use (so you hand-write or hand-modify after generation) in some type so you don't generate those types and instead manually merge those hand-written/hand-modified structs back in to the code afterwards. This saves you from having to manually remove all code that is generated regarding Foo first before merging in your own.

This also works with generics e.g. you can refer to foo<T>. As with other generics this will create a pub type FooT = Foo<T>; definition in rust to work with wasm-bindgen's restrictions (no generics) as on the wasm side there will be references to a FooT in wasm. The wasm type definition is not emitted as that will be implementation-dependent. For an example see extern_generic in the core unit test.

Where to put your definition: in a hand-written module re-exported at the crate root — the tool emits pub use crate::Foo; glue into the declaring scope's generated module so every generated reference resolves back to it. See Output format § Generated crate roots for the full contract (rust and wasm sides).

@raw_bytes_flavor

A bare tag valid only on a _CDDL_CODEGEN_EXTERN_TYPE_ generic. It lets one extern generic reference a second, differently-bounded wrapper for the instances whose element is a raw-bytes type:

ext_set<T> = _CDDL_CODEGEN_EXTERN_TYPE_ ; @raw_bytes_flavor

When an instance's argument resolves to a _CDDL_CODEGEN_RAW_BYTES_TYPE_ (e.g. x = ext_set<pub_key>), the monomorphized alias references the convention-named <ExternName>RawBytes flavor instead of the plain name — pub type ExtSetPubKey = ExtSetRawBytes<PubKey>; — and the crate-root re-export glue emits pub use crate::ExtSetRawBytes; alongside the base pub use crate::ExtSet;. Every instance whose arguments are all non-raw-bytes keeps the plain Foo<T> name.

This exists because a raw-bytes type implements RawBytesEncoding (the caller frames its CBOR bytes) rather than the CBOR Serialize/Deserialize traits, and a single wrapper type cannot serve both element contracts at once — impl<T: Serialize> Serialize for Foo<T> and impl<T: RawBytesEncoding> Serialize for Foo<T> are conflicting blanket impls. The <ExternName>RawBytes flavor is a second, separately-bounded user-owned wrapper for exactly the raw-bytes instances.

The tag is opt-in and never automatic: a consumer whose single wrapper is bound solely on RawBytesEncoding already compiles today under the plain name, so flavoring the plain name whenever the two markers compose would silently break that working output. The tag takes no name argument — the wrapper is user-owned, so any preferred name is one pub type <ExternName>RawBytes<T> = Whatever<T>; alias away.

Generic is required. Using it on any rule other than a _CDDL_CODEGEN_EXTERN_TYPE_ definition is a hard error, and so is using it on an extern that declares no generic parameters (foo = _CDDL_CODEGEN_EXTERN_TYPE_ ; @raw_bytes_flavor): the tag flavors generic instances, so a base with no parameters has no instances to flavor and the tag could only sit there inert. Declare the parameters or drop the tag.

Seam projection. Unlike @copy, this tag does not travel the extern-interface export. The export renders every rule body as a bare marker and drops generic parameters, so a flavored base projects as the param-less ext_set = _CDDL_CODEGEN_EXTERN_TYPE_ — a form the tag cannot be honored on, and the exact spelling the hard error above refuses. A --extern-import consumer therefore sees the base without the tag, which costs it nothing: it cannot instantiate a param-less base either way. Flavoring across a crate boundary means declaring the extern in the consumer's own spec.

@copy

A bare tag valid only on a _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule. It declares that the externally-defined rust type derives Copy:

hash = _CDDL_CODEGEN_RAW_BYTES_TYPE_ ; @copy

Without it, the generator emits a defensive .clone() at every boundary that moves the value — map-key deserialize loops (table.insert(key.clone(), …)), wasm getters (self.0.field.clone().into()), optional getters (self.0.field.clone().map(Into::into)), list indexed getters (self.0[index].clone().into()), and enum-variant accessors ((hash).clone().into()). Downstream clippy::clone_on_copy then flags each. With @copy, those clones are dropped: the value copies instead (table.insert(key, …), self.0.field.into(), self.0.field.map(Into::into), self.0[index].into(), (*hash).into()).

Only the rust face changes. The type's wasm face is a distinct #[wasm_bindgen] wrapper that is not Copy, so the wasm→rust boundary keeps its clone (constructor/setter params) — @copy never drops a clone that would move out of a borrowed wasm value.

Honesty assertion. The declaring crate emits a compile-time Copy assertion for each @copy type (a _assert_copy::<T>() bound-carrier in generated/extern_interface_check.rs), so a false @copy — a type that does not actually derive Copy — fails that crate's own build with a named error, never a distant consumer's.

Seam projection. @copy travels the extern-interface export (extern-interface/<dep>/**), so a --extern-import consumer that imports the type inherits its Copy-ness and drops the same boundary clones. It can, because it describes the base type — the export's param-less rendering carries it faithfully. (@raw_bytes_flavor describes a generic instance instead, which is why it does not travel; see its section above.)

Using it on any rule other than a _CDDL_CODEGEN_EXTERN_TYPE_ / _CDDL_CODEGEN_RAW_BYTES_TYPE_ definition (or at a field position) is a hard error.

@extern_companions

Valid only on a non-generic _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule declared in this crate's own spec (not in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_ scope). Both markers name a type this crate does not define while the generator still mints that type's structural companion classes, so both can collide with a sibling crate's class of the same name. The directive declares that specific structural wasm companion classes of that type already exist in a sibling wasm crate, so the generator references them instead of minting its own:

transaction_metadatum = _CDDL_CODEGEN_EXTERN_TYPE_ ; @extern_companions cml_chain_wasm=TransactionMetadatumList

; identical contract on the raw-bytes marker — a hash type whose `<Name>List` the sibling owns
pub_key = _CDDL_CODEGEN_RAW_BYTES_TYPE_ ; @extern_companions cml_chain_wasm=PublicKeyList

The generated wasm crate then emits use cml_chain_wasm::TransactionMetadatumList; and mints no local class of that name. The argument is <use_path_prefix>=<Class>[,<Class>…] — one prefix (a rust path, emitted verbatim as the use head) and a comma-separated list of class names, with no whitespace anywhere in the argument.

The problem it solves

When a rule declares an extern or raw-bytes type, the generator still owns that type's structural companions — the <Elem>List, Map<K>To<V> and their siblings it synthesizes for [* t] / {* k => v} shapes. Each is a #[wasm_bindgen] class. If the sibling crate that defines the extern type also defines a class of the same structural name (typically the canonical <Name>List), and both wasm crates link into one cdylib, the link fails:

rust-lld: error: duplicate symbol: __wbg_transactionmetadatumlist_free
>>> defined in …/cml_cip25_wasm….rcgu.o
>>> defined in …/libcml_chain_wasm.rlib(…)

Renaming one side (via @rust_name on the extern rule) removes the collision but mints a second JS class for one concept, with no type identity against the sibling's own API — a value obtained from the sibling cannot be handed back to it. Referencing the sibling's class is the fix that keeps one concept, one class.

The companion class contract

The referenced class is not checked by the tool — it is the consumer's own compile that checks it, exactly like the extern marker itself: an absent or wrongly-shaped class fails the emitted use (or the conversion at its use site) loudly and near. What the generated code requires of it:

  • It is public at <prefix>::<Class> and is a #[wasm_bindgen] class (so JS sees one class, and so the ABI symbols it defines are the ones this crate no longer defines).

  • It derives (or implements) Clone — every boundary crossing clones before converting.

  • From<Inner> for <Class> and From<<Class>> for Inner, plus AsRef<Inner>, where Inner is the rust core type of the shape the class stands for, over the same rust element type this crate uses (i.e. the sibling's own rust type, which this crate re-exports for the marker rule):

    Deferred classInner
    <Elem>List ([* elem])Vec<Elem>
    NonEmpty<Elem>List ([+ elem])NonEmptyVec<Elem>
    Map<K>To<V> ({* k => v})BTreeMap<K, V>, or OrderedHashMap<K, V> under --preserve-encodings
    PairMap<K>To<V> ({* k => v} ; @duplicates preserve)PairMap<K, V>
    NonEmptyMap<K>To<V> / NonEmptyPairMap<K>To<V> ({+ …})NonEmptyMap<K, V> / NonEmptyPairMap<K, V>

    A From<Vec<_>> is what a table's keys() accessor uses (…collect::<Vec<_>>().into()): a cross-crate wrapper's tuple field is private, so the deferred class is never built by tuple-struct syntax.

  • Its JS-visible method surface should match what the generator would have emitted (new/len/get/add for a list; new/len/insert/get/keys for a map) — nothing in the generated rust depends on this, but JS callers of this crate's API do. In CML's runtime the impl_wasm_list! macro produces exactly this shape.

Only listed classes defer

The class list is a filter, not a blanket opt-out. An unlisted structural companion of the same extern type still mints locally — which is what lets a crate borrow the family the sibling owns and keep the one it doesn't:

transaction_metadatum = _CDDL_CODEGEN_EXTERN_TYPE_ ; @extern_companions cml_chain_wasm=TransactionMetadatumList

; keys() returns the SIBLING's TransactionMetadatumList …
; … while PairMapTransactionMetadatumToTransactionMetadatum is minted here (the sibling's map class
; is hand-named something else, so there is no collision to avoid)
meta = {
1: uint,
* transaction_metadatum => transaction_metadatum ; @duplicates preserve
}

A wrapper defers only when every named constituent resolves to the declaring extern type. A mixed shape ({* transaction_metadatum => local_thing}) is not "of" that type and mints locally even if its structural name is listed.

Manifest and flag notes

  • The wasm crate's dependency on the sibling is yours to declare. The tool adds nothing to wasm/Cargo.toml for this directive (pass --wasm-dep=<pkg>=<path> if you want the tool to assert the entry). This mirrors the extern re-export contract: the directive states a fact about your workspace, it does not create one.
  • Inert without --wasm. The classes it governs are a wasm-boundary concern; a rust-only build mints none, so the directive changes nothing. One spec, many flag sets.
  • The prefix is emitted as written, with one remap. If its leading component names a declared extern dependency, --extern-wasm-crate rewrites that component to the dependency's wasm crate — the same rule every cross-crate import obeys. It only bites when you spell a dependency's rust crate name here, where the remap yields the wasm crate you meant anyway; spell the wasm crate directly and nothing rewrites.
  • @duplicates reject set wrappers cannot be deferred. The <Elem>OrderedSet / NonEmpty<Elem>OrderedSet emitters consult no deferral seam, so listing such a name has no effect today. Every other structural family (loose/restricted lists, loose/restricted maps, both duplicate flavors) defers.

When to use this vs the dependency-keyed flags

SituationMechanism
The type is declared in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_/<dep>/ scope (or via --extern-import) and the dep's own generation minted the wrapper--extern-wrapper-index=<dep>=<dep>/wasm/src/generated/collections.rs — the dep's committed index is consulted, so the tool knows the class exists
Same, and the whole workspace regenerates together--workspace-dep=<dep> — defers unconditionally and records a request sidecar so the dep mints what you need
The type is marked extern or raw-bytes in this crate's spec (a one-type re-export of a sibling's type), and/or the sibling's class is hand-written so no generated index lists it@extern_companions

The first two key on the constituents' owning dependency, which a locally-declared marker does not have; and an index can only list what generation minted. Using @extern_companions on a dep-scoped rule is a hard error naming both flags, so the two never compete for one decision.

Rejected positions

Using it anywhere other than a locally-scoped non-generic _CDDL_CODEGEN_EXTERN_TYPE_ / _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule is a hard error: on a rule this crate generates (a record, a type/group choice, a collection) — such a rule owns the companions it mints, so there is nothing to borrow — at a field/member position, on a non-last arm of a multi-choice type rule, and on a dep-scoped extern. A generic extern base (foo<T> = _CDDL_CODEGEN_EXTERN_TYPE_) is rejected too: every companion class is named from the ident at the use site, so an instance i = foo<uint> used as [* i] mints IList and never FooList — a deferral declared on the base is looked up under a name nothing asks for. Declare the concrete shape as its own non-generic extern rule and put the deferral there. (The raw-bytes marker needs no such rule of its own: a generic _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule is rejected outright, directive or not — see its section below.) A malformed argument (missing, no =, a non-path prefix, an empty or non-identifier class name) is a loud parse-time failure rather than a silent drop — a silently dropped declaration re-mints the very classes it exists to suppress, and the only symptom would be a duplicate symbol in a different crate's link. Finally, if a listed class name is also defined by a rule in this crate, generation fails naming both: the use and the rule would claim one identifier.

CDDL_CODEGEN_RAW_BYTES_TYPE

Allows encoding as bytes but imposing hand-written constraints defined elsewhere.

foo = _CDDL_CODEGEN_RAW_BYTES_TYPE_
bar = [
foo,
]

This will treat foo as some external type called Foo. This type must implement the exported (in serialization.rs) trait RawBytesEncoding. The contract under the json flags is the same as _CDDL_CODEGEN_EXTERN_TYPE_'s above: serde::Serialize/serde::Deserialize under --json-serde-derives, plus schemars::JsonSchema under --json-schema-export, since the generated code delegates its JSON representation to the user type — including the obligation that the impl's schema_name() be unique within the crate, and that a GENERIC raw-bytes type's impl vary the name with its parameters (schema_id() defaults to schema_name(), so a constant name silently merges every instantiation into one published definition; the json-gen run fails naming both offenders). If that contract ever changes, both sections change together.

One position asks more of Serialize than "be implemented": a JSON object MEMBER NAME. A raw-bytes type keying a table ({* foo => v}), an open table's typed row or a typed rest row renders through serde_json's map-key serializer, which admits a string, an integer, a bool and a unit variant and refuses everything else — so a Serialize writing a byte SEQUENCE (the natural derive over a byte array, or a Vec<u8> passthrough) makes to_json fail at runtime on every such map, naming the key. Nothing checks this at compile time, and the CBOR face is unaffected, so a spec can pass every build and fail on its first JSON write. What satisfies it is what a hand-written raw-bytes type is expected to carry anyway: a string image — lowercase hex, bech32, base64 — with the matching Deserialize reading it back. The same obligation reaches a @newtype wrapper over bytes and any extern used in those positions.

This can be useful for example when working with cryptographic primitives e.g. a hash or pubkey, as it allows users to have those crypto structs be from a crypto library then they only need to implement the trait for them and they will be able to be directly used without needing any useless generated wrapper struct for the in between.

The wasm companion classes minted for the shapes it appears in ([* foo], {* foo => …}, …) are the generator's, exactly as for an extern type — so when a sibling wasm crate already publishes one of them, @extern_companions defers to it here under the identical contract.

Placement follows the same contract as _CDDL_CODEGEN_EXTERN_TYPE_: define the type in a hand-written module and re-export it at the crate root — the tool emits pub use crate::Foo; glue into the declaring scope's generated module (this matters especially when the rule is referenced only through pub type aliases, which have no other way to resolve). The wasm crate mirrors this: define the #[wasm_bindgen] wrapper in a hand-written wasm-crate module and re-export it at the wasm crate root. See Output format § Generated crate roots.