Generating JavaScript Bindings with jco

This guide takes a built WebAssembly component and turns it into a JavaScript module you can import — typed exports, host-supplied imports, and the generated files a bundler needs to see.

Browsers do not load components natively. jco bridges that by transpiling: it extracts the core modules from the component, generates the JavaScript that implements the canonical ABI around them, and emits an ES module whose exports mirror the component’s WIT interface. From the caller’s point of view the result behaves like any other module.

What transpilation produces The input component is split into the core modules it contains and a generated JavaScript file that lifts and lowers typed values around them. The application imports the JavaScript, which loads the core modules itself. imaging.wasm a component jco imaging.core.wasm the extracted core module imaging.js canonical ABI, in JavaScript imaging.d.ts types derived from the WIT import { resize } from … an ordinary ES module All three outputs must ship together — the JavaScript loads the core module by relative path.

Prerequisites

  • [ ] Node 20+ and jco available (npx jco --version)
  • [ ] A built component — see writing your first WIT interface
  • [ ] A bundler that can resolve .wasm imports, if the target is a browser
  • [ ] The WIT to hand, so you know what the generated names should be

Step-by-step procedure

1. Transpile

npx jco transpile imaging.wasm -o dist/imaging
ls dist/imaging
# imaging.js  imaging.d.ts  imaging.core.wasm

2. Import and call

import { resize } from "./dist/imaging/imaging.js";

const out = resize(
  pixels,                                   // list<u8> → Uint8Array
  { width: 1920, height: 1080 },            // record → object
  { width: 640, height: 360 },
  { tag: "lanczos", val: 3 },               // variant → tagged object
);

A result<T, E> becomes a thrown error on the failure path rather than a returned union, which is the idiomatic JavaScript shape — so wrap calls that can fail in try/catch.

3. Supply the imports the world declares

npx jco transpile imaging.wasm -o dist/imaging --map 'example:imaging/logging=./logging.js'
// logging.js
export function log(level, message) {
  console[level === "error" ? "error" : "log"](message);
}

The mapping tells the generated code where to find each imported interface. Without it, transpilation emits a stub that throws when called — which is a clear failure, but only at run time.

4. Check the generated types

// dist/imaging/imaging.d.ts (excerpt)
export interface Dimensions { width: number; height: number }
export type Filter = { tag: "nearest" } | { tag: "bilinear" } | { tag: "lanczos"; val: number };
export function resize(image: Uint8Array, from: Dimensions, to: Dimensions, using: Filter): Uint8Array;

5. Bundle for the browser

// vite.config.ts
export default defineConfig({
  plugins: [wasm(), topLevelAwait()],
  build: { target: "esnext" },
});

The generated JavaScript imports its core module, so the same bundler configuration that handles any .wasm import applies — see bundling Wasm ESM with Vite.

6. Run it in Node to check before involving a bundler

node --input-type=module -e "
  import { resize } from './dist/imaging/imaging.js';
  console.log(resize(new Uint8Array(16), {width:2,height:2}, {width:1,height:1}, {tag:'nearest'}).length);
"

Expected output

$ npx jco transpile imaging.wasm -o dist/imaging
Transpiled JS Component Files:

 - dist/imaging/imaging.core.wasm   198.4 KiB
 - dist/imaging/imaging.d.ts          1.2 KiB
 - dist/imaging/imaging.js           14.7 KiB

$ node test.mjs
resized 1920x1080 → 640x360 in 14.2 ms

The generated JavaScript is the interesting number. Fifteen kilobytes of ABI code is comparable to what wasm-bindgen emits for a similar surface, and like that glue it grows with the richness of the interface rather than with the size of the module behind it.

What makes the generated glue large An interface of numeric functions produces almost no JavaScript. Adding strings and lists brings in encoding and copying code. Adding records, variants and resources brings in construction and lifetime handling. The size of the core module behind the interface is irrelevant. numeric functions only ≈ 2 KB + strings and lists ≈ 8 KB + records, variants, resources ≈ 15 KB The core module could be 20 KB or 2 MB — none of it appears in this measure. So the lever for glue size is the interface surface, exactly as with any other binding generator.

Gotchas

missing import at run time. The world declares an import you did not map. Pass --map for each, and check the world with wasm-tools component wit if you are unsure what it expects.

The core module 404s in production. The generated JavaScript loads it by relative path, and a bundler that did not process the import left the path pointing at the source tree. Ensure the transpiled output goes through the bundler, not around it.

Top-level await errors in the build. The generated module instantiates at import time. Set build.target to esnext and add the top-level-await plugin if your bundler needs one.

A result does not appear in the return value. By design: the error case throws. Catch it rather than checking for a tag.

Regenerating after a WIT change breaks callers silently. The types changed and JavaScript does not check them. Use the emitted .d.ts and run tsc --noEmit in CI to turn that into a build failure.

Ship all three, or none of them work The generated JavaScript loads the core module by relative path and is described by the declaration file. Deploying the JavaScript without the core module produces a runtime failure; omitting the declaration file only costs type checking. imaging.js what the app imports loads the next box by path imaging.core.wasm the actual code missing → runtime failure imaging.d.ts types from the WIT missing → only type checking is lost Route the whole output directory through the bundler rather than copying files individually — that is where the path usually breaks.

Performance note

Transpiled components run the same core module, so the compute is unchanged. The overhead is in the generated ABI code, which does the same lifting and lowering wasm-bindgen glue does: encoding strings, copying lists, constructing objects for records. For coarse-grained calls that overhead is noise; for a per-element interface it dominates, exactly as it would with any other binding layer.

One difference worth knowing is that the canonical ABI copies lists in both directions by specification, where a hand-written binding could have passed a pointer and aliased the memory. If a kernel moves megabytes per call, keep a raw core-module path alongside the component interface and use the typed one for the control plane only.

Fitting transpilation into a build

Transpilation is a build step, and treating it as one — rather than as something run by hand and committed — avoids the usual drift. The pattern that works is a script that builds the component and transpiles it in sequence, with the output directory treated as generated:

{
  "scripts": {
    "build:wasm": "cargo component build --release",
    "build:bindings": "jco transpile target/wasm32-wasip1/release/imaging.wasm -o src/generated --map 'example:imaging/logging=../logging.js'",
    "build": "npm run build:wasm && npm run build:bindings && vite build"
  }
}

Two details make this reliable. The output directory belongs in .gitignore, because a committed generated file is one that will eventually disagree with the component beside it. And the --map arguments live in the script rather than in someone’s shell history, since a missing mapping produces a stub that throws at run time rather than a build error.

For type checking, point tsc at the generated declaration file and run it after transpilation. That turns an interface change into a compile error in the consuming code, which is the whole benefit of having typed interfaces in the first place:

npm run build:bindings && npx tsc --noEmit

Debugging generated bindings

When a call misbehaves, the generated JavaScript is readable and worth opening. It contains the lifting and lowering code for each function, which means you can see exactly what happens to an argument on its way across — where a string is encoded, where a list is allocated, where a result is unpacked into a value or a throw.

Three things are worth locating the first time. The instantiation block near the top shows which core modules are loaded and in what order, which explains a “module not found” failure immediately. The realloc export is what the ABI calls to allocate in the component’s memory, and a failure there is usually an out-of-memory condition rather than a binding bug. And each exported function has a small wrapper that does the conversions — reading one is the fastest way to understand why an argument shape you expected to work does not.

Because the file is generated, do not edit it. If something needs to change, the change belongs in the WIT or in the guest implementation, and the next transpile will overwrite anything else. That is a constraint worth respecting even under time pressure: an edited generated file is a bug that reappears on the next build with no record of what was changed.

Frequently Asked Questions

Can I use jco output without a bundler? Yes — the output is standard ES modules and works from a static server, provided the .wasm is served as application/wasm.

Does this work in a worker? Yes. Import the generated module inside the worker; it will load its own core module there, and the worker gets its own instance and memory.

Is there a Node-specific mode? jco transpile output runs in Node as-is. There are flags for instantiation style and for WASI support, which matter when the component’s world imports WASI interfaces.

Does the generated code work with a Content Security Policy? It needs whatever policy WebAssembly compilation requires — typically wasm-unsafe-eval in script-src. That is the same requirement any Wasm module has and is unrelated to transpilation, but it surprises people who assumed the JavaScript output meant no Wasm-specific policy was involved.

Can I transpile once and commit the output? You can, and it will drift. The generated files correspond to a specific component build, so committing them means the next component change produces bindings that no longer match unless somebody remembers to regenerate. Treating them as build output avoids the whole class of problem.

Is the transpiled output tree-shakeable? Partly: unused generated wrappers can be removed by a bundler, but the core module is one opaque asset and travels whole. The lever on size is therefore the interface surface rather than the bundler.

Does the output work in Deno or Bun? Generally yes; both support the module syntax and the WebAssembly API it relies on.

← Back to Wasm Component Model & WIT Bindings