Functions

Zena provides two primary ways to define functions: top-level function declarations (which are never closures and compile to direct machine calls) and arrow functions (which are expressions and can capture their surrounding lexical scope).

Top-level functions and arrow functions

Zena separates non-capturing module functions from heap-allocated closures.

Top-level function declarations

A function declaration binds a name at the top level of a module. It is a statement with a block body:

zena
function add(a: i32, b: i32): i32 {
  return a + b;
}

export function main(): i32 {
  return add(20, 22);
}

Top-level function declarations are immutable bindings and cannot be reassigned.

No closure environment

A function declaration can never be a closure. Its body can access only its own parameters, its own local variables, and module-level declarations (globals, imports, classes, and other functions).

Hoisting and recursion

Top-level function declarations are hoisted across the module. They can be referenced before their declaration and support direct and mutual recursion:

zena
export function isEven(n: i32): boolean {
  if (n == 0) { return true; }
  return isOdd(n - 1);
}

function isOdd(n: i32): boolean {
  if (n == 0) { return false; }
  return isEven(n - 1);
}

Passing top-level functions as values

Referencing a top-level function by name produces a first-class function value that can be passed as an argument, stored in variables, or returned:

zena
function double(x: i32): i32 { return x * 2; }

function apply(f: (v: i32) => i32, val: i32): i32 {
  return f(val);
}

let result = apply(double, 21); // 42

Performance: Direct calls vs. functions as values

Direct calls to top-level function declarations compile to static WebAssembly calls with zero runtime wrapper allocation. When a top-level function is passed as a first-class value, it is wrapped in a uniform function object with a null captured environment so it can be called interchangeably with closures.

Arrow functions

Arrow functions are expressions. They can be defined anywhere and can capture variables from their enclosing scope:

zena
// Expression body
let square = (x: i32): i32 => x * x;

// Block body
let logAndSquare = (x: i32): i32 => {
  console.log(x);
  return x * x;
};

When an arrow function accesses variables from an outer function, the compiler captures those variables in a heap-allocated context:

zena
let createMultiplier = (factor: i32) => {
  return (x: i32): i32 => x * factor; // Captures 'factor'
};

let triple = createMultiplier(3);
let value = triple(10); // 30

Parameters and arguments

Function parameters define the inputs accepted by a function.

Parameter annotations and contextual typing

Parameters require explicit type annotations when their types cannot be inferred:

zena
let multiply = (a: i32, b: i32): i32 => a * b;

When an arrow function is passed as a callback to a function with a known signature, parameter types are inferred from context and can be omitted:

zena
let numbers = [1, 2, 3, 4];

// Parameter 'x' is inferred as i32 from Array<i32>.map
let doubled = numbers.map(x => x * 2);

Default parameters

Parameters can specify default values evaluated when the argument is omitted:

zena
let increment = (x: i32, amount: i32 = 1): i32 => x + amount;

increment(10);    // 11 (uses default amount = 1)
increment(10, 5); // 15 (explicit amount)

Default parameters follow these rules:

  • Argument-count based: Defaults are selected at compile time based on the number of arguments supplied at the call site.
  • Fresh evaluation: Default value expressions are evaluated fresh on every call that omits the argument.
  • null does not trigger defaults: Explicitly passing null to a nullable parameter passes null rather than triggering the default expression:
    zena
    let greet = (name: String? = 'World') => `Hello, ${name}`;
    greet();     // "Hello, World" (default used)
    greet(null); // "Hello, null" (null passed explicitly)
    

Optional parameters

A parameter marked with ? is optional and must follow all required parameters. Its type becomes nullable (T?):

zena
let greet = (name: String, title?: String): String => {
  if (title != null) {
    return `Hello, ${title} ${name}`;
  }
  return `Hello, ${name}`;
};

greet('Alice');        // "Hello, Alice"
greet('Bob', 'Dr.');   // "Hello, Dr. Bob"

Because WebAssembly primitives cannot hold null without boxing, primitive optional parameters must have a default value or be explicitly boxed with Box<T>:

zena
let scale = (value: f64, factor: f64 = 1.0): f64 => value * factor; // OK
// let invalid = (value: f64, factor?: f64) => ...; // Error: primitive cannot be optional without default

Destructured parameters

Function parameters can unpack records and tuples directly in the signature:

zena
// Record destructuring with inline types
let getDistance = ({x: f64, y: f64}): f64 => (x * x + y * y);

// With property renaming using 'as'
let getArea = ({width as w: f64, height as h: f64}): f64 => w * h;

// Tuple destructuring
let sumPair = ((a, b): (i32, i32)): i32 => a + b;

Parameter arity adaptation

When passing a callback to a higher-order function, you can provide a function that accepts fewer arguments than the expected signature. The compiler automatically generates an adapter that discards the extra arguments:

zena
let items = ['a', 'b', 'c'];

// Array.forEach passes (item: String, index: i32, array: Array<String>)
// The callback only takes 1 argument; extra arguments are safely ignored:
items.forEach(item => {
  console.log(item);
});

Return types and multi-value returns

Return type inference

The compiler infers a function's return type from the return expressions in its body, or void if there are no return expressions:

zena
let add = (a: i32, b: i32) => a + b; // Inferred return type: i32
let log = (msg: String) => { console.log(msg); }; // Inferred return type: void

When return types are required

Explicit return type annotations are mandatory in the following cases:

  1. Recursive functions: Any function that calls itself directly or participates in a mutual recursion cycle requires an explicit return type annotation.
  2. Import cycle boundaries: Functions referenced across circular module imports must annotate all parameters and their return type.
  3. Generators and async functions: Functions using gen must explicitly annotate Iterator<T>, and functions using async must explicitly annotate Future<T>.

Multi-value returns (inline tuples)

Zena supports returning multiple values without heap allocation using inline tuples. Unlike regular boxed tuples (i32, i32), inline tuples compile directly to WebAssembly multi-value function returns:

zena
let divide = (a: i32, b: i32): inline (i32, i32) => {
  return (a / b, a % b); // quotient and remainder
};

// Destructure immediately at the call site:
let (quot, rem) = divide(17, 5);
// quot = 3, rem = 2

Inline tuples exist only in return positions and must be destructured immediately. They cannot be stored in variables or passed into functions as arguments.

Tail calls (tail return)

A recursive function normally grows the stack once per call, and a deep enough recursion overflows it. tail return f(x); compiles the call to WebAssembly's return_call, which discards the current frame before the callee runs — so a chain of tail calls executes in constant stack space however long it gets:

zena
function sum(n: i32, acc: i32 = 0): i32 {
  if (n == 0) {
    return acc;
  }
  tail return sum(n - 1, acc + n);
}

sum(1000000); // fine; the same function with a plain `return` overflows

The annotation is required rather than inferred, for two reasons. A program written against tail-call elimination breaks when it does not fire, and whether it fires depends on code around the call site rather than the call itself. And a tail call erases the caller's line from every backtrace taken inside the callee, which is a poor default for calls that do not need the frame gone.

tail is a contextual keyword — it is only special directly before return — so it remains available as a name.

Mutual recursion, method calls, calls to a closure parameter and virtual calls through an interface are all eligible, and all run in constant stack:

zena
function isEven(n: i32): i32 {
  if (n == 0) { return 1; }
  tail return isOdd(n - 1);
}

function isOdd(n: i32): i32 {
  if (n == 0) { return 0; }
  tail return isEven(n - 1);
}

tail return is rejected where the frame is not actually finished with, and the compiler says which rule applies:

  • The operand must be one call: not f(x) + 1, not new C(x), not f?.(x).
  • The enclosing function must declare its return type, and the call must return that same type. A conversion would have to run after the callee returned, in the frame the tail call discarded.
  • Not in an async function or a generator, where return completes a future or ends an iteration rather than returning from a frame.
  • Not in a constructor, which returns the new instance.
  • Not inside a try, and not while a using binding is live: a handler, a finally and a release all run on the way out.

Function types and compatibility

Function types describe callable signatures and are written with named parameters:

zena
type Predicate<T> = (value: T) => boolean;
type BinaryOp = (a: i32, b: i32) => i32;

Parameter names are required in function type signatures to distinguish them from tuple types ((i32, i32) is a tuple type; (a: i32, b: i32) => i32 is a function type).

Structural compatibility

Function types are structural. A function value is assignable to a function type if:

  • Its parameter count is less than or equal to the target signature (arity adaptation).
  • Each parameter type is contravariant (accepts the target parameter type or a supertype).
  • Its return type is covariant (produces the target return type or a subtype).

Calling union function types

When a variable is typed as a union of functions (even with different arities), calling the variable dispatches dynamically to the active function and discards excess arguments:

zena
type Fn1 = (a: i32) => i32;
type Fn2 = (a: i32, b: i32) => i32;

let call = (fn: Fn1 | Fn2): i32 => {
  return fn(10, 20); // Dispatches with (10) if fn is Fn1, or (10, 20) if fn is Fn2
};

Function and method overloading

External function declarations (declare function) and class methods support overloading with multiple signatures under the same name:

zena
declare function printValue(val: i32): void;
declare function printValue(val: String): void;

printValue(42);      // Calls printValue(i32)
printValue('hello'); // Calls printValue(String)

The compiler performs overload resolution at compile time by selecting the most specific matching signature for the argument types at the call site.

Generators and async functions

Functions can produce lazy sequences or manage asynchronous execution using dedicated modifiers.

Generator functions (gen)

Generator functions use the gen keyword and yield values over time. They return an Iterator<T>:

zena
gen function range(start: i32, end: i32): Iterator<i32> {
  var current = start;
  while (current < end) {
    yield current;
    current += 1;
  }
}

for (let n in range(0, 5)) {
  console.log(n);
}

Async functions (async)

Async functions use the async keyword and suspend on futures with await. They return a Future<T>:

zena
async function fetchUser(id: i32): Future<String> {
  let response = await fetch(`https://api.example.com/users/${id}`);
  return response.text();
}

Next

  • Classes — class declarations, constructors, methods, and interfaces
  • Control Flow — pattern matching, expressions, and loops
  • Types — primitive types, nominal types, unions, and generics