Functions
Zena provides two primary ways to define functions: top-level function
declarations, which are statements that cannot be closures and compile to
direct WebAssembly calls, and arrow functions, which are expressions that can
capture variables from their enclosing lexical scopes.
Function declarations and arrow functions ​
function declarations ​
A function declaration binds a name at the top level of a library:
function add(a: i32, b: i32): i32 {
return a + b;
}
export function main(): i32 {
return add(20, 22);
}
function declarations have the following characteristics:
- Library-level statement:
functiondeclarations can only appear at the top level of a library. They cannot be nested inside blocks, loops, or other functions:
let makeCounter = (start: i32) => {
function bad(): i32 {
Error:
return start;
}
return bad;
};
- Mandatory block body: The body of a
functiondeclaration must always be a block enclosed in{ ... }. - Immutable binding: Like a
letvariable, the name of afunctiondeclaration cannot be reassigned:
function noop(): void {}
noop = noop;
Error:
- Hoisting and mutual recursion:
functiondeclarations are hoisted across the library. They can be referenced before their declaration and can participate in direct or mutual recursion without declaration-order constraints:
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);
}
- First-class values: Referencing a
functiondeclaration by its name evaluates to a first-class function value that can be stored in variables, passed to functions, or returned:
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
Arrow functions ​
Arrow functions are expressions. They can be defined in any expression context, assigned to variables, or passed inline as callbacks:
// Expression body
let square = (x: i32): i32 => x * x;
// Block body
let logAndSquare = (x: i32): i32 => {
console.log(x);
return x * x;
};
The parameter list of an arrow function always requires parentheses, even when
declaring a single unannotated parameter: (x) => x * 2.
Unlike function declarations, arrow functions can close over variables from
their surrounding lexical environment.
Top-level functions versus closures ​
The distinction between function declarations and arrow functions is rooted in
environment allocation and execution guarantees:
- Zero-allocation direct calls: Direct calls to a
functiondeclaration compile to direct WebAssemblycallinstructions. The compiler guarantees that no context object or heap wrapper is allocated at the call site. - Guaranteed non-capturing: Because a
functiondeclaration can only appear at library scope, its body can only reach its own parameters, its own local variables, and library-level declarations (globals, imports, classes, and other functions). It can never accidentally capture lexical state from an outer scope. - Uniform values: When either a
functiondeclaration or an arrow function is passed as a first-class value, it satisfies the standard function type interface. Forfunctiondeclarations, the function object is created with an empty (null) environment.
Parameters ​
Parameter annotations and immutability ​
Function parameters require type annotations when their types cannot be inferred from context.
Parameters are immutable bindings within the function body. Attempting to reassign a parameter or update it with a compound assignment operator produces a compile-time error:
let increment = (x: i32): void => {
x += 1;
Error:
};
To modify a parameter's value locally, declare a mutable var initialized with
the parameter.
Default parameters ​
Parameters can define default value expressions that are evaluated when the caller omits the corresponding argument:
let increment = (x: i32, amount: i32 = 1): i32 => x + amount;
increment(10); // 11 (uses default amount = 1)
increment(10, 5); // 15 (explicit argument supplied)
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. If a default expression constructs an object or array, each call receives a distinct instance.
nulldoes not trigger defaults: Explicitly passingnullpassesnullrather than evaluating the default expression:
let greet = (name: String? = 'World'): String => `Hello, ${name}`;
greet(); // "Hello, World" (default evaluated)
greet(null); // "Hello, null" (null passed explicitly)
- Access to
thisin methods: Default value expressions in class methods can referencethisand class fields, including private#fields:
class Cursor {
#position: i32 = 0;
seek(to: i32 = this.#position): i32 {
return to;
}
}
Optional parameters ​
A parameter marked with ? is optional and must follow all required parameters.
When no default value is provided, its type becomes nullable (T?):
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 provide a default value or be explicitly wrapped in
Box<T>:
let scale = (value: f64, factor: f64 = 1.0): f64 => value * factor; // Valid
let wrap = (value: f64, factor?: Box<f64>): f64 => value; // Valid
Destructured parameters ​
Function parameters, like variable declarations, accept any irrefutable pattern to unpack incoming arguments directly in the parameter list. This includes records, tuples, arrays, and class instances.
Record patterns ​
For records, the combined syntax declares property names and types together, avoiding duplication:
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;
// With property defaults
let getOffset = ({x: i32 = 0, y: i32 = 0}): i32 => x + y;
Alternatively, the separate syntax places the pattern and explicit type annotation apart:
let getDistance = ({x, y}: {x: f64, y: f64}): f64 => x * x + y * y;
The separate syntax also destructures class instances by field name:
class Point(x: i32, y: i32)
let getSum = ({x, y}: Point): i32 => x + y;
Tuple patterns ​
Tuples are unpacked using parenthesized patterns:
let sumPair = ((a, b): (i32, i32)): i32 => a + b;
Array patterns ​
Arrays are unpacked using bracketed patterns, optionally including rest
elements (...):
let sumFirstTwo = ([a, b]: Array<i32>): i32 => a + b;
// With rest pattern
let headAndTail = ([head, ...tail]: Array<i32>): i32 => head + tail.length;
Destructuring with default values ​
Destructured parameters can specify default values for individual fields or for the entire parameter when omitted:
let origin = ({x: i32, y: i32} = {x: 0, y: 0}): i32 => x + y;
origin(); // 0 (parameter default used)
origin({x: 3, y: 4}); // 7 (explicit argument supplied)
Types and signatures ​
Return types ​
A function's return type is inferred from the return expressions in its body. If
the body contains no return expressions, or if control flows off the end of a
block without returning a value, the return type is inferred as void:
let add = (a: i32, b: i32) => a + b; // Inferred return type: i32
let log = (msg: String) => { console.log(msg); }; // Inferred return type: void
An explicit return type annotation follows the parameter list after a colon:
function multiply(a: i32, b: i32): i32 {
return a * b;
}
When return types are required ​
Return type annotations are optional in most cases, including exported functions under normal conditions. However, an explicit return type is mandatory in the following situations:
- Direct and mutual recursion: When a function calls itself directly or participates in a mutual recursion cycle, the compiler cannot infer the return type from a call whose type is still unresolved:
function countdown(n: i32) {
Error:
if (n > 0) {
countdown(n - 1);
}
}
(Non-cyclic forward references do not require return type annotations; the compiler eagerly infers the callee's return type upon first reference).
- Functions crossing an import cycle: When two libraries import each other in a cycle, any function imported across the cyclic edge must provide a complete signature with all parameter types and the return type explicitly annotated.
- Functions using
tail return: The enclosing function must declare its return type so the compiler can verify at compile time that the tail call returns the exact same type. - Generators and async functions: Generator functions must declare
Iterator<T>(or inferTfrom yields), and async functions must declareFuture<T>.
Multi-value returns (inline tuples) ​
Zena supports returning multiple values without allocating heap objects using
inline tuples. These compile directly to WebAssembly multi-value function
returns:
let divide = (a: i32, b: i32): inline (i32, i32) => {
return (a / b, a % b);
};
let (quot, rem) = divide(17, 5);
// quot = 3, rem = 2
Inline tuples exist only in return positions and must be destructured immediately at the call site. They cannot be stored in variables or passed as arguments.
Contextual typing ​
When an arrow function is passed as an argument to a function or method with an expected signature, the parameter types of the arrow function are inferred from context:
let numbers = [1, 2, 3, 4];
// Parameter 'x' is inferred as i32 from Array<i32>.map
let doubled = numbers.map((x) => x * 2);
Contextual typing also operates with generic callees:
let sortBy = <T>(items: Array<T>, compare: (a: T, b: T) => i32): void => { ... };
// T is inferred from items, so 'a' and 'b' receive that type contextually
sortBy(items, (a, b) => a.name.length - b.name.length);
Explicit type annotations on closure parameters take precedence over contextual types.
Generic functions ​
Functions can declare type parameters within angle brackets before the parameter list:
function identity<T>(value: T): T {
return value;
}
let id = <T>(value: T): T => value;
Type parameters can be bounded with extends constraints:
interface Printable {
toString(): String;
}
function printItem<T extends Printable>(item: T): void {
console.log(item.toString());
}
Type arguments are typically inferred from arguments at the call site, but can also be supplied explicitly:
let str = identity<String>('hello');
Function types ​
Function types describe callable signatures. They are written with parameter names followed by types:
type Predicate<T> = (value: T) => boolean;
type BinaryOp = (a: i32, b: i32) => i32;
Parameter names are required in function type signatures to distinguish them
syntactically from tuple types ((i32, i32) is a tuple type; (a: i32, b: i32) => i32 is a function type).
Function types are structural:
- Parameter types are contravariant (accepting the specified type or any supertype).
- Return types are covariant (producing the specified type or any subtype).
Function overloading ​
External function declarations (declare function) and class methods support
overloading with multiple signatures under the same name:
declare function format(value: i32): String;
declare function format(value: f64): String;
format(42); // Calls format(i32)
format(3.14); // Calls format(f64)
The compiler resolves overloads at compile time by selecting the signature whose parameter types most specifically match the call-site argument types. Regular top-level functions do not support overloading.
Closures ​
Environment capture ​
Arrow functions can capture variables from their enclosing lexical scopes. Captured variables are stored in a heap-allocated context object, allowing them to outlive the scope in which they were created:
let makeCounter = (start: i32) => {
var count = start;
return () => {
count += 1;
return count;
};
};
let counter = makeCounter(10);
counter(); // 11
counter(); // 12
When a closure captures a mutable var variable, modifications made through the
closure affect the original binding, and changes made by the outer scope are
observed by the closure.
Arity adaptation ​
Zena supports passing callbacks that accept fewer arguments than the expected signature. The compiler automatically synthesizes an adapter closure that discards excess arguments:
let items = ['a', 'b', 'c'];
// Array.forEach passes (item: String, index: i32, array: Array<String>)
// The callback declares only 1 parameter; extra arguments are safely ignored:
items.forEach((item) => {
console.log(item);
});
Arity adaptation also applies when calling a union of function types with differing parameter counts:
type Fn1 = (a: i32) => i32;
type Fn2 = (a: i32, b: i32) => i32;
let call = (fn: Fn1 | Fn2): i32 => {
return fn(10, 20); // Passes (10) if fn is Fn1, or (10, 20) if fn is Fn2
};
Tail calls ​
tail return f(x); compiles the call to WebAssembly's return_call instruction.
The current stack frame is discarded before the callee runs, allowing recursive
functions to execute in constant stack space:
function sum(n: i32, acc: i32 = 0): i32 {
if (n == 0) {
return acc;
}
tail return sum(n - 1, acc + n);
}
sum(1000000); // Executes in constant stack space
Why tail calls are explicit ​
Tail-call optimization in Zena requires the tail keyword rather than being
performed automatically:
- Guaranteed optimization: A program relying on tail-call elimination to prevent stack overflow should not fail silently if an edit alters the call position.
- Stack trace preservation: A tail call discards the caller's frame, removing
it from stack traces and debugging backtraces. Requiring
tail returnensures frames are erased only when intended.
tail is a contextual keyword: it is only recognized as a modifier immediately
preceding return.
Rules and restrictions ​
The compiler enforces the following constraints on tail return:
- Single call operand: The operand must be a single function or method call
(
f(x),o.m(x),this.#m(x)). Expressions wrapping a call (such astail return f(x) + 1;) or constructor invocations (new C()) are rejected:
function count(): i32 { return 1; }
function invalidTail(): i32 {
tail return count() + 1;
Error:
}
- Enclosing return type declaration: The enclosing function must declare an explicit return type.
- Exact return type match: The callee must return the exact type declared by the enclosing function. No widening or subtyping conversions are allowed:
class Animal {}
class Dog extends Animal {}
function makeDog(): Dog { return new Dog(); }
function widen(): Animal {
tail return makeDog();
Error:
}
- Not allowed inside
tryblocks: Atryblock owes exception handling and potentialfinallyexecution to the current frame:
function inTry(): i32 {
try {
tail return count();
Error:
} catch (e) {
return 0;
}
}
- Not allowed with live
usingbindings: Any active resource acquired withusingmust have its disposer run before the frame is destroyed. - Not allowed in constructors or generators: Constructors return newly allocated instances, and generators return lazy iterators. (Async functions support tail calls through future adoption, described below).
- Multi-value returns:
tail returnis not currently supported for functions returninginlinetuples.
Tail return in async functions (adoption) ​
In an asynchronous function returning Future<T>, writing tail return f();
performs future adoption rather than awaiting the callee:
async function countdown(n: i32): Future<i32> {
if (n == 0) {
tail return base();
}
tail return countdown(n - 1);
}
In an ordinary return await f();, when the callee settles, the event loop must
schedule a microtask to resume the caller's frame solely to transfer the result
into the caller's future.
With tail return f();:
- Immediate frame retirement: The caller frame retires immediately without waiting for the callee to resolve.
- Direct settlement: When
f()settles, the caller's future settles synchronously from it without resuming the caller frame. - Zero-microtask chaining: Because adoption propagates synchronously, no intermediate microtask hop is scheduled.
- Operand requirement: The operand to
tail returnin an async function must statically evaluate to the declaredFuture<T>itself (not the unwrapped valueT):
async function getValue(): Future<i32> {
tail return 42;
Error:
}
Generator functions ​
A generator function produces a lazy sequence of values. It is declared with the
gen modifier and returns an Iterator<T>. Both function declarations and arrow
functions can be generators:
gen function range(start: i32, end: i32): Iterator<i32> {
var current = start;
while (current < end) {
yield current;
current += 1;
}
}
let countdown = gen (start: i32): Iterator<i32> => {
var n = start;
while (n > 0) {
yield n;
n -= 1;
}
};
When called, a generator does not execute its body immediately; it returns a
suspended iterator. The body executes on demand as elements are requested by a
for-in loop or manual calls to .next():
for (let item in range(0, 5)) {
console.log(item.toString());
}
The Iterator return type and yield ​
Generator functions adhere to the following typing and suspension rules:
- Declared return type: The declared return type must be
Iterator<T>. When the return type annotation is omitted,Tis inferred from the expressions passed toyield. yieldexpressions: Theyield value;statement produces an element of typeTand pauses execution, yielding control back to the caller until the next element is requested.
Generator return restrictions ​
Generators complete by falling off the end of the body or via a bare return;.
Returning a value (return value;) is a compile-time error:
let g = gen (): Iterator<i32> => {
yield 1;
return 2;
Error:
};
Deterministic disposal ​
If consumption of an iterator terminates early — such as through a break or
return in a for-in loop, or because an exception was thrown — the generator is
disposed immediately. Any enclosing finally blocks and active using resource
cleanups execute deterministically at that point rather than waiting for garbage
collection.
Async functions ​
An async function performs asynchronous work and returns a Future<T>. Async
functions can be top-level function declarations, arrow expressions, or class
methods:
import {Future} from 'zena:async';
async function fetchUser(id: i32): Future<String> {
let response = await httpGet('https://api.example.com/users/' + id.toString());
return response.body;
}
let loadConfig = async (): Future<String> => {
let file = await readFile('config.json');
return file.content;
};
Async syntax and eager execution ​
Async functions in Zena follow an eager-start model:
- Eager start: When an async function is called, execution begins synchronously
and proceeds until the first unresolved
awaitexpression. It then returns a pendingFuture<T>to the caller. - Concurrency without spawn primitives: Because async functions start eagerly, initiating multiple operations concurrently does not require special spawning constructs:
let loadDashboard = async (): Future<Dashboard> => {
// Both calls execute synchronously until their first unresolved await:
let userFuture = fetchUser(1);
let feedFuture = fetchFeed();
// Both operations proceed concurrently while awaited in sequence:
let user = await userFuture;
let feed = await feedFuture;
return new Dashboard(user, feed);
};
- Run-to-completion: Code between suspension points executes to completion on a single logical thread without preemption or data races.
Return types and return expressions ​
The typing of an async function distinguishes between the return type of the function itself and the return expressions inside its body:
- Declared return type: The declared return type must be
Future<T>. If the annotation is omitted, the compiler infersTfrom the return expressions and types the function asFuture<T>. - Return expressions: Inside an async body, a
return expr;statement expects an expression of typeT(the unwrapped value type), notFuture<T>. The runtime automatically wrapsexprinto the returnedFuture<T>:
async function compute(): Future<i32> {
return 42; // Returns i32; the caller receives Future<i32>
}
- Contrast with
tail return: Ordinaryreturnreturns an unwrapped valueT. In contrast,tail return callee();inside an async function requirescallee()to return the declaredFuture<T>itself, performing future adoption rather than value wrapping.
Absence of implicit future flattening ​
In JavaScript, Promise objects automatically flatten nested promises. Resolving a
promise with another promise or returning a promise from a .then() handler collapses
nested wrappers so that a Promise<Promise<T>> collapses to Promise<T>.
In Zena, Future<T> is a nominal generic class. The language does not perform
implicit runtime flattening:
Future<Future<T>>is distinct and representable: A future containing another future does not collapse into a single future.- No recursive unwrapping: Types in Zena mean what they declare. Generic code
instantiated with
T = Future<U>retainsFuture<Future<U>>without ambiguous runtime type tests or recursive type operators. - Explicit unwrapping: Unwrapping a future is always explicit through
await.
Awaiting futures at return ​
When an async function produces its result by calling another async function
f(): Future<T>, writing return f(); is a compile-time type mismatch because
return expects T:
async function inner(): Future<i32> {
return 42;
}
async function outer(): Future<i32> {
return inner();
Error:
}
To forward the result of inner(), write return await inner();:
async function outer(): Future<i32> {
return await inner(); // Awaits the Future<i32> to produce i32
}
Using return await provides the following guarantees:
- Type unwrapping:
awaitevaluatesFuture<T>toT, matching the expected return value type. - Explicit suspension: The suspension point remains explicit. If
inner()fails or cancellation occurs, the failure is observed in the caller's frame. - Deterministic cleanup: If
return await inner();is inside atryblock or a scope with liveusingbindings, anycatchorfinallyhandlers inouterexecute afterinner()settles.
When an async function delegates entirely to another future without needing to observe
its outcome, run further cleanup, or handle its errors locally, you can use
tail return inner(); instead. As described in Tail calls, tail return
adopts inner()'s future directly, retiring the caller frame immediately with zero
intermediate microtask hops.
Exception propagation ​
Async functions handle exceptions uniformly across eager execution and subsequent resumptions:
- Eager exceptions: If an exception is thrown in the eager section before the
first
await, it does not escape as a synchronous throw from the function call. Instead, the exception is caught by the async state machine, and the function returns a rejectedFuture<T>containing the error. - Continuation exceptions: Exceptions thrown after resuming from an
awaitsimilarly reject the function'sFuture<T>. - Awaiting a rejected future: When an async function awaits a rejected future,
the error resurfaces as a standard
throwat theawaitexpression:
async function risky(): Future<i32> {
throw new Error('Something went wrong');
}
async function run(): Future<i32> {
try {
let value = await risky();
return value;
} catch (e) {
console.log('Caught error: ' + e.message);
return 0;
}
}
Microtask queue and execution model ​
Asynchronous execution in Zena is driven by a cooperative, single-threaded event loop:
- Deterministic FIFO queue: Continuations scheduled by settling futures are
placed in a first-in, first-out (FIFO) microtask queue managed by
zena:async. - Always-async resumption: Awaiting an already-completed future does not resume the continuation synchronously inline. Resumption is always scheduled on the microtask queue. This guarantees consistent ordering and prevents stack overflows caused by deeply recursive synchronous completions.
- Platform execution: On platforms without an ambient event loop (such as
standalone WebAssembly run under
zena-cliorwasmtime), the runtime drains the microtask queue to completion before the program exits. On JavaScript hosts and browsers, the queue integrates with the host microtask loop.