Skip to main content

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 record has no validated construction, so any constrained type (a value bound, a [+ …] non-empty container, a @duplicates reject set) 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 and variants may not be recursive; CDDL specs routinely are. Handles are opaque, so resource 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-encodings a type carries a hidden *Encoding sidecar 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 themindex_0 becomes index0, not index-0. This is a consumer-compatibility floor, not a legality constraint: index-0 is 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: map is a keyword at the pinned floor and was not at 1.231, while float32/float64 were 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 spelled Value::U64 projects to new-uint and as-uint, never new-u64. The new-/as-/kind families 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::ca-c), and the world is <lib-name>-world. Interfaces linked with use must be acyclic, which is why a multi-file spec with mutually-referencing scopes generates without --component and 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 returns Err(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 any is re-encoded canonically on read-back: set an indefinite-length 9f0102ff and the getter returns the definite-length 820102. That is the same rule every type in that posture follows — any is simply where a caller is most likely to assume otherwise. Under --preserve-encodings the 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 keeps any-cbor-kind a 1:1 projection of the runtime discriminant with no synthetic invalid case. Under --json-serde-derives the free functions cbor-to-json and cbor-from-json join it, both fallible for the same reason. Invalid bytes always cross as a normal Err, 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 newwit-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 take list<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-bytes exists 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 shapelowering
borrow<t>works
option<borrow<t>>works
list<borrow<t>> over an exported resourceworks
list<borrow<t>> over an imported resourceE0506
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.

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's TryFrom door is never routed.
  • @default fields: a defaulted field is a plain T on the rust side (the default fills absence in) while the projection still treats it as optional, so the glue reads and writes an Option that 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-methods does not gate this face. to-cbor-bytes and from-cbor-bytes are 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-bytes is the exception that is posture-gated: it appears only under --preserve-encodings --canonical-form, the one posture whose composed runtime declares it.
  • --json-serde-derives adds a fallible to-json / from-json pair to each type the tool defines, plus the cbor-to-json / cbor-from-json free functions. They are strings only; there is no JsValue equivalent to mirror the wasm face's to_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-export and 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=false user loses nothing.
  • --emit-tests with --component prints a one-line skip and continuescddl-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 namemod['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

WITWhat JS sees
resourcea class, with kebab-case members camelCased (set-nestedsetNested, from-cbor-bytes → the static fromCborBytes)
enuma 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 intan 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
u64bigint (42n), never number
option<t> in a returnt 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-cborUint8Array in both directions, with trailing data refused where the bytes are consumed
free functiona module-level export beside the classes (cbor-kindcborKind)
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.ts emits export * as types twice and tsc --strict fails with TS2300: Duplicate identifier 'types'. The runtime .js disambiguates by exporting the fully-qualified names, so the bare types alias 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-typed own handle resolves the index in the wrong table and hands back a different object: a freshly decoded ledger's head() 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-json doors. The fixtures behind component_jco carry 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 --optimize runs wasm-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"]:

SpecComponent .wasmWith [profile.release] strip = true
A small single-scope spec (records, a choice, a bounded type, a map, an any field)275,768 B211,930 B (−23.1 %)
A larger single-scope spec (the project's json fixture)338,772 B251,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 = true is 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 wasm name section (63,825 B of the small fixture's core module). Note that stripping also removes the target_features custom section, which post-processing tools read — if you then run one, you may have to re-declare the features explicitly.
  • wasm-opt cannot 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-opt component size to quote. As an upper bound on what a component-aware Binaryen would recover, running wasm-opt -Os on 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 same name-section stripping strip = true already 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.