Component Differences
--component emits a third face beside the rust and wasm crates: a
generated WIT package plus the wit-bindgen guest glue that implements it over the rust crate,
built for wasm32-wasip2. This document is to that face what
Wasm differences is to the wasm-bindgen one — the record of why the
boundary looks the way it does, so that a shape you did not expect reads as a decision rather than
an accident.
The two faces answer to different type systems, so they differ in ways that are worth knowing before you build against either. Where a difference exists, it is stated here as a difference — this face is a parallel with documented deltas, not a re-skin of the wasm one.
Resources, not records
Every type the tool defines crosses the boundary as a WIT resource — an opaque handle — rather
than as a record value. Four reasons, in the order they actually bind:
- Invariant safety. A WIT
recordhas no validated construction, so any constrained type (a value bound, a[+ …]non-empty container, a@duplicates rejectset) could be built through by a caller in a state the rust type forbids. A resource's constructor is the only door. - Recursion. WIT
records andvariants may not be recursive; CDDL specs routinely are. Handles are opaque, soresource node { children: func() -> list<node>; }is legal and recursion costs nothing. - Copying. The canonical ABI copies values across the boundary; handles are table operations. A record-of-records API copies its whole transitive contents on every call.
- Encoding state. Under
--preserve-encodingsa type carries a hidden*Encodingsidecar that is not representable as a WIT value and must stay behind the handle for byte-exact round-trips.
Parameters borrow, returns own. Every parameter position — constructors, setters, statics,
methods — takes borrow<t> for a composite, and list<borrow<t>> for a collection of them. Every
return position mints fresh own handles. That is not a style preference: the canonical ABI
transfers ownership of an own handle at the call boundary (lifting an own removes it from the
caller's table), so a list<own<t>> parameter would consume every element of the caller's
array. That is the same bug class the wasm face's FooList wrapper classes exist to prevent — see
Wasm differences — reached here by a position rule
instead of by wrapper classes. borrow is parameter-position only; a return that transitively
contains one is rejected when the WIT is resolved.
A type the projection cannot render is excluded and recorded, never silently dropped: the
emitted world.wit carries a // unexported: <ident> — <reason> comment row for it, and any
signature that would have referenced it is excluded with the same reason. The generated WIT's
validity is gated by component_wit_validates and, at corpus breadth, by
component_wit_validates_the_corpus.
Getters hand back a snapshot
A getter clones the field and returns a fresh owned handle. Mutating what you got does not
mutate the parent, and mutating the parent afterwards does not change what you got — in both the
return and the parameter direction. This is the same clone-at-the-boundary rule the wasm face
already follows, and it is asserted end to end by the behavioural gate component_host.
Collections are plain lists
There are no wrapper classes on this face. An array field is list<t>, a map field is
list<tuple<k, v>>, and a consumer builds a native array rather than calling
FooList.new()/add(). The wasm face's wrappers exist for ownership safety first and type-support
gaps second; the borrow-parameter rule above covers the ownership half, and WIT's type system has
neither of the gaps (nested lists, lists of handles and nested options are all expressible).
The consequence is that constraints move from the type to the door. A [+ t] non-empty
container, a @duplicates reject set and a @duplicates preserve pair-map all despecialize to a
plain list<…>, and the invariant is re-checked where the list is consumed — the constructor or
setter that takes it returns result<…, string> and reports the same refusal the rust crate's own
TryFrom door would. Nothing is lost; the check simply happens at the call that would have
violated it.
Access granularity is coarser than the wasm face's. A map-typed field's only read is the
whole-map getter: it materializes every entry and mints a fresh handle per resource-typed entry on
every call. There is no get(key) and no incremental insert as the wasm face's table wrappers
have. If whole-map materialization ever dominates a real profile, keyed accessors on the parent
resource are the follow-up that would address it.
Setters exist only for optional fields
The constructor takes every mandatory non-fixed field, and each non-fixed field gets a bare
getter named after it. Only an optional field additionally gets a set-<field> — a mandatory
field has no setter at all, because the constructor is the only place its value can be established.
There is no indexed setter of any kind (no set-<field>-at(i, v)); a collection field is written
whole.
Getter and setter are deliberately asymmetric: the getter of an optional field returns
option<t>, while its setter takes the bare t. A setter sets a value; it never clears one.
An optional fixed-value field — one whose only information is whether it is present — instead
gets a bool presence getter and a set-<field>(present: bool).
A setter whose type carries a bound is fallible (result<_, string>); one whose type does not is
infallible, so the signature tells you whether a value can be refused.
Rest rows and open tables project as extra getters
A rule that captures entries it did not declare — an
open struct-map ({ 1: uint, * k => v }), an
open array ([uint, * t]), or an open table
(t = { * k1 => v1, * k2 => v2 }) — projects each captured row as one read-only getter on the
resource, named after the row (rest by default, or whatever
@name gives it). A map row is list<tuple<k, v>>, an array tail is list<t>,
which is the collection spelling above with nothing new added.
Each such getter is read-only in the strong sense: the row is not a constructor parameter and has
no setter, so on this face a captured region can be read but not written. That is the same
posture the wasm face takes on the field itself, minus the mutable wrapper the wasm face hands back —
here the whole-map snapshot rule applies, so what you get is a copy. An
@ignore row stores nothing and therefore projects no getter at all.
An open table projects both of its rows this way, the typed one and the trailing catch-all, each
its own list<tuple<…>> getter. This is the one place the component face is less despecialized
than the wasm face rather than more: the wasm class flattens the typed row's map surface onto itself
and keeps only the catch-all behind a getter, while here both rows are symmetric getters, because
this face has no per-key accessors to flatten.
Passing a resource into itself stores a snapshot
Because exported resource methods take &self, the guest holds each type in a RefCell. The
canonical ABI permits lending the same handle twice into one call, so x.set-children([x]) is a
legal thing for a caller to write, and recursive CDDL types make it type-legal in many places.
Generated glue materializes every parameter to an owned value before it touches self, so such
a call returns normally and x stays usable. What is stored is a value copy — a snapshot taken
at the moment of the call — so mutating x afterwards leaves the stored child untouched. An
aliased cycle is impossible by construction.
This matters more than it looks. Glue that held two RefCell guards at once would panic, and a
panic in a component is a trap; a trap poisons the instance, so every subsequent call on it
fails. In a composed topology, where one instance of a shared library component serves every
consumer, that is an availability failure and not merely a failed call. The emission rule is pinned
by component_glue_never_holds_two_refcell_guards, and its runtime consequence by component_host.
Nested options work
option<option<t>> is a legal WIT type, so a nullable value in a position that adds its own
presence-option keeps all three states — absent, present-and-null, present-with-value — directly
in the signature. The wasm face's flattening plus additive has_* accessors (see
Wasm differences) is a wasm-bindgen workaround
that this face does not need and does not emit.
Naming
WIT identifiers are kebab-case ASCII, and an interface is one flat namespace with a
strong-uniqueness rule — names compare equal after acronym lowercasing and after stripping the
[method]/[static]/[constructor] prefixes, so a resource may not carry a member with the
resource's own name. The tool converts rust identifiers deterministically and refuses a spec whose
converted names collide, naming the collision; the remedy is the
@name comment-DSL rename, which changes no wire bytes.
Four rules are worth knowing because they are visible in the emitted WIT:
- Digit-led words merge into the word before them —
index_0becomesindex0, notindex-0. This is a consumer-compatibility floor, not a legality constraint:index-0is accepted end to end by the toolchain floor below (it resolves, encodes, validates, and builds), and is rejected only by wasm-tools 1.231-era consumer tooling. The merge is what keeps generated packages readable by those consumers. - Keywords are
%-escaped against the union of keyword sets across supported toolchain versions, because the set has moved in both directions:mapis a keyword at the pinned floor and was not at 1.231, whilefloat32/float64were keywords at the older end and are not now. Escaping a non-keyword is accepted everywhere, so the union is the only choice compatible in both directions. The%is WIT syntax and never reaches the generated Rust bindings. - A choice's member names follow the rust CONSTRUCTOR name, never the enum arm identifier. A
rust
Value::new_uint(u64)whose arm is spelledValue::U64projects tonew-uintandas-uint, nevernew-u64. Thenew-/as-/kindfamilies all derive from that one source, so they agree with each other by construction. - Each exported module scope becomes one
interface, nested scope paths flatten with-(a::c→a-c), and the world is<lib-name>-world. Interfaces linked withusemust be acyclic, which is why a multi-file spec with mutually-referencing scopes generates without--componentand is refused with it.
A name change is a composition-breaking change, on the same footing as a shape change. Two
components unify a type only when the package id, the interface name, the type name and the shape
all agree — renaming a type, an interface, or a variant arm makes a composition that previously
linked refuse to link. Note the asymmetry in who enforces it: a composer such as wac checks the
named type exports and refuses a mismatch, while a wasmtime::Linker satisfies an import by
function set and will happily accept a structurally identical type under a different name. Do
not conclude from a host harness that a rename was safe.
Int is a variant, and its nint arm is CBOR-biased
CDDL's int spans the full CBOR uint/nint range, which exceeds s64, so it crosses as a value
type rather than a handle:
variant int {
uint(u64),
nint(u64),
}
The nint payload is the CBOR-encoded magnitude, not the absolute value: nint(n) means
-(n + 1), so nint(4) is −5. The arm names follow CBOR's major-type terminology and the rust
constructors (Int::new_uint / Int::new_nint), and nothing in the WIT type expresses the bias —
which is why the emitted WIT carries the bias as a comment on the arm, and why it is stated here.
Because arm names participate in cross-package unification, this spelling is frozen: changing
nint to something more self-describing would break every already-composed consumer.
any-cbor is bytes, exactly one item, canonically re-encoded
CDDL any crosses as a transparent alias:
type any-cbor = list<u8>;
A structural recursive variant is impossible (WIT forbids recursive value types), and a resource
would fail composability exactly where any matters most — metadata extension points shared
between libraries. Bytes are the one representation two independently generated packages can agree
on without a shared package. Three consequences, all of which have bitten in probing:
- Exactly one CBOR item. Validity is checked where the bytes are consumed; getters always
return valid bytes. Trailing data is an error rather than a silent truncation: handing
0102(two items) to a door returnsErr(Unexpected trailing data in CBOR)rather than accepting the first item. - Byte-exact means "against the rust crate's serialization", not "against what you passed in".
Under the default (non-preserve) posture a stored
anyis re-encoded canonically on read-back: set an indefinite-length9f0102ffand the getter returns the definite-length820102. That is the same rule every type in that posture follows —anyis simply where a caller is most likely to assume otherwise. Under--preserve-encodingsthe original encoding is retained as it is for every other type. - Introspection is a free function, and it is fallible.
cbor-kind: func(v: any-cbor) -> result<any-cbor-kind, string>takes caller-supplied bytes, so decoding is the check; the fallible shape keepsany-cbor-kinda 1:1 projection of the runtime discriminant with no syntheticinvalidcase. Under--json-serde-derivesthe free functionscbor-to-jsonandcbor-from-jsonjoin it, both fallible for the same reason. Invalid bytes always cross as a normalErr, never as a trap.
Fallible doors return the rust error text
Anything that can refuse — a bounds-checking constructor, a despecialized collection door, a
from-cbor-bytes, a JSON door — returns result<…, string>, and the string is the rust type's
Display text, equal to the native DeserializeError a direct rust caller would see (e.g.
Deserialization failed in Hash because: 31 not in range 32 - 32). A structured error variant is
a possible future refinement; today the text is the contract. A refusal is a normal Err and never
a trap, so the instance stays usable afterwards.
Note a template-level distinction visible in the WIT: a fallible constructor is
constructor(...) -> result<t, string>, while a fallible static is
from-cbor-bytes: static func(...) -> result<t, string>. A resource cannot carry both a
constructor and a static named new — wit-bindgen lowers both to the same Rust method — so the
generated vocabulary avoids that collision by construction.
Encoding posture is a property of the package, not of a type
There are no flavored types — no canonical-int beside a preserve-int. Posture is decided by the
flags the crate was generated with and belongs to the package as a whole:
- Value types are pure semantics. Encoding state never crosses the boundary as part of a value. This is not new: the wasm face has always returned bare values from getters, with preserve state riding the container's hidden sidecar.
- Byte-exactness attaches to handles. A resource never loses its encoding state, because that
state never leaves the component that owns it. If you need a byte-exact embedding, take a
borrow<t>or takelist<u8>— never a bare value type. In generated code this is automatic; in hand-written WIT that composes with a generated package, it is the rule to follow. - A posture change is a package change. A preserve-generated crate and a canonical-generated
crate of the same spec have different surfaces (
to-canonical-cbor-bytesexists or does not), so they are different packages, which is what package identity already expresses. Composing across a posture mismatch is an integration-time obligation, not something the type system catches.
The --wit-package flag names that identity, and its version follows
semver's 0.x rule: a host resolves a:b/c@0.2.0 against a defined 0.2.1 but not against
0.3.0, so on a 0.x line a minor bump is a link-time break. For wire types that is arguably
what you want — an incompatible API fails to link rather than misbehaving at runtime — as long as
it is a decision rather than a surprise.
A version bump is not the only link-time break, though: unification is by name and shape, so a WIT name change sits on exactly the same footing as a shape change — renaming an alias with the shape untouched makes a composition that previously linked refuse to link. That makes the naming rules load-bearing for interop rather than cosmetic; Naming states the rule and the asymmetry in who enforces it.
Sharing types across packages
The reason this face exists. Point a consumer at a dependency's generated WIT and the dependency's types cross as imported WIT resources: one dependency component instance serves every consumer, and handles minted by one are usable by all of them. Without that, a consumer's dependency types would be its own private resources — structurally identical to the dependency's and interchangeable with nothing.
Import mode is opt-in, and the fallback is exclusion
A dependency reached through --extern-import enters import mode only when
you also supply its WIT with --component-extern-wit. Opt-in because a
dependency need not have a component face at all — a hand-written or stub dependency has no
component/wit to point at, and a mandatory flag would make such a spec ungeneratable with
--component.
Be clear about what the no-flag path gives you, because it is not a degraded-but-working surface:
a dependency type with no WIT projection is excluded, and so is every consumer signature naming one.
The emitted world.wit records each with a // unexported: <ident> — <reason> row, exactly as it
does for any other unprojectable type. A consumer made entirely of dependency types therefore
projects to an empty interface. Import mode only ever adds.
Under --config, both flags derive from a deps edge whenever both crates have component = true;
see The component face.
The seam is CBOR bytes, and it costs what that costs
A consumer's rust struct holds the dependency's native rust type, because its own serializer needs it; the WIT surface carries an imported resource handle. Converting between them is a serialize on one side and a deserialize on the other:
- a getter returning a dependency type does
ImportedFoo::from_cbor_bytes(&native.to_cbor_bytes()); - a parameter taking one does
dep_crate::Foo::from_cbor_bytes(&handle.to_cbor_bytes()).
One serialize + one copy + one deserialize per dependency-typed value, per crossing, in each direction. CBOR is self-delimiting, so the seam is well defined; it is not free, and a design that crosses a large collection element-by-element on a hot path will feel it. Said plainly here rather than buried: the alternative to knowing the cost is discovering it.
A consequence worth stating on its own: the object that comes back is live, and the value it carries is a copy. A consumer getter mints a fresh resource in the dependency instance's table, so the dependency's own exported interface — including its setters — works on the handle you get back. It is not the handle you passed in, and mutating one does not reach the other.
Getters on dependency-typed fields are fallible
from_cbor_bytes on the far side can reject a value the consumer's own serializer just produced, if
the dependency component and the consumer's linked dependency rust crate disagree on a type's shape.
That is a failure class which is a compile error today, so the signatures say so: a getter returning
a dependency type is result<foo, string>, and a constructor or setter taking one is fallible the
same way.
The two skews are caught in different places. API-shape skew is caught at composition time by WIT
versioning (a 0.x minor bump is a link-time break — see above). Same-API encoding skew is
caught only at runtime, by these Errs. Version-matching the dependency component against the
dependency crate the consumer links is the integrator's obligation.
Dependency-typed collection parameters take an accumulator
A [* dep_type] parameter is not spelled list<borrow<token>>. It is spelled
borrow<token-list>, where token-list is a consumer-exported accumulator you fill one element at
a time:
resource token-list {
constructor();
push: func(v: borrow<token>) -> result<_, string>;
len: func() -> u32;
}
Name the cause, because that is what gives a future toolchain fix a trigger to revisit this:
wit-bindgen's Rust backend miscompiles every repeated position of borrow<imported-resource>.
Its lowering hoists a single handle binding out of the loop and reassigns it per iteration while a
reference to it is retained, which is E0506. Measured unfixed from the pinned 0.57.1 through
0.60.0. The WIT itself is fine — the same package resolves, encodes and validates.
| parameter shape | lowering |
|---|---|
borrow<t> | works |
option<borrow<t>> | works |
list<borrow<t>> over an exported resource | works |
list<borrow<t>> over an imported resource | E0506 |
list<option<borrow<t>>>, list<tuple<u64, borrow<t>>> | E0506 |
So the borrow moves one level up, out of the repeated position, and the per-element seam conversion
happens inside push rather than once at the door — which is why push is fallible on the same
grounds as the getters. Map-shaped parameters get the same treatment with
insert: func(k: …, v: …). The alternatives lost on their own terms: list<own t> would consume
the caller's handles at the ABI boundary, which is the exact bug class the borrow rule exists to
prevent, and list<list<u8>> would trade a typed surface for nested bytes. The restriction is on
imported resources only — own-crate collection parameters keep list<borrow<t>>, and collection
returns stay list<own t> in both cases.
Posture matching is an integrator obligation
The seam is byte-exact only while both crates agree about what CBOR they write and accept, so a
preserve-encodings, canonical-form or deserialize-depth-limit mismatch across it silently
re-encodes on every crossing rather than failing. This is the seam's stake in
Encoding posture is a property of the package,
which already frames posture as an integration obligation.
--config sees both ends of a deps edge and refuses a mismatched one before anything is written,
naming both crates, the axis and each side's value; the rule is stated in
The component face. A hand-written flag invocation sees one crate
at a time and cannot check it. The scope is narrow on purpose: it applies only where the seam exists,
because on any other deps edge the dependency's types are reached by ordinary rust linkage, no
bytes are produced or parsed at a boundary, and there is no crossing to re-encode.
Instantiate the dependency once — nothing enforces it
Resource types are generative per instantiation: two instances of the same dependency component have mutually incompatible handle types despite wearing the same interface name. So a composition must instantiate the dependency once and use that single instance both as every consumer's instantiation argument and as the world's re-export.
The trap is that nothing catches the mistake. A two-instance topology composes at exit 0,
validates, and decodes to a world whose exported interfaces are indistinguishable from the correct
one's — measured on this project's own acceptance fixture, the two artifacts differ by 417 bytes of
instantiation metadata and by nothing a reader of the world would notice. It survives loading into a
host, too. The mistake surfaces only at the first handle crossing, as mismatched resource types.
Prescribed shape: one instantiate per package; alias the dependency's exported interface once;
use that alias as the consumer's instantiation argument and export it from the world. Do not use
wac plug — it satisfies the consumer's import from the dependency's export and then drops that
export, leaving a single-export world in which a host cannot mint a dependency object at all.
Known limitation: no binary dedupe
The consumer component still statically contains the dependency's rust code, because its own
serializer needs it. Module-level dedupe, API dedupe and object identity are achieved; full binary
dedupe is not. Measured on the acceptance fixture's wasm32-wasip2 debug builds: 4,474,750 B +
4,862,282 B of parts against a 9,339,587 B composition — composition adds about 2.5 KB of wiring and
removes nothing.
Eliminating it would mean generating a second serializer that composes the imported
to-cbor-bytes fragments instead of linking the dependency crate. That is real work, and it is
deferred.
Toolchain floor, and the ambient-wasm-tools trap
This face emits fallible constructors for every bounds-validating type. That is a spec feature
with a real minimum: wasm-tools 1.231.0 rejects them both at parse time and when validating a
built artifact, while wit-parser/wit-component 0.247, wit-bindgen 0.57, rustc 1.96's
wasm-component-ld and wasmtime 47 all accept them. Consumers need 0.247-era tooling or newer.
The practical trap is that a wasm-tools binary already on your PATH may be older than that, in
which case it cannot read these artifacts at all — it will report an invalid constructor export
or an invalid identifier for a package that is perfectly valid. An old wasm-tools is not evidence
about a component; check its version before believing it. This project's own gates never shell out
to an ambient binary for that reason: component_wit_validates uses in-process wit-parser /
wit-component / wasmparser pinned at the floor.
Known limitation: the wasm32-wasip2 cdylib link crash
The generated rust/Cargo.toml declares crate-type = ["cdylib", "rlib"] for the wasm face's sake,
and on wasm32-wasip2 that cdylib can crash the linker in the rust sysroot with a SIGSEGV inside
wasm-component-ld/LLD. It is spec-dependent, it is not a diagnostic about your spec, and building
only the component/ crate does not avoid it. The fix — narrowing the rust crate to
crate-type = ["rlib"] in the consuming workspace — and the exact failure signature are documented
on the --component flag, which is where a user turning this face on
meets it; that account is the single source for it and is not restated here.
Known limitation: shapes whose glue does not yet compile
Two shape classes (three corpus fixtures) currently emit a component crate whose glue does not
compile. The failure is loud — the generated component crate's own build fails; the rust/ and
wasm/ faces are unaffected — the WIT itself is valid, and each shape is pinned with its
diagnosis in the corpus compile gate's ledger (EXPECTED_COMPILE_FAIL, gate
component_corpus_compiles), so a fix announces itself by turning the pin stale rather than
needing rediscovery:
- Nested despecialization: a constrained collection (
NonEmptyVec,NonEmptyMap) sitting below the top level of a parameter — a named table rule whose map key is a non-empty list, or a non-empty list as a list element. The conversion walk sees WIT types only below the top level, so the constrained rust type'sTryFromdoor is never routed. @defaultfields: a defaulted field is a plainTon the rust side (the default fills absence in) while the projection still treats it as optional, so the glue reads and writes anOptionthat does not exist.
Until these close, a spec relying on either shape should keep --component off or restructure
the shape; every other corpus shape compiles and validates.
Flag-semantics deltas
--to-from-bytes-methodsdoes not gate this face.to-cbor-bytesandfrom-cbor-bytesare always emitted, because the bytes seam is load-bearing for cross-crate composition and for the extern-type bridges, both of which have nothing else to cross on.to-canonical-cbor-bytesis the exception that is posture-gated: it appears only under--preserve-encodings --canonical-form, the one posture whose composed runtime declares it.--json-serde-derivesadds a fallibleto-json/from-jsonpair to each type the tool defines, plus thecbor-to-json/cbor-from-jsonfree functions. They are strings only; there is noJsValueequivalent to mirror the wasm face'sto_json_value(). The pair goes to the types the tool defines because their serde impls exist by contract: derived here, or — on a rule carrying@custom_json— hand-written by you, which this face publishes exactly as the wasm door does (the directive is refused on an extern, so no bridging type is ever asked for impls it need not have).--json-schema-exportand the json-gen crate are orthogonal and untouched. They are gated on their own flag and exist even under--wasm=false, so a--component --wasm=falseuser loses nothing.--emit-testswith--componentprints a one-line skip and continues —cddl-codegen --emit-tests: component module skipped (component test emission not yet supported). Test emission for this face is not built yet, and the tool says so out loud rather than silently emitting nothing.
JavaScript via jco
A JS host does not run a component — it transpiles one. jco rewrites the component into an
ES module graph, and the surface it synthesizes is not the wasmtime one, so what a JS consumer meets
is stated here on its own terms. Everything below is a runtime observation against jco
1.26.1, @bytecodealliance/preview2-shim 0.19.0 and node 22.21.1, pinned by the
component_jco gate. Runtime rather than typings on purpose: what the emitted .d.ts intends and
what the boundary does disagreed on the enum face, which is the one delta most likely to break a
port from the wasm face.
Transpiling: install the shim, map nothing
jco transpile <component>.wasm -o <dir> --name <module> is the whole command. The 14 wasi:*
imports every wasip2 artifact carries (see Binary size) need no flag: jco rewrites
them to @bytecodealliance/preview2-shim by default, so that package must be installed and
must not be mapped. A hand-written --map 'wasi:*=@bytecodealliance/preview2-shim/*' breaks the
output, because the shim exposes one module per subsystem with named exports
(preview2-shim/cli#environment) rather than one module per interface.
Reach the interface by its fully-qualified name — mod['cddl:my-lib/types@0.1.0']. jco also
emits a bare types alias; it resolves on a single-component artifact and is a trap on a composed
one (see Do not transpile a composed artifact), so the
qualified spelling is the one to write everywhere.
The JS surface, row by row
| WIT | What JS sees |
|---|---|
resource | a class, with kebab-case members camelCased (set-nested → setNested, from-cbor-bytes → the static fromCborBytes) |
enum | a string label, never a number: kind() returns 'i1', and passing the number 1 is refused with TypeError: "1" is not one of the cases of <enum>, naming the WIT enum |
variant int | an object literal in both directions — { tag: 'uint', val: 5n } / { tag: 'nint', val: 4n }. The nint payload carries its CBOR bias unapplied, exactly as the WIT variant declares it |
u64 | bigint (42n), never number |
option<t> in a return | t or undefined — so an as-<variant> that does not match reads as undefined, and so does an unset optional field |
option<option<t>> | the tagged form, because flattening would be ambiguous: { tag: 'none' } (absent), { tag: 'some', val: 'x' } (present with a value), { tag: 'some' } with no val key (present and null). All three are distinguishable; setNested(undefined) produces the third |
any-cbor | Uint8Array in both directions, with trailing data refused where the bytes are consumed |
| free function | a module-level export beside the classes (cbor-kind → cborKind) |
result<…, string> | thrown, not returned — see below |
The enum row is the delta to plan a port around: the wasm face's c-style enums take the numeric
discriminant and this one refuses it. That was the expected delta before it was probed; it is now
measured, and the refusal is a TypeError at the JS boundary rather than a deserialize error from
the component.
Snapshot semantics cross unchanged. A getter hands back a fresh handle rather than a view, and
lending a handle as receiver and argument (node.setChildren([node])) returns normally and stores
a copy — the same rules a wasmtime host sees, asserted again from
JS because a transpiler is free to get them wrong.
A refusal is thrown, and the instance survives it
A fallible door does not hand back a { tag: 'err' }. jco throws a ComponentError whose
.payload is the WIT string and whose .message is the same text; e instanceof Error holds, so
ordinary try/catch works:
try {
new T.Hash(new Uint8Array(3));
} catch (e) {
e.payload; // "Deserialization failed in Hash because: 3 not in range 32 - 32"
}
new T.Hash(new Uint8Array(32)); // the component is fine — the failure poisoned nothing
That second line is the point of returning result<…> instead of trapping: a trap would take the
whole instance down and every later caller with it. The despecialized bounds behave the same way —
setAliases([]) on a [+ text] field throws 0 not at least 1 and the object stays usable.
Disposal is an own property, and reclamation is otherwise GC-driven
jco defines [Symbol.dispose] as an own property on each owned instance, not on the prototype —
so Cls.prototype[Symbol.dispose] is undefined and a feature detect must run against the
instance. Symbol.dispose exists in node 22, so using declarations work. Calling it frees the
guest handle, and a later method call on that object throws
TypeError: Resource error: Not a valid "…" resource.
Without an explicit dispose, a FinalizationRegistry reclaims the handle on GC. That is the
sentence a long-running dApp has to care about: handles are cheap to mint and their release is
non-deterministic, so a loop that mints thousands of them holds guest-side table entries until
the collector gets around to it. Dispose explicitly where the lifetime is known.
Two generated packages: transpile each one, wire them with --map
The prescribed cross-crate shape is one transpile per component, with the dependency edge wired in the JS module graph:
jco transpile chain.wasm -o chain --name chain
jco transpile wallet.wasm -o wallet --name wallet \
--map 'cddl:chain/types@0.1.0=@scope/chain#types'
The --map value is <interface id>=<module specifier>#<export>. Both specifier forms work: a
relative one (../chain/chain.js#types, what the gate drives) and a bare npm specifier
(@scope/chain#types) resolved through node_modules.
The whole cross-crate flow then behaves as it does through a native host: mint a chain.Token,
push it through the consumer's wallet.TokenList accumulator, construct a wallet.Ledger, get a
chain-typed handle back from ledger.head(), and mutate it through the dependency's own
interface. Values survive the crossing unmutated, the returned handle is a live resource in the
dependency's table (ledger.head() instanceof chain.Token), and snapshot semantics hold across the
seam exactly as they do inside one component.
Instantiate-once comes free here. The invariant that nothing enforces at composition time is delivered by ES module semantics: every consumer importing the same dependency specifier shares one module instance, so there is no two-instance topology to get wrong.
Do not transpile a composed artifact
Composing the two components into one world first and transpiling the result is the obvious
alternative, and it is broken under jco 1.26.1 — in two places, one of which is silent. The
runtime half and the bare-alias resolution are pinned by the component_jco gate's composed leg, so
a jco fix is reported as the good news it is rather than discovered years later; the tsc verdict
below is a measurement beside it rather than a gated one.
- At build time, the composed world's root typings do not compile. Both packages' single
interface is named
types, so the.d.tsemitsexport * as typestwice andtsc --strictfails withTS2300: Duplicate identifier 'types'. The runtime.jsdisambiguates by exporting the fully-qualified names, so the baretypesalias silently resolves to the first interface only. - At run time, handles do not cross between the composed instances. Passing a dependency-minted
handle into a consumer function throws
TypeError: Resource error: Not a valid "…" resource— in the scalar-borrow position and through the accumulator alike. Worse, in the other direction a consumer getter returning a dependency-typedownhandle resolves the index in the wrong table and hands back a different object: a freshly decoded ledger'shead()reads correctly, and after three unrelated dependency handles have been minted the same expression returns some other object's fields, with no error anywhere.
The cause, read off the transpiled JS, is that jco allocates a separate handle table per component
instance for the same resource type and emits no transfer between them for a JS-held handle. This
is a jco defect and not an emitter one: the identical composed artifact is driven correctly through
wasmtime by component_compose.
The short-name collision is general rather than a property of that fixture. Each exported module
scope becomes one interface, so a single-scope crate's interface is always named types — any
two generated packages composed into one world collide on that name.
The boundary is about instances, not interfaces. Many interfaces in one component is fine:
a generated two-interface crate whose types take borrow<leaf> and list<borrow<leaf>> from a
sibling interface and return own leaf transpiles and drives correctly, repeated-borrow position
and all — measured on such a crate, since the gate's own fixtures are single-interface. It is many
component instances in one composed artifact that jco cannot yet wire. So a single generated crate
needs nothing special, and a multi-crate consumer transpiles per component.
Packaging: documented, not generated
There is no --package-json component flavor, and none is planned on the strength of what is
known today. The --map target is an npm package name, and no existing flag carries one:
--component-dep names the dependency's rust package and
--component-extern-wit names a WIT directory. A generated flavor
therefore needs a new cross-crate flag family with its config keys, its documented rows and its
deps-edge derivation, plus a ruling on how a component/ package sits under --package-json's
rust/-nesting layout — which is a good deal more than the scripts entry it looks like from
outside.
What ships instead is the shape itself: one npm package per generated component, jco transpile
in its build script, and the dependency wired by --map to the dependency package's specifier.
// @scope/chain — the dependency package
{
"name": "@scope/chain", "type": "module", "exports": "./dist/chain.js",
"scripts": { "build": "jco transpile chain_component.wasm -o dist --name chain" }
}
// @scope/wallet — the consumer package, dependencies: { "@scope/chain": "…" }
{
"name": "@scope/wallet", "type": "module", "exports": "./dist/wallet.js",
"scripts": {
"build": "jco transpile wallet_component.wasm -o dist --name wallet --map 'cddl:chain/types@0.1.0=@scope/chain#types'"
}
}
Reach for a generated flavor when the hand-maintained part stops being trackable by hand: a
generated tree whose consumer crates each carry more than a couple of deps edges, where
regenerating with a changed dependency set leaves a --map entry stale or missing and nothing says
so. That count — dependency edges per consumer — is the dimension the cost grows along, and it is
visible to the party maintaining the transpile script.
Not probed on this face
Stated so the remainder is visible rather than implied. Each of these is unprobed from JS; the underlying feature is exercised elsewhere:
--json-serde-derives'to-json/from-jsondoors. The fixtures behindcomponent_jcocarry no JSON methods. Probing it means generating one of them with the flag and driving the two doors from node.- The browser. Everything here is
preview2-shim's node build. Probing it means running the same drivers under a headless browser against the shim's browser entry. --preserve-encodings'to-canonical-cbor-bytes. Probing it means generating the surface fixture at that posture and asserting byte-exactness across the JS boundary.- Other versions. jco 1.26.1 and node 22 only — the gate pins both exactly, which is what makes its verdict a statement about a known vintage rather than about whatever npm resolved today. Probing another means a second pinned lockfile or node version.
- jco's other transpile options —
--instantiation,--minify,--optimize,--tracing,--no-wasi-shim,--valid-lifting-optimization,--import-bindings. None were needed for any of the above and none were exercised. Note--optimizerunswasm-opt, which refuses whole components outright.
Binary size
Measured on this project's own generated output under rustc 1.96.1, --target wasm32-wasip2,
[profile.release] opt-level = "s", with the rust crate narrowed to crate-type = ["rlib"]:
| Spec | Component .wasm | With [profile.release] strip = true |
|---|---|---|
A small single-scope spec (records, a choice, a bounded type, a map, an any field) | 275,768 B | 211,930 B (−23.1 %) |
A larger single-scope spec (the project's json fixture) | 338,772 B | 251,406 B (−25.8 %) |
| The smallest real spec measured (one bounded-bytes rule) | 145,020 B | — |
Three things to take from that:
- The floor is the rust crate, not the glue. A one-rule spec still produces a 145 KB component, because every generated component statically links the CBOR runtime it bridges to. Spec size moves the number far less than its presence does.
strip = trueis the cheap win, and it is a cargo profile key rather than a post-processing step. Roughly a quarter of an unstripped artifact is the wasmnamesection (63,825 B of the small fixture's core module). Note that stripping also removes thetarget_featurescustom section, which post-processing tools read — if you then run one, you may have to re-declare the features explicitly.wasm-optcannot process a component at all. Binaryen 125 refuses the file outright (this looks like a wasm component, which Binaryen does not support yet), so there is no post-wasm-optcomponent size to quote. As an upper bound on what a component-aware Binaryen would recover, runningwasm-opt -Oson the embedded core module takes the small fixture's 257,255 B module to 158,872 B (−38.2 %) and the large one's 296,870 B to 169,504 B (−42.9 %) — but most of that is the samename-section strippingstrip = truealready gives you; the optimisation itself accounts for about 7 % of the module.
The WASI import surface is a fixed floor, not a function of your spec. Every artifact measured
above — including the 145 KB one — imports exactly 14 wasi:* interfaces
(wasi:io/{poll,error,streams}, wasi:clocks/monotonic-clock, and ten wasi:cli/*, all at
@0.2.9 under this toolchain) and exports exactly one interface. Those imports come from the
sysroot's reactor adapter, not from generated code, and a host must satisfy them even for a pure
codec: an empty host linker fails on wasi:io/poll before your code runs. The generated WIT itself
stays WASI-free.