Writing Your First WIT Interface
This guide writes a WIT file from scratch — package, world, interface, functions with real types — and generates guest bindings from it, so the interface description becomes compiled code rather than documentation.
WIT is deliberately small. It has no expressions, no control flow and no generics beyond a handful of built-in containers. Everything it can say is about shapes: what a function takes, what it returns, and what a component imports and exports. That narrowness is what makes bindings generatable for every language.
Prerequisites
- [ ]
cargo-componentinstalled (cargo install cargo-component) - [ ]
wasm-toolsfor reading the interface back out of a built artifact - [ ] A clear idea of the interface you want — WIT is a poor place to explore a design
- [ ] Rust, or another language with maintained component bindings
Step-by-step procedure
1. Declare the package
// wit/world.wit
package example:imaging@0.1.0;
The namespace, name and version together identify the package. Versioning is part of the identity rather than metadata, which is what allows two versions of an interface to coexist in one build.
2. Describe an interface
interface resize {
record dimensions {
width: u32,
height: u32,
}
variant filter {
nearest,
bilinear,
lanczos(u32),
}
resize: func(image: list, from: dimensions, to: dimensions, using: filter)
-> result, string>;
}
Three type constructors are doing the work. record is a struct with named fields. variant is a
tagged union whose cases may carry a payload. result<T, E> is the standard success-or-error type, and
using it rather than returning a sentinel is the idiomatic way to express failure.
3. Declare what the component needs from its host
interface logging {
enum level { debug, info, warn, error }
log: func(at: level, message: string);
}
4. Tie them together in a world
world imaging {
export resize;
import logging;
}
5. Generate and implement
cargo component new --lib imaging && cd imaging
# put the WIT above in wit/world.wit
cargo component build --release
cargo component generates a trait for the exported interface and a module for the imported one:
use crate::bindings::exports::example::imaging::resize::{Dimensions, Filter, Guest};
use crate::bindings::example::imaging::logging::{log, Level};
struct Component;
impl Guest for Component {
fn resize(image: Vec<u8>, from: Dimensions, to: Dimensions, using: Filter)
-> Result<Vec<u8>, String>
{
log(Level::Info, &format!("{}x{} -> {}x{}", from.width, from.height, to.width, to.height));
if image.len() != (from.width * from.height * 4) as usize {
return Err("image length does not match dimensions".into());
}
Ok(scale(&image, from, to, using))
}
}
The Rust types are ordinary: list<u8> is a Vec<u8>, string is a String, result is a Result.
That mapping is what makes the generated code pleasant to implement against.
6. Read the interface back from the artifact
wasm-tools component wit target/wasm32-wasip1/release/imaging.wasm
Expected output
The round trip should reproduce what you wrote, with the package identity intact:
$ wasm-tools component wit imaging.wasm
package root:component;
world root {
import example:imaging/logging@0.1.0;
export example:imaging/resize@0.1.0;
}
package example:imaging@0.1.0 {
interface resize {
record dimensions { width: u32, height: u32 }
variant filter { nearest, bilinear, lanczos(u32) }
resize: func(image: list, from: dimensions, to: dimensions, using: filter)
-> result, string>;
}
}
Reading it back matters because the artifact is what consumers see. If a build step or a dependency altered the interface, this is where you find out — not when someone else’s generated bindings fail to match.
Gotchas
package not found. The package identifier in the file does not match the directory or the
dependency declaration. Names are structural here, not cosmetic.
Generated bindings do not match your implementation. The WIT changed and the build did not
regenerate. cargo component build regenerates; a stale bindings.rs checked into the repository does
not.
A variant case with a payload will not compile on the guest side. Payload types have to be
declared before use, and recursion is not allowed. Flatten the type or introduce a resource.
list<u8> is copied. Every list crosses by copy under the canonical ABI. For large buffers that is
a real cost — see understanding the canonical ABI.
Version churn breaks consumers. WIT versions are part of the interface identity, so bumping one is a breaking change for anyone importing it. Treat the version field with the same care as a public API.
Performance note
The type vocabulary has direct performance consequences, and the largest is that list<T> copies. A
function taking a list<u8> and returning one moves the data twice per call. For an image pipeline
processing frames that is significant, and the mitigations are the familiar ones: process in larger
units, or expose a resource that owns the buffer across calls so it does not cross on every one.
Records and variants are cheap — they lower into a handful of scalars — so a chatty interface made of small records costs far less than one moving lists. Designing the interface around that asymmetry usually produces both a faster boundary and a clearer API.
Designing an interface that will survive
WIT makes it easy to write an interface quickly and slightly too easy to write one you will regret. Four habits keep a first interface usable a year later.
Name the domain, not the implementation. resize: func(image: list<u8>, ...) describes what the
caller wants; run-lanczos-kernel: func(buf: list<u8>, ...) describes how it currently works. The
first survives a change of algorithm and the second does not.
Make failure explicit. Returning result<T, string> costs nothing and forces callers to handle the
error case; returning a sentinel value or an empty list does not. Where the errors have structure, a
variant beats a string:
variant resize-error {
invalid-dimensions,
unsupported-format(string),
out-of-memory,
}
That gives every consumer a machine-readable reason instead of a message to pattern-match.
Version from the start. A package without a version is a package you cannot evolve without breaking
consumers. Starting at @0.1.0 and bumping deliberately is much easier than retrofitting versioning
once several components depend on the interface.
Keep the world small. A world that imports six interfaces requires a host to supply all six before the component runs at all. Importing only what the component genuinely needs makes it usable in more places, and makes the capability review shorter — the same principle as a minimal import object.
Sharing an interface between projects
The reason to write WIT rather than generate bindings directly from source is that the interface becomes an artifact in its own right, and artifacts can be shared. Publishing the WIT package means a guest in Rust and a host in JavaScript are both generated from one file, and neither can drift from it silently.
wit/
deps/
example-imaging/ # pulled from the published package
world.wit
world.wit # your own world, importing the shared interface
For a plugin system that is the whole point: the interface is the contract, published and versioned separately from any implementation, and third parties build against it without seeing your code. For a single application it is less compelling — the WIT lives next to the source and both change together — which is another reason the model repays polyglot situations more than single-language ones.
Frequently Asked Questions
Can I use WIT without the component model? Not usefully; it describes component interfaces. For core modules the equivalent is a hand-maintained description of your own ABI.
How do I share interfaces between projects? Publish the WIT package and depend on it by name and version. That is the mechanism that lets two independently built components agree on a type.
Is resource worth using?
Yes when an object has a lifetime — an open file, a decoder, a session. It gives the host a handle with
methods and a defined ownership story, which is much better than passing an integer you have to
validate yourself.
How do I evolve an interface without breaking consumers? Add rather than change: a new function or a new variant case is compatible in a way that altering an existing signature is not. When a breaking change is unavoidable, bump the package version and let both versions coexist — that is precisely what versioned package identity is for, and consumers migrate on their own schedule instead of all at once.
Can one package contain several worlds?
Yes, and it is a useful pattern: a full world for hosts that can provide everything and a minimal
world for those that cannot. Components are built against one world each, so publishing two lets
implementers choose their level of capability without you maintaining two interfaces.
Should the WIT live with the guest or in its own package? With the guest while there is one implementation; in its own package as soon as there are two.
Related
- Wasm component model & WIT bindings — the surrounding model.
- Generating JavaScript bindings with jco — consuming this interface from JavaScript.
- Understanding the canonical ABI — what these types cost at the boundary.
- Passing complex types across the boundary — the hand-written equivalent.
← Back to Wasm Component Model & WIT Bindings