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.
| Tag | Derives |
|---|---|
@used_as_key (bare) | Eq, PartialEq, Ord, PartialOrd (+ Hash under --preserve-encodings) |
@used_as_key hash | Hash, Eq, PartialEq |
@used_as_key ord | Ord, PartialOrd, Eq, PartialEq |
@used_as_key hash ord | the 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/textalias): such a list lowers to a bareVec<..>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 aholder = [ … ]) 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
@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:
| value | meaning |
|---|---|
preserve | accept duplicates on the wire and re-emit them byte-exactly (the contract is preservation, not merely "allow") |
reject | duplicates 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.
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
}
@namerenames the generated capture field (defaultrest) — here toextra. It renames only the field; the containing type keeps its rule name.@duplicatesselects the rest row's duplicate-key policy, exactly as for a standalone table: the default isreject(a repeated captured key is aDuplicateKeydecode error), and@duplicates preserveswitches the capture container to the byte-exactPairMap<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.
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:
@namerenames the captured tailVecfield (defaultrest) exactly as it renames the map capture field —* bytes ; @name extrasyieldspub extras: Vec<Vec<u8>>.@duplicatesdoes not apply to a rest tail and is rejected gracefully: an array tail is positional (Vecin 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.
@ignoreis 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
serializefn 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_deserializefor a genuine view type.@duplicateson the same entry: a duplicates policy governs a capture container, which@ignoredoes not create. (On an array rest tail@duplicatesis rejected for both flavors — a positionalVectail has no keys for a duplicate policy to govern.)@nameon the same entry: there is no capture field to rename.
- Typing is still enforced.
* uint => any ; @ignorestill errors on a text key (the spec said uint labels), and an ignored* texttail 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 alias — pub 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 thefoo = bartypename form); - a
T / nullrule, which collapses to anOption<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 @newtype — foo = 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:
- It is a crate-root macro of the crate hosting the
json_schema_genmodule. In-crate that is the generated rust crate itself, socrate::custom_schema_impl!(…). Under--common-import-override/--export-static-cratethe 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. - That hosting crate must have
json_schema_genreachable from its root, because the expansion reaches back into it. Under--export-static-cratethat is thepub mod json_schema_gen;you hand-declare (the tool's new-file notice names it). In-crate the tool declares the module insidesrc/generated/mod.rs, so root reachability comes from the seed-oncesrc/lib.rs'spub use generated::*;— a line you own after the first export. Narrowing that glob to a name list makes every invocation anE0433forjson_schema_gen, reported at the macro rather than at the edit that caused it. - The invocation must live in the crate that DEFINES the type, because
schemars::JsonSchemais a foreign trait and the orphan rule allows the impl nowhere else. For a generated type carrying@custom_jsonthat means a hand-owned module of the generated rust crate, declared from its seed-oncesrc/lib.rs— outsidesrc/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. 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 atcargo publish.- The invoking crate needs
schemarsreachable 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-exportthe generated rust crate already declaresschemars.
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 fromschema_name(), or aninline_schema()oftrue(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::Deserializeorschemars::JsonSchemaderives on a generated type. A parent that embeds the type still needsJsonSchemaon 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-exportis 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:
| kind | rust type | what it records |
|---|---|---|
sz | Option<cbor_event::Sz> | how an integer — or a tag head — was sized |
str | StringEncoding | how a text/bytes header was written (definite width, or the indefinite chunk lengths) |
len | LenEncoding | how a container's length header was written |
none | — | the 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
byteswhose codec writes#6.42(text)declaressz,strand gets exactly those two slots. - Argument mode stays position-derived.
szandlenareCopyand passed by value;stris 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_presentflag and a map-record member's<field>_key_encodingare generated-code-owned and are never part of the codec's tuple; the declaration describes only what crosses the call. - Without
--preserve-encodingsit 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_encodingscontract 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_encodingsit 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 containers — entries (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_deserializepair, its@custom_wire_majordeclaration 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,anyand 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-encodingsits alias must declare its wire with@custom_encodings(an undeclared pair there is a graceful rejection, not a silent normalization); - without
--preserve-encodingsno 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
E0308naming&StringEncodingagainstStringEncoding), 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_encodingsand<field>_value_encodings) areBTreeMaps 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/* trow itself. What a row slot carries is row-scoped (an open-map rest row takes@name,@duplicatesand@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_aliasremoves 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@newtypewrapper writes through its own generatedSerializeimpl 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@newtypecase 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 …besidefoo = #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 instantiation —
foo = 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_serializealone emits noSerializeimpl and never calls the named function, so the generated crate does not compile;@custom_deserializealone keeps the type's own generatedDeserializeimpl while rewriting every embed site, soFoo::from_cbor_bytesand a field of typeFoodecode 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 rule —
t = { * 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
encodingsmember) 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 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
SerializeandDeserializeimpls 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>andFrom<<Class>> for Inner, plusAsRef<Inner>, whereInneris 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 class Inner<Elem>List([* elem])Vec<Elem>NonEmpty<Elem>List([+ elem])NonEmptyVec<Elem>Map<K>To<V>({* k => v})BTreeMap<K, V>, orOrderedHashMap<K, V>under--preserve-encodingsPairMap<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'skeys()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/addfor a list;new/len/insert/get/keysfor a map) — nothing in the generated rust depends on this, but JS callers of this crate's API do. In CML's runtime theimpl_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.tomlfor 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-craterewrites 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 rejectset wrappers cannot be deferred. The<Elem>OrderedSet/NonEmpty<Elem>OrderedSetemitters 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
| Situation | Mechanism |
|---|---|
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.