Current capacities
Types
- Primitives -
bytes,bstr,tstr,text,uint,nint - Fixed values -
null,nil,true,false - Array values -
[uint] - Table types as members -
foo = ( x: { * a => b } ) - Inline groups at root level -
foo = ( a: uint, b: uint) - Array groups -
foo = [uint, tstr, 0, bytes] - Map groups (both struct-type and table-type) -
foo = { a: uint, b: tstr }orbar = { * uint => tstr } - Embedding groups in other groups -
foo = (0, bstr) bar = [uint, foo, foo] - Group choices -
foo = [ 0, uint // 1, tstr, uint // tstr } - Tagged major types -
rational = #6.30([ numerator : uint, denominator : uint]) - Optional fields -
foo = { ? 0 : bytes } - Type aliases -
foo = bar - Type choices -
foo = uint / tstr - Serialization for all supported types.
- Deserialization for almost all supported types (see the Limitations section).
- CDDL Generics -
foo<T> = [T],bar = foo<uint>, in every instantiation position: rule RHS, keyed member (x: foo<uint>), homogeneous array element (bars = [* foo<uint>]), and bare member position (a = [foo<uint>, tstr]). In each case the anonymous instance is registered and emitted (generics on plain groups, e.g.set<a> = (* a), remain unsupported). - Length bounds -
foo = bytes .size (0..32) - Non-empty containers -
[+ T]maps toNonEmptyVec<T>and{+ k => v}(or the1*spelling) toNonEmptyMap<K, V>, enforcing the "at least one" lower bound through a singleTryFromdoor instead of a bypassable constructor check (see Output format). Other count-permitting map markers (?,n*m, …) are rejected gracefully. - Integer width via
.size-u = uint .size 1maps tou8,uint .size 8tou64, etc..sizeon a signedintis rejected gracefully: per the spec semantics clarified by the RFC author (cbor-wg/cddl#32),int = uint / nintand a control distributes over the choice with.sizeundefined (never-matching) onnint, soint .size Nmeans exactly theuint .size Nwindow0...(256**N)— a signedi{8N}reading would mis-enforce it in both directions, and the aligned unsigned reading is spelleduint .size Ndirectly. If you mean an N-byte signed integer, use the explicit range (e.g.-9223372036854775808..9223372036854775807maps toi64), which is fully supported. - Value ranges - integer (
foo = -10..3,bar = int .le 10) and float (f = 0.5..10.5,g = float64 .lt 10.5) windows are both enforced (float checks are NaN-safe; see the range note below for the boundaries) - cbor in bytes -
foo_bytes = bytes .cbor foo - Support for the CDDL standard prelude (using raw CDDL from the RFC) -
biguint, etc - default values -
? key : uint .default 0
We generate getters for all fields, and setters for optional fields. Mandatory fields are set via the generated constructor. All wasm-facing functions are set to take references for non-primitives and clone when needed. Returns are also cloned. This helps make usage from wasm more memory safe.
Identifiers and fields are also changed to rust style. ie foo_bar = { Field-Name: text } gets converted into struct FooBar { field_name: String }
Group choices
Group choices are handled as an enum with each choice being a variant. This enum is then wrapped around a wasm-exposed struct as wasm_bindgen does not support rust enums with members/values.
Group choices that have only a single non-fixed-value field use just that field as the enum variant, otherwise we create a GroupN for the Nth variant enum with the fields of that group choice. Any fixed values are resolved purely in serialization code, so 0, "hello", uint puts the uint in the enum variant directly instead of creating a new struct.
For a map-representation group choice whose arm collapses to a single field (foo = { a: uint // b: tstr }), the enum variant stores only the value; the fixed member key lives in the IR and is written on serialize and verified on deserialize, so the output is a well-formed map(1) { key: value } that any CBOR implementation reads and spec-valid input is accepted. Arms whose member-key types differ ({ 1: uint // b: tstr }) dispatch on the key's CBOR type; arms sharing a key type (all-text or all-uint keys) fall through to the ordinary try-each-variant path, each attempt verifying its key value. A multi-field map arm decodes through its GroupN record, keying on the first member key. Only fixed-value member keys are supported in a collapsed map arm: a non-fixed key (k => v), a keyless entry, or a non-uint/non-text key is rejected at generation time with a clear error rather than silently emitting malformed CBOR. The same limit applies to ordinary struct-map records: fixed member keys support uint and text only, and other fixed key kinds (nint, float) are rejected at generation time with a clear error — for nint it points at the table { * k => v } alternative, which keeps generating; a float key is rejected in both forms (a float-family table key domain — { * float64 => uint }, { * number => uint }, { * time => uint }, or a composite key carrying a float — is also rejected at generation, since floats have no total order and cannot key a map), so the float message advises an integer/text key instead. A literal-key arrow spelling k => v is equivalent to the colon spelling k: v (the same wire entry per RFC 8610) — single- and multi-entry alike — and generates identically. A non-literal (type-domain) arrow entry with no occurrence indicator — { tstr => uint } — is rejected at generation: per RFC 8610 an entry with no occurrence marker occurs exactly once, and treating it as a 0..N table would silently widen that occurrence (the generated decoder would wrongly accept e.g. an empty map); spell the table explicitly as { * tstr => uint }. A + (or the equivalent 1*) marker on such an entry — { + tstr => uint } — is honored as a non-empty table: the field type becomes NonEmptyMap<K, V> (see Non-empty containers), whose single TryFrom door rejects an empty map with the same 0 not at least 1 error at both the API and the wire, so the + lower bound cannot be bypassed. The other count-permitting markers — { ? tstr => uint }, { 2*3 tstr => uint }, { *3 tstr => uint }, { 2* tstr => uint }, { 0*3 tstr => uint } — are rejected gracefully at generation with a message naming the marker and advising * (or + when the intent is ≥1); honoring a real bounded map cardinality is a candidate feature, and silently widening these markers to an unbounded * table (which made the generated decoder wrongly accept out-of-window maps) was the bug removed by that rejection. A bareword key that is a Rust keyword ({ if: uint }) would emit an invalid field identifier, so it is rejected with a clear error; rename it with a ; @name <other> comment directive (the CBOR wire key stays the bareword text).
Type choices
Type choices are handled via enums as well with the name defaulting to AOrBOrC for A / B / C when inlined as a field/etc, and will take on the type identifier if provided ie foo = A / B / C would be Foo.
Any field that is T / null is transformed as a special case into Option<T> rather than creating a TOrNull enum.
A special case for this is when all types are fixed values e.g. foo = 0 / 1 / "hello", in which case we generate a special c-style enum in the rust. This will have wasm_bindgen tags so it can be directly used in the wasm crate. Encoding variables (for --preserve-encodings=true) are stored where the enum is used like with other primitives.
Type aliases and tagged rules
A rule that is a plain reference to another type (foo = bar) or a wire-transparent wrapper (basic = (uint), where parentheses have no CBOR effect) generates a transparent pub type Foo = ... alias. Use ; @newtype to opt into a pub struct wrapper instead (see comment DSL).
A top-level range rule whose window does not collapse exactly onto a Rust primitive width — literal-headed (c = -10..-3) or typename-headed (bounded = int .le 10) — generates a newtype wrapper whose constructor and deserializer enforce the window (a transparent alias has nothing to hang the check on). An exact collapse (u8ish = 0..255) stays a pub type alias. Float-typed range windows are enforced the same way: a float range (c = 0.5..10.5, exclusive 0.5...10.5, or a control form like float64 .lt 10.5) wraps into a newtype whose constructor and deserializer enforce the window with a NaN-safe check (NaN is always rejected), and a tagged float range writes/requires its tag. Boundaries: .ne over a float and a decimal bound on an integer-primitive head (uint .le 10.5) are rejected at generation time with a clear error (there is no principled single-value float exclusion, and silently flooring a decimal onto an int head would mis-enforce), and float bounds are not available under --preserve-encodings (native floats as members are unimplemented there).
A top-level single-type tag rule is the exception: it always generates the tag-writing/tag-checking newtype wrapper (pub struct Tagged(String) whose serialize writes write_tag(42) and whose deserialize rejects a wrong tag with TagMismatch). This holds for every single-type inner — a primitive or named type (tagged = #6.42(text), uri = #6.32(tstr)), a bytes .cbor T wrapper (foo_bytes = #6.20(bytes .cbor foo)), a .default-carrying inner (#6.42(uint .default 5) — the default has no meaning on an always-present standalone value and is dropped), and a ranged inner (#6.42(uint .le 255) or the literal-headed #6.5(3..10), whether or not the range collapses exactly onto a rust primitive). A transparent pub type alias cannot carry a custom serialize impl, so an alias would silently drop the tag from that type's own standalone to/from_cbor_bytes API — a CBOR conformance bug. @newtype is therefore redundant (though harmless) on a tag rule. Tag rules whose body is a group (rational = #6.30([...])) already generate a struct that writes the tag and are unaffected.
Recursive types
Terminable recursive types are supported — a rule may reference itself through an occurrence that can terminate, e.g. tree = [value: uint, children: [* tree]] (an empty children ends the recursion). These generate and round-trip normally.
The generated deserializer is recursive-descent, so by default it has no depth bound: maliciously deep CBOR recurses until the thread's stack overflows and the process aborts (SIGABRT) — this is a process abort, not a returnable error, so it cannot be caught with catch_unwind. There is deliberately no default limit, because any fixed cap would reject spec-valid documents that happen to nest deeper.
Consumers that deserialize untrusted input (e.g. on-chain data) should generate with --deserialize-depth-limit=N, which makes the generated deserializers return a graceful DeserializeError once nesting exceeds N instead of overflowing the stack. See the flag's documentation for the tradeoff (it also rejects legitimately deep documents past N).
Limitations
This section is generated from cddl-matrix/matrix.json by cddl-matrix/query_q1_gaps.ts (regenerate with cd cddl-matrix && bun run query_q1_gaps.ts --write). It lists the constructs in cddl-codegen's target CDDL profile (RFC 8610 + the IANA control-op registry) that the generator does not yet support — its actionable gaps. "Supported" means the generated crate's emitted round-trip tests pass, not merely that it generates and compiles. Constructs that post-date the target profile are listed separately at the end (they are not gaps).
Unsupported constructs
| Construct | CDDL example | Behavior |
|---|---|---|
| Incremental group-choice extension (//=) | tcpopts = (1: int); tcpopts //= (2: tstr) | rejected gracefully at parse |
| Incremental type-choice extension (/=) | a = int; a /= tstr | rejected gracefully at parse |
| Generic group definition | set<a> = (* a) | panics generation |
| any | x = any | generates but does not compile |
| cbor-any | x = cbor-any | panics generation |
| eb16 | x = eb16 | panics generation |
| eb64legacy | x = eb64legacy | panics generation |
| eb64url | x = eb64url | panics generation |
| false | x = false | rejected gracefully at parse |
| float16 | x = float16 | panics generation |
| float16-32 | x = float16-32 | panics generation |
| float32-64 | x = float32-64 | panics generation |
| nil | x = nil | rejected gracefully at parse |
| null | x = null | rejected gracefully at parse |
| true | x = true | rejected gracefully at parse |
| undefined | x = undefined | panics generation |
| Any (#) | foo = # | panics generation |
| Choice from named group (&) | color = &colors; colors = (red: 0, green: 1) | panics generation |
| Choice from inline group (&) | color = &(red: 0, green: 1, blue: 2) | panics generation |
| Major-type sigil (#N, #N.n) | foo = #1.2 | panics generation |
| Major-type 7 / simple sigil (#7, #7.n) | foo = #7.20 | panics generation |
| Unwrap (~) | bar = [uint]; foo = ~bar | panics generation |
| Literal value as a type | answer = 42 | rejected gracefully at parse |
| Byte-string literal value | magic = h'cafe' | rejected gracefully at parse |
| Numeric literal value | version = 5 | rejected gracefully at parse |
| Text literal value | marker = "v1" | rejected gracefully at parse |
Control operators
Supported: .cbor, .default, .eq, .ge, .gt, .le, .lt, .ne, .size.
Unsupported (in-profile):
.abnf, .abnfb, .and, .b32, .b45, .b64c, .b64c-sloppy, .b64u, .b64u-sloppy, .base10, .bits, .cat, .cborseq, .det, .feature, .h32, .hex, .hexlc, .hexuc, .join, .json, .oid, .plus, .printf, .regexp, .sdnv, .sdnvseq, .within.
Contextual gaps (supported top-level, unsupported when nested)
These constructs work as their own rule but are unsupported in the listed nesting roles — an inline anonymous composite must be given a name (a rule or a ; @name).
| Construct | Unsupported roles | Example |
|---|---|---|
grpent.groupname | occurrence-target | m = { * grp }; grp = (k: int) |
grpent.inline_group | group-choice-arm, occurrence-target, occurrence-target, occurrence-target, occurrence-target, occurrence-target | t = [ (uint, tstr) // bytes ] |
grpent.member | occurrence-target, occurrence-target | a = [uint, + bytes] |
memberkey.bareword | occurrence-target, occurrence-target | multi = { a: uint, 0*1 b: uint } |
memberkey.type1 | group-choice-arm, map-key, map-key, map-key, map-key, map-key, map-key, map-key, occurrence-target, occurrence-target | t = { uint => tstr // b: tstr } |
memberkey.value | group-choice-arm, group-choice-arm, map-key, map-key, map-key, map-key | flt = { 1.5: uint // b: tstr } |
type2.array | array-element, cbor-payload, choice-member, map-key, map-value, occurrence-target | a = [[int]] |
type2.map | array-element, cbor-payload, choice-member, generic-arg, group-choice-arm, map-value, occurrence-target | a = [{x: int, y: uint}] |
type2.tag | tag-content | t = #6.24(#6.25(uint)) |
Out of profile (not gaps)
The following construct post-dates cddl-codegen's target profile, so it is not counted as a support gap: Tagged data item, type-valued tag number (#6.<T>) (t = #6.<n>(int); n = uint, RFC9682).