Skip to content

Functions

Functions are declared with the return type first, then the function name, optional generic parameters, parameters, and body.

Int add(Int left, Int right) {
    return left + right;
}

Every function has a return type. Use Void when no result is needed; its only value is ():

Void log_done() {
    print("done");
}

Parameters are written as Type name. Defaulted parameters may appear anywhere in the parameter list. A default can be an ordinary expression in the declaration scope and is checked against the parameter’s expected type. The expression is evaluated at each call site that omits the argument; passing the argument explicitly does not evaluate it:

Int default_factor() {
    1 + 1
}

Int scale(Int factor = default_factor(), Int value) {
    return value * factor;
}

Int result = scale(2, 21);
Int defaulted = scale(value = 21);

Call arguments may be positional or written with name = expr. A positional argument always binds the earliest unbound parameter; it never skips a default by looking at the argument type. Named arguments can be reordered and can skip parameters that have defaults:

Int value = scale(2, 21);
Int named = scale(factor = 2, value = 21);
Int reordered = scale(value = 21, factor = 2);
Int defaulted = scale(value = 21);
Int missing = scale(21); // error: 21 binds factor, leaving value missing.

Positional arguments cannot appear after named arguments:

Int bad = scale(value = 21, 2); // error: positional argument after a named argument.

Named arguments are matched by parameter name, so they may appear in a different order from the function declaration. Omitted default parameters are filled before the call runs.

If several overloads share the same name, Jiang chooses the one that matches argument count, argument names, and argument types. Default parameters participate in ambiguity checks: if two overloads can both match the same call by arity and types, the declaration is ambiguous.

Int f(Int value) {
    return value;
}

Int f(Int value, Int extra = 1) {
    return value + extra;
}

Int a = f(10); // error: both overloads can match.

Use different names or non-overlapping parameter lists instead.

Local Declarations, Value Blocks, and Returns

Section titled “Local Declarations, Value Blocks, and Returns”
Int abs(Int value) {
    if value < 0 {
        return -value;
    } else {
        return value;
    }
}

return is a statement. A Void function may omit its value:

Void done() {
    return;
}

Blocks can be used as value blocks. Statements do not contribute to the block value; the value comes only from the final tail expression without a semicolon. A block without a tail expression has Void value:

Int value = {
    Int base = 40;
    base + 2
}

Void void_value = {
    Int ignored = 1;
}

A function body is also a block, so a tail expression at the end of the body can be the function result. These two forms are equivalent:

Int add(Int left, Int right) {
    return left + right;
}

Int add_tail(Int left, Int right) {
    left + right
}

Use return for early returns or branch exits.

Use tuples for multiple returned values:

(Int, Int) split(Int value) {
    return (value, value + 1);
}

Int main() {
    (_ left, _ right) = split(41);
    return left + right;
}

(T) is equivalent to T; a one-element tuple is not a distinct runtime shape.

Generic parameters follow the function name:

T id<T>(T value) {
    return value;
}

@where(T: Numeric)
T add<T>(T left, T right) {
    return left + right;
}

@where(...) is a leading attribute before the generic declaration.

Named keyword options use the same = spelling: struct [align = 8] and async [domain = ui_domain]. Colons in type constraints and lifetime attributes keep their existing meaning.

Functions inside a type are associated functions unless their first parameter is a receiver. Write self for a readonly Self& receiver:

struct User {
    Int id;

    User zero() {
        return User(id = 0)
    }

    Int value(self) {
        return self.id;
    }
}

User user = User(id = 42)
Int a = user.value();
User z = User.zero();

The receiver must be explicit in the parameter list. Ret method(self, ...) uses Self&; Ret method(Self&! self, ...) requires a unique mutable receiver; and Ret method(Self self, ...) consumes the receiver by value. Associated functions omit a receiver.

init(self, ...) is a constructor with an initializing self target. It is called through Type(...) or new Type(...), and is not exposed as a normal function value. deinit(self) is the cleanup entry point and is not called manually as an ordinary function.

Instance calls can also be written with an explicit receiver argument:

Int a = user.value();
Int b = User.value(user$.ref());

Jiang separates C-style function pointers from closures:

  • RawFn<R, A, B, ...> is a raw function pointer. It has no captured environment and is the type to use for C ABI callbacks.
  • Fn<R, A, B, ...> is a Jiang closure value. It can capture local values and can also wrap a matching RawFn.
  • FnOnce<R, A, B, ...> is a consuming closure. Calling it moves the closure, which permits moving value captures out of its body.
  • Fn<R, A, B, ...>^ is an owned closure handle. Use it when the closure value must outlive the current stack frame.

The first type argument is the return type, followed by parameter types.

Bool less(Int left, Int right) {
    return left < right;
}

RawFn<Bool, Int, Int> raw_compare = less;
Fn<Bool, Int, Int> compare = Fn(raw_compare);
Bool ok = compare(1, 2);

Anonymous functions use the expected type from their context:

Fn<Int, Int> add_base(Int base) {
    Fn<Int, Int> add = { value => value + base };
    return add; // error: the moved stack Fn cannot let its captured environment escape.
}

A closure with no parameters starts with { =>. An explicit capture list comes before the parameters and selects an existing local: [name] captures by value, while [ref name] and [ref! name] create shared and unique borrows. A capture list cannot declare, rename, type, or initialize a variable. Create an ordinary local first when a closure needs a snapshot or expression:

Int snapshot = value;
Fn<Int, Int> add_snapshot = { [snapshot] delta => snapshot + delta };

Locals omitted from the list are captured as shared views. Raw pointers are the exception and are captured by value. Writing outer storage requires an explicit [ref! name] capture.

When the final parameter is Fn or RawFn, its argument can be written as a trailing closure:

Void run(Fn<Void> work) {
    work();
}

run { print("done"); };
map(values) { value => transform(value) };

In trailing position, { statements } means a zero-parameter closure. A standalone brace body remains a normal block; write => when a standalone closure has no parameters. A call accepts one trailing closure, and any earlier omitted parameters must have defaults and be skipped by name.

A stack Fn is movable but not copyable, so ordinary value use moves it by default. It can be passed to functions that call it immediately, but it cannot be returned or stored somewhere that may outlive the captured stack values.

Use FnOnce only when the callback must consume a captured value. A FnOnce cannot be called through FnOnce&, and it cannot be called a second time.

Use new to create an owned closure:

@life()
Fn<Int>^ make_answer(Int value) {
    return new { [value] => value };
}

Int main() {
    Fn<Int>^ answer = make_answer(42);
    return answer() - 42;
}

Fn^ can be called directly. If pattern matching or field access gives you a reference to an owned closure handle, borrow a callable view first:

Int call_optional(Fn<Int>^? maybe) {
    guard maybe is .some(ref answer_ref) else {
        return 0;
    }
    Fn<Int>& answer = answer_ref$.ref();
    return answer();
}

An owned closure owns its closure object and all by-value captures. Borrowed captures remain borrows: putting them in Fn^ does not extend their sources. All borrowed captures contribute to one callable lifetime position, so the owned closure cannot outlive the shortest borrowed capture.

An unbound instance method includes the receiver reference as its first parameter and is represented as a RawFn:

RawFn<Int, User&> get_value = User.value;
Int value = get_value(user$.ref());

unsafe can be written before the return type inside RawFn<...> or Fn<...> to mark the context required to call the value. It describes the outer callable type, not the return value itself:

RawFn<unsafe Int, Int>[]& unsafe_callbacks;

Calling a function with the unsafe effect requires an explicit effect context:

Int value = unsafe {
    unsafe_callback(1)
};

An async function is a stackless coroutine. Calling it from an async context implicitly suspends the caller until the result is available, so the call expression has the declared result type:

async Int load_page() {
    1
}

async Int render() {
    load_page() + 1
}

Async functions, async RawFn values, and async Fn closures use the same call and suspension model. A closure may capture an environment; its captures still follow the normal ownership, lifetime, and cross-Domain Sendable rules.

Use Task { ... } to start work eagerly in the current Domain, or Task(domain = worker) { ... } to choose a Domain explicitly. The result is an address-stable Task<T>. This direct form is structured and !Movable: it stays in its enclosing scope. It may also be initialized directly in a statically addressable struct, tuple, or fixed-array field; an aggregate containing it cannot be moved, passed or returned by value, or captured. Starting multiple Tasks before awaiting them preserves concurrent progress even when they are awaited in source order:

async Int load_both() {
    Task<Int> left = Task { load_left() };
    Task<Int> right = Task { load_right() };
    left.await() + right.await()
}

Use new Task { ... } or new Task(domain = worker) { ... } when the Task owner must independently move, be returned, be passed to another function, or be stored in a movable field or container. The result is a movable, non-copyable Task<T>^; moving it transfers only the owner pointer and never relocates the running Task:

@life()
Task<Int>^ start_load() {
    new Task(domain = global_domain) { load_page() }
}

@region(a)
struct Request {
    @life(a)
    Task<Int>^ task;
}

Task<T>^ has the same capture lifetime shape as direct Task<T>; ^ changes storage ownership, not borrowing. A factory with no borrowed source uses @life(). A returned owner that captures a borrow instead declares that source with @life(return: source), and a containing field binds the Task lifetime through the struct’s @region. Because owner destruction does not wait, an owner that carries capture loans must be consumed with await() or cancel_and_await() before it is dropped.

task.await() waits and consumes the result exactly once. task.cancel() is a synchronous, idempotent request: it does not wait and does not consume the result, so a later await() remains valid. task.cancel_and_await() requests cancellation and asynchronously waits for the Task to exit. Dropping a Task<T>^ owner neither blocks nor implicitly cancels; the runtime reclaims the Task after both the owner and coroutine have finished with it. Direct Task<T> values remain structured and are cleaned up before their enclosing scope exits: unfinished children are first cancelled and then awaited before the remaining locals are destroyed.

Cancellation is cooperative. Suspend and resume boundaries observe requests automatically; long-running computation that does not suspend should call coroutine.check_cancelled() periodically. It returns immediately when no request exists and otherwise enters the current coroutine’s cleanup. If ordinary await() observes a child that ended through cancellation, it also cancels the current parent; structured cleanup then cancels and waits for unfinished siblings. Use cancel_and_await() when only the selected Task should stop and the caller should continue.

A Domain effect names where async work runs. Start with the two standard Domains:

async [global_domain] Int load_on_worker() {
    1
}

Int main() {
    coroutine.sync(main_domain) {
        Task<Int> worker = Task(domain = global_domain) {
            load_on_worker()
        };
        worker.await()
    }
}

main_domain is serial and bound to the program’s startup thread. global_domain uses the shared concurrent executor. Task(domain = global_domain) { ... } starts a Task there; Task { ... } inherits the current Domain. The removed async { ... } and sync [domain] { ... } block forms are not accepted. coroutine.sync(domain) { ... } always requires an explicit Domain. In an async function it suspends the current coroutine, runs the closure on the target Domain without blocking a worker thread, and then resumes on the entering Domain. An outermost coroutine.sync(main_domain) in an ordinary function blocks the calling thread until completion while pumping main-thread work.

Values transferred by value across Domains must preserve their normal move or copy capability and also satisfy Sendable. A shared T& may cross when T: Sendable, but its ordinary lifetime still cannot outlive the source owner. A unique T&! does not cross directly. To pass a shared view, explicitly create one with $.ref(); writes through the original unique reference stay frozen while that shared reborrow is live. Raw pointers are not automatically Sendable.

Applications with their own queue or event loop can provide an Executor and bind it to a Domain:

struct InlineExecutor: Executor {
    Void enqueue(Self& self, ExecutorJob job) {
        job.run();
    }
}

struct InlineDomain: Domain<kind = .serial> {
    associated ExecutorType = InlineExecutor;

    InlineExecutor make_executor(Self& self) {
        InlineExecutor()
    }
}

const InlineDomain inline_domain = InlineDomain();

The same Domain type supports two lifetime models. A named const binding is a program-wide execution identity and lazily owns one Executor instance. It is the preferred form for long-lived shared Domains and has lower scheduling overhead than a runtime Domain.

Create an ordinary Domain value when a page, scene, or session needs an independent execution identity that can be released when that owner is no longer used. Pass a shared reference when selecting it:

SceneDomain scene_domain = SceneDomain(config = config);

Task<Int> task = Task(domain = scene_domain$.ref()) {
    load_scene()
};

Int value = coroutine.sync(scene_domain$.ref()) {
    update_scene()
};

Only a named const Domain may appear in an async [domain] effect. A Task accepted by a runtime Domain can finish after the Domain owner leaves scope; dropping the owner does not implicitly cancel it. The Domain’s resources are released after its accepted Tasks finish. By contrast, coroutine.sync(scene_domain$.ref()) is structured: it borrows the Domain only until the closure and its child coroutines complete, and the returned value does not carry that Domain lifetime.

Choose based on ownership first: use const for shared application-lifetime execution identities, and an ordinary Domain value for independently owned, finite-lived execution resources. The runtime form pays modest dynamic-lifetime overhead so that those resources can be reclaimed deterministically.

ExecutorJob is a move-only, one-shot value: after accepting it, an Executor must eventually call run() exactly once. An Executor may receive concurrent enqueue calls and must synchronize its own mutable queue state.

Use Atomic<T> for supported lock-free scalar state. The default operations use sequential ordering; the same operation names accept an explicit MemoryOrder when weaker ordering is required:

Atomic<Int> state = Atomic<Int>(0);
state.set(1, .release);
Int observed = state.get(.acquire);
Int previous = state.get_and_set(2, .acquire_release);
Bool changed = state.compare_and_set(
    2, 3, MemoryOrder.acquire_release, MemoryOrder.acquire
);

Use Mutex<T> for compound shared state. Mutex<T> is address-stable and !Movable; when T: Sendable, transfer a Mutex<T>^ owner handle across Domains. with_lock provides a unique mutable reference only for the synchronous callback, so it cannot be held across an await.

Low-level async APIs can suspend explicitly with coroutine.suspend(registration). The registration callback receives a continuation; call resume(value) on completion and register active cancellation with on_cancel(handler). A late resume is ignored after cancellation has claimed the operation.

Use RawFn when you need an ABI-level function pointer. Use Fn for normal callback APIs that may accept capturing closures.