This page describes the current SSA builder and backend implementation. Its unversioned esp examples are compatibility snapshots, still accepted by the reader. Start with the new text walkthrough for esper 1 syntax and the roadmap for the library-first migration boundary.

Esper has no exception mechanism. Error handling is implemented by the frontend using return values, tagged unions, or extern calls to runtime support functions. This guide shows how to lower each pattern.

A failure the language does not intend to recover from is a different question: that is a panic, and the symbols a frontend supplies for one are covered in Embedding Esper. Everything below is about failures the program is expected to handle.

What we’re lowering

Consider a language with built-in error handling:

func divide(a: Int32, b: Int32) -> Result<Int32, Error> {
    if b == 0 {
        return .error(DivisionByZero)
    }
    return .ok(a / b)
}

func main() -> Int32 {
    match divide(10, 0) {
    case .ok(let value):
        return value
    case .error(let err):
        return -1
    }
}

The frontend must decide how to represent Result<T, E> and how call sites check for errors. There are four common strategies, ordered from simplest to most complex.

Strategy 1: Return codes

The simplest approach: functions return an integer where 0 means success and nonzero means error. The actual result (if any) is written through an output pointer.

Pseudo-code

func parseInt(s: *UInt8) -> Int32 {
    let ch = s[0]
    if ch < '0' || ch > '9' {
        return -1  // error
    }
    // ... parsing logic ...
    return 0       // success
}

Step-by-step lowering

  1. Return type: The function returns i32. The caller checks if the return value is zero (success) or nonzero (error code).
  2. Error path: When validation fails, produce the error code and ret. No memory allocation, no struct, no tag. This is the cheapest possible error representation.
  3. Why return codes? Zero overhead on the success path (just a ret with a value). The tradeoff is that the caller must check the return value at every call site, and there is no way to carry rich error information without a separate output pointer.

Esper lowering

func $parse_int(%0 ptr) -> i32 {
entry:
  // Load the first byte.
  %1 = load8.u i32 %0

  // Check if it is below '0' (0x30).
  %2 = ldi i32 48
  %3 = lt.u i32 %1, %2
  jmp.if %3 error check_upper

check_upper:
  // Check if it is above '9' (0x39).
  %5 = ldi i32 57
  %6 = gt.u i32 %1, %5
  jmp.if %6 error parse

parse:
  // ... parsing logic ...
  %8 = ldi i32 0             // success
  ret i32 %8

error:
  %10 = ldi i32 -1           // error code
  ret i32 %10
}

Strategy 2: Multiple return values

An Esper function returns as many values as its signature declares, so a result type made of a tag and a payload needs no type declaration, no memory and no output pointer. This is the form most Result<T, E> lowerings should start from.

The function

esper_build_begin takes the return types as an array, and esper_build_ret takes the returned values as one. Nothing else about the function is different from a single-return one: each arm ends in its own ret, and each ret carries both halves of the pair.

func divide(a: Int32, b: Int32) -> (Int32, Int32) {
    if b == 0 {
        return (1, 0)   // (error_code, unused)
    }
    return (0, a / b)   // (success, result)
}
/// The tag half of every result this unit returns.
enum { OK = 0, ERR_DIV_BY_ZERO = 1 };

static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef rets[2] = {ESP_TYPE_I32, ESP_TYPE_I32};

  EsperBuilder *b = esper_build_begin(unit, "divide", params, 2, rets, 2);
  EsperBlockId entry = esper_build_block(b, "entry");
  EsperBlockId by_zero = esper_build_block(b, "by_zero");
  EsperBlockId ok = esper_build_block(b, "ok");

  esper_build_bind(b, entry);
  EsperVal zero = esper_build_unary(b, ESP_OP_EQZ, ESP_TYPE_I32,
                                    esper_build_param(b, 1));
  esper_build_jmp_if(b, zero, by_zero, ok);

  // Error: the tag says which one, and the payload carries nothing.
  esper_build_bind(b, by_zero);
  EsperVal err[2];
  err[0] = esper_build_ldi(b, ESP_TYPE_I32, ERR_DIV_BY_ZERO);
  err[1] = esper_build_ldi(b, ESP_TYPE_I32, 0);
  esper_build_ret(b, err, 2);

  esper_build_bind(b, ok);
  EsperVal good[2];
  good[0] = esper_build_ldi(b, ESP_TYPE_I32, OK);
  good[1] = esper_build_binary(b, ESP_OP_DIV_S, ESP_TYPE_I32,
                               esper_build_param(b, 0),
                               esper_build_param(b, 1));
  esper_build_ret(b, good, 2);

  if (!esper_build_end(b))
    fprintf(stderr, "divide: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
func $divide(%0 i32, %1 i32) -> (i32, i32) {
entry:
  %2 = eqz i32 %1
  jmp.if %2 by_zero ok

by_zero:
  %4 = ldi i32 1
  %5 = ldi i32 0
  ret i32 %4, i32 %5

ok:
  %7 = ldi i32 0
  %8 = div.s i32 %0, %1
  ret i32 %7, i32 %8
}

The error arm produces a payload it does not use. That ldi i32 0 is the price of a fixed-arity return, and it is the whole price: no allocation, no store, no layout, and on most targets the pair comes back in two registers.

Checking the result

A multi-return call produces one SSA value of type tuple, and extract projects a component out of it. The tuple is not a value the program can do anything else with: it has no operations of its own, and its component types are not recorded on it but read off the callee’s signature.

esper_build_call needs its callee to resolve, so divide is declared before main is built. A declaration with no body prints as a function with no blocks, which is what the first two lines below are.

match divide(10, 0) {
case (0, let value): return value
case (_, _):         return -1
}
static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef two[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef one[1] = {ESP_TYPE_I32};

  esper_func_declare(unit, "divide", params, 2, two, 2, false);

  EsperBuilder *b = esper_build_begin(unit, "main", NULL, 0, one, 1);
  EsperBlockId entry = esper_build_block(b, "entry");
  EsperBlockId ok = esper_build_block(b, "ok");
  EsperBlockId failed = esper_build_block(b, "failed");

  esper_build_bind(b, entry);
  EsperVal args[2];
  args[0] = esper_build_ldi(b, ESP_TYPE_I32, 10);
  args[1] = esper_build_ldi(b, ESP_TYPE_I32, 0);

  // A multi-return call yields one tuple-typed value. Nothing reads a
  // tuple directly; extract projects a component out of it.
  EsperVal result = esper_build_call(b, ESP_TYPE_TUPLE, "divide", args, 2);
  EsperVal tag = esper_build_extract(b, ESP_TYPE_I32, result, 0);
  EsperVal value = esper_build_extract(b, ESP_TYPE_I32, result, 1);

  EsperVal succeeded = esper_build_unary(b, ESP_OP_EQZ, ESP_TYPE_I32, tag);
  esper_build_jmp_if(b, succeeded, ok, failed);

  esper_build_bind(b, ok);
  esper_build_ret(b, &value, 1);

  esper_build_bind(b, failed);
  EsperVal sentinel = esper_build_ldi(b, ESP_TYPE_I32, -1);
  esper_build_ret(b, &sentinel, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "main: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
func $divide(%0 i32, %1 i32) -> (i32, i32) {
}

func $main() -> i32 {
entry:
  %0 = ldi i32 10
  %1 = ldi i32 0
  %2 = call tuple $divide, %0, %1
  %3 = extract i32 %2, 0
  %4 = extract i32 %2, 1
  %5 = eqz i32 %3
  jmp.if %5 ok failed

ok:
  ret i32 %4

failed:
  %8 = ldi i32 -1
  ret i32 %8
}

Both components are projected before the branch, including the payload the error path never reads. That is deliberate. extract is pure and touches no memory, so hoisting it above the branch costs nothing and keeps the value dominating both arms; sinking it into the ok block would be equally correct and no cheaper.

Propagating an error upward

A ? operator, or a try that rethrows, is the pattern that decides whether a result representation is any good. In the multi-return form it costs nothing: the callee’s two values are already SSA values in the caller, and forwarding them is a ret of the pair.

func scaledQuotient(a: Int32, b: Int32, k: Int32) -> (Int32, Int32) {
    let q = try divide(a, b)
    return (0, q * k)
}
static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef two_in[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef three_in[3] = {ESP_TYPE_I32, ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef two_out[2] = {ESP_TYPE_I32, ESP_TYPE_I32};

  esper_func_declare(unit, "divide", two_in, 2, two_out, 2, false);

  EsperBuilder *b = esper_build_begin(unit, "scaled_quotient",
                                      three_in, 3, two_out, 2);
  EsperBlockId entry = esper_build_block(b, "entry");
  EsperBlockId propagate = esper_build_block(b, "propagate");
  EsperBlockId ok = esper_build_block(b, "ok");

  esper_build_bind(b, entry);
  EsperVal args[2] = {esper_build_param(b, 0), esper_build_param(b, 1)};
  EsperVal result = esper_build_call(b, ESP_TYPE_TUPLE, "divide", args, 2);
  EsperVal tag = esper_build_extract(b, ESP_TYPE_I32, result, 0);
  EsperVal quotient = esper_build_extract(b, ESP_TYPE_I32, result, 1);

  EsperVal succeeded = esper_build_unary(b, ESP_OP_EQZ, ESP_TYPE_I32, tag);
  esper_build_jmp_if(b, succeeded, ok, propagate);

  // Forwarding an error is a ret of the two values already in hand. The
  // callee's pair becomes this function's pair unchanged.
  esper_build_bind(b, propagate);
  EsperVal forwarded[2] = {tag, quotient};
  esper_build_ret(b, forwarded, 2);

  esper_build_bind(b, ok);
  EsperVal scaled[2];
  scaled[0] = esper_build_ldi(b, ESP_TYPE_I32, 0);
  scaled[1] = esper_build_binary(b, ESP_OP_MUL, ESP_TYPE_I32, quotient,
                                 esper_build_param(b, 2));
  esper_build_ret(b, scaled, 2);

  if (!esper_build_end(b))
    fprintf(stderr, "scaled_quotient: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
func $divide(%0 i32, %1 i32) -> (i32, i32) {
}

func $scaled_quotient(%0 i32, %1 i32, %2 i32) -> (i32, i32) {
entry:
  %3 = call tuple $divide, %0, %1
  %4 = extract i32 %3, 0
  %5 = extract i32 %3, 1
  %6 = eqz i32 %4
  jmp.if %6 ok propagate

propagate:
  ret i32 %4, i32 %5

ok:
  %9 = ldi i32 0
  %10 = mul i32 %5, %2
  ret i32 %9, i32 %10
}

The whole propagation is one block of two instructions, and neither of them is work: the ret names values the extracts already produced. A frontend lowering ? emits exactly this, once per fallible call, with the error block shared across every call in the function when the signatures agree.

Strategy 3: Tagged result types

A tuple exists only as the result of a call, and it carries no description of itself: its component types come from the callee’s signature and are not recorded anywhere else. So a tuple cannot be stored in a global, held as a struct member, or written into an aggregate constant, and a frontend that needs a Result to be a value rather than a calling convention needs a struct instead.

A struct result is a named type in the unit’s type table with member types and byte offsets. esper_unit_add_struct declares it, struct.new builds one from its members, and field projects one back out.

Declaring the type and returning it

struct Result { var tag: Int32; var value: Int32 }

func divide(a: Int32, b: Int32) -> Result {
    if b == 0 { return Result(tag: 1, value: 0) }
    return Result(tag: 0, value: a / b)
}

The signature arrays are typed EsperTypeRef, and esper_tref_agg answers one, so the struct names itself as a return type the same way ESP_TYPE_I32 does.

/// Builds `divide` again, this time returning the tagged struct.
static void build_divide(EsperUnit *unit, uint32_t result_type) {
  EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef rets[1] = {esper_tref_agg(result_type)};

  EsperBuilder *b = esper_build_begin(unit, "divide", params, 2, rets, 1);
  EsperBlockId entry = esper_build_block(b, "entry");
  EsperBlockId by_zero = esper_build_block(b, "by_zero");
  EsperBlockId ok = esper_build_block(b, "ok");

  esper_build_bind(b, entry);
  EsperVal zero = esper_build_unary(b, ESP_OP_EQZ, ESP_TYPE_I32,
                                    esper_build_param(b, 1));
  esper_build_jmp_if(b, zero, by_zero, ok);

  esper_build_bind(b, by_zero);
  EsperVal err[2];
  err[0] = esper_build_ldi(b, ESP_TYPE_I32, 1);
  err[1] = esper_build_ldi(b, ESP_TYPE_I32, 0);
  EsperVal failure = esper_build_struct_new(b, result_type, err, 2);
  esper_build_ret(b, &failure, 1);

  esper_build_bind(b, ok);
  EsperVal good[2];
  good[0] = esper_build_ldi(b, ESP_TYPE_I32, 0);
  good[1] = esper_build_binary(b, ESP_OP_DIV_S, ESP_TYPE_I32,
                               esper_build_param(b, 0),
                               esper_build_param(b, 1));
  EsperVal success = esper_build_struct_new(b, result_type, good, 2);
  esper_build_ret(b, &success, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "divide: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}

/// Divides twice and returns whichever result failed, or the second.
///
/// The first result has to stay live across the second call, which is
/// the thing a tuple cannot do and a struct can.
static void build_first_failure(EsperUnit *unit, uint32_t result_type) {
  EsperTypeRef params[3] = {ESP_TYPE_I32, ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef rets[1] = {esper_tref_agg(result_type)};

  EsperBuilder *b = esper_build_begin(unit, "first_failure", params, 3, rets, 1);
  EsperBlockId entry = esper_build_block(b, "entry");
  EsperBlockId took_first = esper_build_block(b, "took_first");
  EsperBlockId took_second = esper_build_block(b, "took_second");

  esper_build_bind(b, entry);
  EsperVal left[2] = {esper_build_param(b, 0), esper_build_param(b, 1)};
  EsperVal first = esper_build_call(b, esper_tref_agg(result_type),
                                    "divide", left, 2);

  EsperVal right[2] = {esper_build_param(b, 0), esper_build_param(b, 2)};
  EsperVal second = esper_build_call(b, esper_tref_agg(result_type),
                                     "divide", right, 2);

  EsperVal tag = esper_build_field(b, ESP_TYPE_I32, first, 0);
  EsperVal first_ok = esper_build_unary(b, ESP_OP_EQZ, ESP_TYPE_I32, tag);
  esper_build_jmp_if(b, first_ok, took_second, took_first);

  esper_build_bind(b, took_first);
  esper_build_ret(b, &first, 1);

  esper_build_bind(b, took_second);
  esper_build_ret(b, &second, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "first_failure: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}

static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef members[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  const char *names[2] = {"tag", "value"};
  uint32_t result = esper_unit_add_struct(unit, "Result", members, names, 2,
                                          false);

  build_divide(unit, result);
  build_first_failure(unit, result);
}
type Result = struct {
  i32 tag,
  i32 value,
}

func $divide(%0 i32, %1 i32) -> Result {
entry:
  %2 = eqz i32 %1
  jmp.if %2 by_zero ok

by_zero:
  %4 = ldi i32 1
  %5 = ldi i32 0
  %6 = struct.new Result %4, %5
  ret Result %6

ok:
  %8 = ldi i32 0
  %9 = div.s i32 %0, %1
  %10 = struct.new Result %8, %9
  ret Result %10
}

func $first_failure(%0 i32, %1 i32, %2 i32) -> Result {
entry:
  %3 = call Result $divide, %0, %1
  %4 = call Result $divide, %0, %2
  %5 = field i32 %3, 0
  %6 = eqz i32 %5
  jmp.if %6 took_second took_first

took_first:
  ret Result %3

took_second:
  ret Result %4
}

first_failure is the argument for the struct form. %3 is a Result that is defined before the second call and used after it, so it is an ordinary SSA value with an ordinary live range and the backend spills it if it has to. Written with multi-return, %3 would be a tuple, and the two components would have to be projected out before the second call and carried separately. That is not wrong, but it is bookkeeping the frontend does by hand for every result it holds on to, and a struct is the type system doing it instead.

struct.new and field are not memory operations. They are value construction and projection, and the peephole pass folds a field of a struct.new straight through to the member it names, so a result that is built and immediately taken apart leaves nothing behind. See the structs guide for the aggregate opcodes in full.

When the payload is too large to return

A struct returned by value goes through the target’s ABI, which puts small aggregates in registers and larger ones behind a hidden pointer. When the error payload is genuinely large, or when its size is not known until run time, the frontend writes the result through an output pointer instead and returns only the tag:

func $divide(%0 i32, %1 i32, %2 ptr) -> i32 {
entry:
  %3 = eqz i32 %1
  jmp.if %3 div_by_zero success

success:
  // Write tag = 0 (Ok), then the value at offset 4.
  %5 = ldi i32 0
  store i32 %2, %5

  %7 = ldi i64 4
  %8 = add i64 %2, %7
  %9 = div.s i32 %0, %1
  store i32 %8, %9

  %11 = ldi i32 0            // return success
  ret i32 %11

div_by_zero:
  // Write tag = 1 (Err), then the error code at offset 4.
  %13 = ldi i32 1
  store i32 %2, %13

  %15 = ldi i64 4
  %16 = add i64 %2, %15
  %17 = ldi i32 1
  store i32 %16, %17

  %19 = ldi i32 1            // return error
  ret i32 %19
}

The caller reserves the slot with salloc, passes its address, and branches on the returned tag without loading anything:

func $main() -> i32 {
entry:
  // Reserve the result slot: 8 bytes.
  %0 = ldi i32 8
  %1 = salloc %0

  %2 = ldi i32 10
  %3 = ldi i32 0
  %4 = call i32 $divide, %2, %3, %1

  %5 = eqz i32 %4
  jmp.if %5 ok err

ok:
  // Read the payload back out of the slot.
  %7 = ldi i64 4
  %8 = add i64 %1, %7
  %9 = load i32 %8
  ret i32 %9

err:
  %11 = ldi i32 -1
  ret i32 %11
}

salloc is right here and halloc would be wrong: the slot is dead the moment main returns, and nothing outlives the call that reserved it. A result that does outlive its frame, such as one stored into a collection, needs halloc and a matching free.

This is also where a tagged union belongs when the two arms have different shapes rather than different values. See the unions guide.

Strategy 4: setjmp / longjmp (non-local return)

For languages with exceptions, the frontend can bind the C runtime’s non-local jump directly. Esper treats both halves as ordinary extern calls; it does not model the control flow they perform.

Pseudo-code

func trySomething() -> Int32 {
    try {
        // ... risky work ...
        throw Error(code: 1)
    } catch (let err) {
        return err.code
    }
}

Step-by-step lowering

  1. setjmp saves the current execution state (registers, stack pointer) into a buffer. It returns 0 on the initial call.
  2. longjmp restores the saved state and causes setjmp to return again, this time with a nonzero value (the error code passed to longjmp).
  3. Try block: After setjmp returns 0, execution enters the “try” body. If something goes wrong, longjmp unwinds back to the setjmp point.
  4. Catch block: setjmp returns nonzero, and the error code is the return value.
  5. Why setjmp/longjmp? It is the only way to implement non-local returns (unwinding across multiple call frames) without compiler-generated unwind tables. The cost is saving and restoring register state on every setjmp, and the longjmp path is expensive. Most modern languages prefer result types for this reason.

Esper lowering

func extern $setjmp(ptr) -> i32
func extern $longjmp(ptr, i32)

func $try_something(%0 ptr) -> i32 {
entry:
  // Returns 0 on the first call, nonzero on a longjmp.
  %1 = call i32 $setjmp, %0
  %2 = eqz i32 %1
  jmp.if %2 try_body catch

try_body:
  // ... do risky work ...
  // If something goes wrong, jump back to the setjmp, which returns 1.
  %4 = ldi i32 1
  call $longjmp, %0, %4
  unreachable

catch:
  // setjmp returned nonzero, so %1 holds the error code.
  ret i32 %1
}

The block after the longjmp ends in unreachable rather than a ret. longjmp does not come back, and saying so lets the optimizer drop everything that would follow. The same applies to a call into a panic handler, which is the other function in a frontend that never returns.

Esper does not know that setjmp may return twice, and there is nothing in the IR that says so. A frontend using this pattern is responsible for keeping anything the catch path reads out of registers, which in practice means the same rule C has: a local live across the setjmp belongs in memory, not in an SSA value.

Choosing between them

Key takeaways