Skip to main content

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 } or bar = { * 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 to NonEmptyVec<T> and {+ k => v} (or the 1* spelling) to NonEmptyMap<K, V>, enforcing the "at least one" lower bound through a single TryFrom door 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 1 maps to u8, uint .size 8 to u64, etc. .size on a signed int is rejected gracefully: per the spec semantics clarified by the RFC author (cbor-wg/cddl#32), int = uint / nint and a control distributes over the choice with .size undefined (never-matching) on nint, so int .size N means exactly the uint .size N window 0...(256**N) — a signed i{8N} reading would mis-enforce it in both directions, and the aligned unsigned reading is spelled uint .size N directly. If you mean an N-byte signed integer, use the explicit range (e.g. -9223372036854775808..9223372036854775807 maps to i64), 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

ConstructCDDL exampleBehavior
Incremental group-choice extension (//=)tcpopts = (1: int); tcpopts //= (2: tstr)rejected gracefully at parse
Incremental type-choice extension (/=)a = int; a /= tstrrejected gracefully at parse
Generic group definitionset<a> = (* a)panics generation
anyx = anygenerates but does not compile
cbor-anyx = cbor-anypanics generation
eb16x = eb16panics generation
eb64legacyx = eb64legacypanics generation
eb64urlx = eb64urlpanics generation
falsex = falserejected gracefully at parse
float16x = float16panics generation
float16-32x = float16-32panics generation
float32-64x = float32-64panics generation
nilx = nilrejected gracefully at parse
nullx = nullrejected gracefully at parse
truex = truerejected gracefully at parse
undefinedx = undefinedpanics 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.2panics generation
Major-type 7 / simple sigil (#7, #7.n)foo = #7.20panics generation
Unwrap (~)bar = [uint]; foo = ~barpanics generation
Literal value as a typeanswer = 42rejected gracefully at parse
Byte-string literal valuemagic = h'cafe'rejected gracefully at parse
Numeric literal valueversion = 5rejected gracefully at parse
Text literal valuemarker = "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).

ConstructUnsupported rolesExample
grpent.groupnameoccurrence-targetm = { * grp }; grp = (k: int)
grpent.inline_groupgroup-choice-arm, occurrence-target, occurrence-target, occurrence-target, occurrence-target, occurrence-targett = [ (uint, tstr) // bytes ]
grpent.memberoccurrence-target, occurrence-targeta = [uint, + bytes]
memberkey.barewordoccurrence-target, occurrence-targetmulti = { a: uint, 0*1 b: uint }
memberkey.type1group-choice-arm, map-key, map-key, map-key, map-key, map-key, map-key, map-key, occurrence-target, occurrence-targett = { uint => tstr // b: tstr }
memberkey.valuegroup-choice-arm, group-choice-arm, map-key, map-key, map-key, map-keyflt = { 1.5: uint // b: tstr }
type2.arrayarray-element, cbor-payload, choice-member, map-key, map-value, occurrence-targeta = [[int]]
type2.maparray-element, cbor-payload, choice-member, generic-arg, group-choice-arm, map-value, occurrence-targeta = [{x: int, y: uint}]
type2.tagtag-contentt = #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).