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.

Package, interface, world A package namespaces and versions a set of interfaces. An interface groups related functions and types. A world names the interfaces a component exports and the ones it expects the host to provide, which together form its complete contract. package example:imaging@0.1.0 — namespace + version interface resize types and functions that belong together reusable across worlds interface logging something the host provides the component imports it world imaging export resize · import logging — the complete contract a host must satisfy A component is built against exactly one world; the world is what tooling on both sides generates from.

Prerequisites

  • [ ] cargo-component installed (cargo install cargo-component)
  • [ ] wasm-tools for 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.

The type vocabulary is small on purpose Primitives, string and list cover most interfaces. Record, variant and enum describe structured data. Result and option express fallibility and absence. Each maps predictably into a Rust type and a JavaScript value. u8 · u32 · s64 · f64 · bool · char primitives — direct mappings both sides string · list<T> · tuple String / Vec<T> in Rust, string / Array in JS record · variant · enum · flags structs and tagged unions option<T> · result<T, E> absence and failure, first-class resource an opaque handle with methods and an owner There is no pointer type, and that is the point. Everything a WIT interface can say is a shape — which is exactly what a binding generator needs and nothing more.

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.

When to reach for a resource Passing a large list on every call copies it each time. Declaring a resource that owns the data lets the caller create it once and pass a handle afterwards, so subsequent calls move a single integer instead of megabytes. list on every call the buffer is copied into the callee three times resource + handle created once, then a handle per call The interface reads almost the same in WIT; the difference at run time is between copying megabytes and copying four bytes.

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.

← Back to Wasm Component Model & WIT Bindings