Returning Error Codes Without Exceptions

This guide answers one task: design a failure convention for a WebAssembly module’s exported functions that needs no exceptions, no strings and no language-specific machinery — so it works the same from Rust, C, Zig or AssemblyScript and costs nothing in binary size.

Prerequisites

  • [ ] A module whose interface you control.
  • [ ] A host that can branch on a return value, which is all of them.
  • [ ] A size or portability reason to avoid thrown errors.
  • [ ] A short list of the failures the interface can report.

Negative codes, positive results

The simplest convention that works: a function returns a non-negative value on success and a negative code on failure. It fits in one i32, costs nothing, and every language and host can produce and consume it.

pub const ERR_EMPTY: i32     = -101;
pub const ERR_TOO_LARGE: i32 = -102;
pub const ERR_BAD_MAGIC: i32 = -103;

#[no_mangle]
pub extern "C" fn process(ptr: *const u8, len: usize) -> i32 {
    if len == 0            { return ERR_EMPTY; }
    if len > MAX_INPUT     { return ERR_TOO_LARGE; }
    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    if !data.starts_with(MAGIC) { return ERR_BAD_MAGIC; }
    run(data) as i32                          // the output length
}
const n = instance.exports.process(ptr, len);
if (n < 0) return { ok: false, code: n };
return { ok: true, bytes: new Uint8Array(memory.buffer, outPtr, n) };

One comparison at the call site, no exception machinery, no strings. For a function whose natural success value is a count or a length — which is most of them in a byte-oriented interface — this is both the smallest and the clearest option.

One value, two meanings, one comparison A non-negative return is the successful result, typically a length. A negative return is an error code, grouped by hundreds so the caller can dispatch on the category without knowing every individual code. i32 return value ≥ 0 < 0 the result a length, a count, an index use it directly an error code -1xx input · -2xx limits · -3xx state map to a message on the host

When the success value can be negative

The convention breaks for a function whose natural result includes negative numbers — a comparison, a signed measurement, a delta. Two fixes work.

Return the status and write the value to an out-parameter the caller supplies:

#[no_mangle]
pub extern "C" fn compare(a: *const u8, an: usize, b: *const u8, bn: usize, out: *mut i32) -> i32 {
    if an == 0 || bn == 0 { return ERR_EMPTY; }
    unsafe { *out = do_compare(a, an, b, bn); }     // may be negative
    0                                                // status: ok
}

Or pack both into an i64, with the status in the high half and the value in the low:

#[no_mangle]
pub extern "C" fn compare_packed(/* … */) -> i64 {
    match do_compare(/* … */) {
        Ok(v)  => (v as u32) as i64,                  // status 0 implied by the high half
        Err(c) => ((c as i64) << 32) | 0,
    }
}
const packed = BigInt(instance.exports.compare_packed(...));
const status = Number(packed >> 32n);
const value  = Number(BigInt.asIntN(32, packed));
if (status !== 0) return { ok: false, code: status };

The out-parameter is simpler to read and needs a scratch location; the packed form needs no memory and pushes BigInt arithmetic onto the caller. Either is fine, and choosing one and using it consistently matters more than which.

Detail beyond a code

Sometimes the caller needs more than a category — which field failed, at which offset, with what limit. A small fixed structure written to a known location carries that without strings.

#[repr(C)]
pub struct ErrorDetail { pub code: i32, pub offset: u32, pub limit: u32, pub got: u32 }

static mut LAST_ERROR: ErrorDetail = ErrorDetail { code: 0, offset: 0, limit: 0, got: 0 };

#[no_mangle]
pub extern "C" fn last_error_ptr() -> *const ErrorDetail { unsafe { &LAST_ERROR } }
if (n < 0) {
  const dv = new DataView(memory.buffer, instance.exports.last_error_ptr(), 16);
  const detail = {
    code:   dv.getInt32(0, true),
    offset: dv.getUint32(4, true),
    limit:  dv.getUint32(8, true),
    got:    dv.getUint32(12, true),
  };
  showError(`${MESSAGES[detail.code]} (limit ${detail.limit}, got ${detail.got})`);
}

Sixteen bytes, four integers, no formatting inside the module — and the host can produce a message as specific as any the module could have written, in the user’s language.

The one hazard is the usual one for a “last error” pattern: it is global state, so it is only valid immediately after the call that set it, and it is wrong in the presence of concurrency. Read it directly after a failure and never rely on it later.

A code, plus four integers if you want them The return value carries the category. A small fixed structure at a known address carries the specifics, which the host reads with a DataView and formats into a message. return −102 the category last_error, 16 bytes code, offset, limit, got read with a DataView a specific message formatted by the host Valid only immediately after the failing call — a global "last error" is convenient and has exactly the lifetime you would expect.

Generating both sides from one source

Once the code list exceeds a handful, keeping the module’s constants and the host’s messages in step by hand starts to fail — a code added in one place and not the other produces either a message nobody sees or a failure rendered as undefined.

Generating both from a single definition removes the problem entirely, and the generator can be twenty lines.

// errors.json — the single source
[
  { "code": -101, "name": "EMPTY",      "message": "The input is empty." },
  { "code": -102, "name": "TOO_LARGE",  "message": "That input exceeds the size limit." },
  { "code": -103, "name": "BAD_MAGIC",  "message": "That file format is not recognised." }
]
# generate the Rust constants and the JavaScript table
node tools/gen-errors.mjs errors.json src/errors.rs web/errors.js
// src/errors.rs — generated, do not edit
pub const ERR_EMPTY: i32 = -101;
pub const ERR_TOO_LARGE: i32 = -102;
pub const ERR_BAD_MAGIC: i32 = -103;
// web/errors.js — generated, do not edit
export const MESSAGES = {
  '-101': 'The input is empty.',
  '-102': 'That input exceeds the size limit.',
  '-103': 'That file format is not recognised.',
};

Adding a code then means editing one file and rebuilding, and a code that exists on one side but not the other becomes impossible rather than merely unlikely. For an interface consumed by several hosts — a browser, a server, a command-line tool — the same generator emits each of them from the same list, which is where the approach pays for itself several times over.

Check the generated files in rather than generating at build time, so a consumer reading the repository can see the actual values without running anything.

Expected output

The convention in use, from both sides:

process(ptr, 0)        → -101   (empty)
process(ptr, 1 << 30)  → -102   (too large; detail: limit 1048576, got 1073741824)
process(ptr, 4096)     → 2048   (success: 2048 bytes written)
const MESSAGES = {
  '-101': 'The input is empty.',
  '-102': 'That input exceeds the size limit.',
  '-103': 'That file format is not recognised.',
};

Adding a code means adding an entry here and a constant in the module, with no change to the calling convention and no risk to existing callers — which is the property that makes this convention age well.

A code, then a message The hot path returns a small integer. Only when it is negative does the caller make a second call to fetch the message, so the common case allocates nothing. call returns -3 one i32, no allocation caller branches negative means error last_error_ptr() message fetched message decoded shown or logged The second call happens once per failure, so the formatting cost never touches the success path. Keep the code space stable across versions; a renumbered error is a breaking change nobody notices. Zero means success and positives can carry a result, which leaves the whole negative range for errors.

Gotchas

  • A success value that can be negative. Use an out-parameter or a packed result.
  • Reusing a code for a different meaning. Existing callers keep the old interpretation; only ever add.
  • Reading last_error late. It reflects the most recent failure, not the one you are handling.
  • No default in the message lookup. An unrecognised code renders as undefined to the user.
  • Codes without categories. A caller that must know every individual code cannot handle a new one gracefully.
  • Mixing conventions across the interface. Some functions throwing and some returning is harder to use than either alone.

Performance note

Returning a code costs one comparison at the call site and nothing in the module — no allocation, no formatting, no exception machinery. Against a string-returning interface on the same module, the compressed binary was 27 kB rather than 41 kB, and the call was marginally faster because nothing was serialised. The cost is on the host side: a lookup table of messages, which is a few hundred bytes and lives with the rest of the application’s text.

Frequently Asked Questions

Is this not a step backwards from exceptions? It is the C convention, and it is the right one here for the same reasons it persists in systems interfaces: it is language-neutral, it costs nothing, and it works identically from every host. For a module consumed by one language, richer error values are reasonable.

Should zero mean success or be a valid result? Reserve zero for success where the function has no natural return value, and use a separate status where zero is a legitimate result — a count of zero is a perfectly good answer and should not look like an absence of one.

How do I document the codes? In one place, next to the interface, with the category ranges explained. Generating both the module’s constants and the host’s message table from one source is worth doing once the list exceeds a handful.

Does this work from AssemblyScript and Zig too? Identically — it is the whole appeal. A convention expressed in integers needs no language support beyond returning one, which is why a module intended for several source languages should use it.

What about 64-bit results? The same convention with an i64 works, or an out-parameter. Be aware that i64 values arrive as BigInt in JavaScript, which is occasionally awkward enough to prefer the out-parameter.

It is an old convention and an unfashionable one, and for a compiled module with several possible hosts it remains the one that costs least and works everywhere.

← Back to Errors & Traps Across the Boundary