Mapping C Error Codes to JavaScript Errors

This page answers one task: a C library compiled to WebAssembly reports failure the C way — a negative return value, sometimes with a message available through a separate call — and JavaScript callers should instead get a thrown error with a type, a code and a readable message.

Prerequisites

  • [ ] A C or C++ module built with Emscripten or wasi-sdk whose functions return integer status codes.
  • [ ] The ability to add a small amount of C to the module (an error-message accessor), or at least a list of the library’s codes.
  • [ ] A JavaScript wrapper module through which callers reach the exports, as in designing a promise-based API around a Wasm module.

Why codes should stop at the wrapper

C libraries signal errors through return values because C has no exceptions: 0 for success, a negative number for failure, maybe a global errno or a lib_last_error() function for detail. Compiled to WebAssembly, that convention crosses the boundary unchanged — the export returns an i32, and JavaScript gets a number. Left alone, every caller has to remember to check it, know which numbers mean what, and decide how to report them. Callers forget, and a failed decode silently becomes an empty image.

JavaScript’s convention is the opposite: failure throws, and the caller handles it in a catch or lets it propagate. The wrapper is where one convention turns into the other. Every export call passes through a single helper that checks the return value, looks up what it means, fetches any message the library has, and throws an error object of the right class. Callers then never see a raw code, and a forgotten check is impossible because there is nothing to check.

From a C return code to a JavaScript exception The C function returns a negative status. The wrapper's check helper sees it, reads the library's last-error message from linear memory, looks the code up in a shared table, and throws an instance of the matching JavaScript error class. C function return -3 (LIB_EFORMAT) check(rc) rc < 0 means failure read message lib_last_error() → string code table -3 → FormatError throw new FormatError(msg, -3)

Step 1 — make the codes available to both sides

Define the codes once, in the C header, and generate or hand-copy the JavaScript table from it. A header such as this:

// imglib_errors.h
#define IMG_OK          0
#define IMG_EINVAL     -1   /* bad argument */
#define IMG_ENOMEM     -2   /* allocation failed */
#define IMG_EFORMAT    -3   /* input is not a supported image */
#define IMG_ETRUNC     -4   /* input ended early */
#define IMG_ELIMIT     -5   /* image exceeds configured limits */

maps to a JavaScript table that names each code and picks the error class it should become:

// errors.js
export const CODES = {
  [-1]: ["EINVAL", RangeError],
  [-2]: ["ENOMEM", OutOfMemoryError],
  [-3]: ["EFORMAT", FormatError],
  [-4]: ["ETRUNC", FormatError],
  [-5]: ["ELIMIT", LimitError],
};

For a library with dozens of codes, generate the table in the build — a ten-line script that parses the #define lines keeps the two sides from drifting apart when the library adds a code.

Step 2 — expose the library’s error message

Codes say what kind of failure happened; a message says which one. Many C libraries keep a per-context or thread-local error string. Export an accessor that returns a pointer to it:

#include <emscripten.h>

static char last_error[256];

void img_set_error(const char *msg) {            /* called inside the library where it fails */
    strncpy(last_error, msg, sizeof last_error - 1);
}

EMSCRIPTEN_KEEPALIVE const char *img_last_error(void) { return last_error; }

Reading it from JavaScript is a pointer-to-string conversion — UTF8ToString in Emscripten glue, or a TextDecoder over a subarray of memory for hand-written glue. Copy the message out immediately: the next failing call overwrites the buffer.

Step 3 — check every call in one helper

export class ImgError extends Error {
  constructor(message, code, name) { super(message); this.code = code; this.codeName = name; }
}
export class FormatError extends ImgError { name = "FormatError"; }
export class LimitError extends ImgError { name = "LimitError"; }
export class OutOfMemoryError extends ImgError { name = "OutOfMemoryError"; }

function check(rc) {
  if (rc >= 0) return rc;                                 // success, possibly a count or handle
  const [codeName, Cls] = CODES[rc] ?? ["EUNKNOWN", ImgError];
  const detail = Module.UTF8ToString(Module._img_last_error());
  const err = Cls === RangeError ? new RangeError(detail) : new Cls(detail || codeName, rc, codeName);
  err.code ??= rc;
  throw err;
}

export function decode(bytes) {
  const ptr = copyIn(bytes);
  try {
    const handle = check(Module._img_decode(ptr, bytes.length));
    return readImage(handle);
  } finally {
    Module._free(ptr);                                    // free on success and on failure
  }
}

The finally matters as much as the check: buffers passed into a failing call must still be freed, or every error leaks memory. Unknown codes fall back to the base class with the raw number preserved, so a new code in the library produces a usable error rather than a crash in the wrapper.

Step 4 — let callers branch on the class

Callers now handle failures with ordinary JavaScript:

try {
  const img = await decode(bytes);
  show(img);
} catch (err) {
  if (err instanceof FormatError) toast("That file isn't an image we can read.");
  else if (err instanceof LimitError) toast("That image is too large.");
  else throw err;                                         // unexpected: let it reach the error tracker
}

Choosing classes for the categories a caller acts on — rather than one class per C code — keeps the surface small. A user-facing app usually needs three or four distinctions: bad input, too large, out of memory, and everything else. The code stays on the error object for logs and support tickets.

Which C codes become which JavaScript errors Each library status code maps to an error class chosen by what the caller should do. Argument errors become RangeError, format and truncation errors share FormatError, limits get LimitError, and allocation failure gets OutOfMemoryError. C code JS class caller action IMG_EINVAL (-1) RangeError fix the calling code IMG_ENOMEM (-2) OutOfMemoryError free memory or reload IMG_EFORMAT (-3) FormatError tell the user IMG_ETRUNC (-4) FormatError tell the user IMG_ELIMIT (-5) LimitError offer to downscale

Step 5 — distinguish codes from traps

A returned code means the library detected the problem and returned cleanly; the instance is fine and can be used again. A trap — an out-of-bounds access, an abort(), a failed assertion — is different: the call never returned, and the module may be left mid-update. Keep the two apart in the wrapper. Codes become the domain errors above; a WebAssembly.RuntimeError should become a separate “engine crashed” error and trigger re-instantiation, as covered in recovering a module after a trap. Emscripten’s abort() also throws, with a message prefixed Aborted(, which the wrapper can recognise in the same place.

Handling errno from libc calls

Ported code often reports failure through libc: fopen returns NULL and sets errno to ENOENT; read returns -1 and sets EIO. In Emscripten and wasi-libc, errno is an ordinary variable in linear memory, so a JavaScript wrapper cannot read it directly without help. Export a one-line accessor — int lib_errno(void) { return errno; } — or, better, have the C wrapper function translate errno into the library’s own code before returning, so JavaScript sees one consistent scheme. The numeric values of errno constants differ between WASI and Emscripten’s musl-based libc, so never hard-code them in JavaScript; translate inside C, where the right header is in scope, and pass only the library’s own codes across. With strerror(errno) copied into the last-error buffer, the JavaScript message even carries the familiar “No such file or directory” text.

Expected output

Decoding a truncated PNG throws FormatError: unexpected end of IDAT stream with err.code === -4 and err.codeName === "ETRUNC"; the next call to decode with a valid file succeeds on the same instance, and heap usage is unchanged after a thousand failing calls.

Gotchas

  • Reading the message after another call. The last-error buffer is overwritten. Copy it out before calling anything else.
  • Forgetting to free on failure. Use try … finally around every call that takes an allocated buffer.
  • Treating positive returns as errors. Many functions return counts or handles on success. Check rc < 0, not rc !== 0.
  • Duplicated code tables drifting. Generate the JavaScript table from the C header in the build.
  • Hard-coding errno values. They differ between libcs. Translate inside C.

Performance note

The check helper costs one comparison on success, so the happy path is unaffected. On failure, reading the message and building the error object cost about 4 µs in Chrome — negligible next to the decode attempt that failed. The finally free added nothing measurable.

Per-call cost of error handling in the wrapper Microseconds added per call by the error-checking wrapper on the success path and on the failure path, compared with the decode itself for a small image. microseconds per call check on success 0.0 µs check + message on failure 4 µs decode of a 64 KB PNG 820 µs

Frequently Asked Questions

Should every C code get its own JavaScript class? No. Group them by what the caller does differently, and keep the precise code as a property.

Can Emscripten throw JavaScript exceptions from C directly? Yes, through EM_JS or emscripten_run_script, but throwing out of the middle of C skips its cleanup. Returning a code and throwing in the wrapper is safer.

What about C++ exceptions? With -fwasm-exceptions they can propagate as WebAssembly.Exception; catching them in a C++ shim and returning codes keeps the boundary simpler.

Should the wrapper log errors as well as throw them? No. Throw, and let the caller or the application’s error boundary decide whether a failure is worth logging. Logging in the wrapper produces duplicates.

How do I test the mapping? Call each export with inputs known to fail in each way and assert the class and code, as part of the module’s normal test suite.

What if the library is not thread-safe about its error buffer? In a threaded build, keep the last-error buffer thread-local (_Thread_local in C11) so one worker’s failure does not overwrite another’s message.

← Back to Errors & Traps Across the Boundary