Skip to content

17. First-Class Functions

So far a function has been something you call. In this chapter a function becomes something you can hold: store it in a variable, pass it to another function, keep a whole List of them, and call through any of those. That one idea — functions as values — is what turns a pile of if/ match dispatch into clean, data-driven code.

This builds on Chapter 4 (Functions), Chapter 6 (Error Handling), and Chapter 13 (Collections). If you know Python's "functions are objects" or C's function pointers, you already have the intuition — Sushi's version is typed and compiles to a bare pointer with zero overhead.

A function as a value

Write a function's name without parentheses and you get a function value — a reference to that function. Its type is a function type, written fn(params) -> return:

# A function value: reference a named function, pass it to another function,
# and call through the parameter. A plain reference like this carries no
# captured state -- see chapter 18 for closures, which do.
fn add_one(i32 x) i32:
    return Result.Ok(x + 1)

fn triple(i32 x) i32:
    return Result.Ok(x * 3)

# `op` is a function-typed parameter; `op(v)` calls through it.
fn apply(fn(i32) -> i32 op, i32 v) i32:
    return Result.Ok(op(v)??)

fn main() i32:
    let fn(i32) -> i32 f = add_one
    let fn(i32) -> i32 g = triple
    println(apply(f, 41).realise(0))    # 42
    println(apply(g, 14).realise(0))    # 42
    return Result.Ok(0)

Output:

42
42

Three things to notice:

  • let fn(i32) -> i32 f = add_one binds the function value. The type reads "takes an i32, returns an i32" — the same shape as add_one's signature.
  • apply(fn(i32) -> i32 op, i32 v) takes a function as a parameter. Inside, op(v) calls through it.
  • Calling through a function value returns a Result just like calling the function directly, so the familiar ?? works on op(v).

A plain reference vs. a closure

A plain function reference like add_one above carries no captured variables — it's the bare address of the compiled function, with no allocation and no cleanup. Sushi also has closures: a lambda literal that does capture surrounding variables, covered in the next chapter. Both are fn(...)-typed values with identical call syntax.

A dispatch table with List

Because a function value is an ordinary value, you can put a bunch of them in a List and iterate — a dispatch table or pipeline:

# A List<fn(...)> is a dispatch table you can iterate. Each step transforms the
# accumulator by calling through the stored function value.
fn add_one(i32 x) i32:
    return Result.Ok(x + 1)

fn add_two(i32 x) i32:
    return Result.Ok(x + 2)

# steps is borrowed (&peek) so the caller keeps the table and can free it below; a
# by-value List<...> would be moved into run_pipeline and freed there.
fn run_pipeline(&peek List<fn(i32) -> i32> steps, i32 start) i32:
    let i32 acc = start
    foreach(step in steps.iter()):
        acc := step(acc)??
    return Result.Ok(acc)

fn main() i32:
    let List<fn(i32) -> i32> steps = List.new()
    steps.push(add_one)
    steps.push(add_two)
    steps.push(add_one)
    println(run_pipeline(&peek steps, 10).realise(0))   # 10 +1 +2 +1 = 14
    steps.free()
    return Result.Ok(0)

Output:

14

List<fn(i32) -> i32> is a list whose element type is a function type. foreach hands you each stored function in turn, and step(acc) calls through it. (Use List<fn(...)> rather than a raw array for a collection of functions — in fn() -> T[] the [] belongs to the return type T[], so there is no "array of functions" syntax.)

Functions in a struct

A function value can be a struct field — handy for bundling a name, some config, and the behavior to run:

# A function value can live in a struct field, and you can call it directly:
# `op.run(v)` routes to the fn-typed field when there is no method of that name.
fn square(i32 x) i32:
    return Result.Ok(x * x)

struct Op:
    string name
    fn(i32) -> i32 run

fn apply(Op op, i32 v) i32:
    return Result.Ok(op.run(v)??)

fn main() i32:
    let Op op = Op("square", square)
    println(op.name)
    println(apply(op, 7).realise(0))   # 49
    return Result.Ok(0)

Output:

square
49

You can call the field directlyop.run(7). When a struct has a fn-typed field and no method of the same name, op.run(7) routes to the function stored in the field. (If a method run also existed, the method would win; bind the field to a local first — let f = op.run — to call the field in that case.)

The error type travels with the function

A function type can spell out a custom error type, just like a declaration does with | E. That error type is part of the type, so it propagates correctly through an indirect call:

# The error type is part of the function type, so a custom error propagates
# through an indirect call exactly like it would through a direct one.
enum DivError:
    DivByZero

fn safe_div(i32 a, i32 b) i32 | DivError:
    if (b == 0):
        return Result.Err(DivError.DivByZero)
    return Result.Ok(a / b)

fn run(fn(i32, i32) -> i32 | DivError op, i32 x, i32 y) i32 | DivError:
    let i32 r = op(x, y)??
    return Result.Ok(r)

fn main() i32:
    let fn(i32, i32) -> i32 | DivError f = safe_div
    println(run(f, 84, 2).realise(-1))    # 42
    println(run(f, 1, 0).realise(-1))     # -1  (DivByZero -> realise default)
    return Result.Ok(0)

Output:

42
-1

fn(i32, i32) -> i32 | DivError says the function can fail with a DivError. Inside run, the op(x, y)?? propagates that error out, and the caller turns it into a default with .realise(-1). Omit the | E and the error type is the implicit StdError, exactly as for a normal fn.

Calling through any expression

A function value doesn't have to sit in a plain variable to be called. You can call through any expression that produces one — a List element or a parenthesized expression:

let List<fn(i32) -> i32> table = List.new()
table.push(add_one)
let i32 a = table.get(0)??(41)??      # call the retrieved function value
let i32 b = (table.get(0)??)(41)??    # same, parenthesized

Referencing a generic function

A generic function can be referenced as a value when you give the binding an explicit function type — the annotation fixes which instantiation you mean:

fn identity<T>(T x) T:
    return Result.Ok(x)

let fn(i32) -> i32 g = identity      # identity<i32>, chosen by the annotation
let i32 n = g(41)??                  # 41

Without an expected function type — for instance passing identity straight into a call argument with no typed binding — the reference is still CE2093; bind it to a typed local first.

What else the compiler checks

  • A wrong-shaped call through a function value (wrong arity or argument type) → CE2092.
  • Assigning a function value to an incompatible function-typed variable → CE2002. Function types are invariant: arity, every parameter, the return type, and the error type must match exactly.

Extension methods, perk methods, and C externals aren't bare-referenceable at all — they have different calling conventions, so a bare name that isn't a plain function is just an undeclared identifier.

What you learned

  • A function value is a function's name used without () — its type is a function type fn(params) -> return [| Error].
  • You can store function values (variables, struct fields, List<fn(...)>), pass them as arguments, and call through them; an indirect call returns a Result just like a direct one.
  • A plain function reference is a bare pointer — zero-cost, no captured state. Sushi also has closures (capturing lambda literals) — see the next chapter.
  • Call a function-valued struct field directly (obj.field(x)); a same-named method would win. You can also call through any expression that yields a function value (table.get(0)??(x)).
  • Reference a generic function as a value when an explicit function type is present (let fn(i32) -> i32 g = identity); a bare reference with no expected type is CE2093.
  • The error type is part of the function type and propagates through ??.
  • A call-through mismatch is CE2092, and an assignment mismatch is CE2002.

That's functions-as-data. Next, Chapter 18 (Closures) adds the capturing lambda literal. For the complete reference on this chapter's material, see the First-Class Functions guide and the design note.