Skip to content

Closures And Callables

A closure is a concrete compiler-generated aggregate containing its explicit capture environment. An erased callable is a value whose concrete environment type has been hidden behind one call signature. These are related, but they are not the same type.

Status: accepted language direction; examples using the new callable surface are skipped until the implementation milestone lands.

Captures

A closure literal keeps the ordinary (parameters) -> Result { ... } spelling. Every used outer local appears in the capture list before it:

Zynx
let base = 10;
let add = [base] (value: i32) -> i32 {
	return value + base;
};

assert add(5) == 15;

Captures use the same roles as ordinary values:

  • value copies a Copy value;
  • <-value moves owned non-Copy storage into the environment;
  • value in _ stores a shared loan;
  • &value in _ stores an exclusive loan.

There are no implicit captures and no last-use capture mode. Ownership transfer is visible at closure construction:

Zynx
let read = [config] () -> i32 {
	return config.limit;
};

let update = [&state in _] () -> void {
	state.count += 1;
};

let job = [<-resource] () -> void {
	consume(<-resource);
};

A raw pointer cannot be captured by safe closure construction. Pass it as an argument to an operation whose unsafe contract is visible instead.

Invocation Receiver

The body determines the weakest access needed for the hidden environment:

  • reading captures requires self;
  • mutating through the environment requires &self;
  • taking an owned capture requires ^self and consumes the environment.

This is the ordinary method receiver model, not an Fn/FnMut/FnOnce hierarchy. Normal call syntax invokes the closure:

Zynx
let offset = 3;
let add = [offset] (value: i32) -> i32 {
	return value + offset;
};

assert add(4) == 7;

A closure requiring only self can be invoked when shared, exclusive, or owning access is available. One requiring &self needs exclusive or owning access. A ^self invocation consumes the callable environment. This access compatibility does not change who owns the callable value.

Structural Callable-Shape Bounds

A callable-shaped generic bound constrains how a concrete type can be invoked without erasing that type:

Zynx
fn transform<T, U, F: (&self, ^T) -> ^U>(value: ^T, mapper: &F) -> ^U {
	return mapper(<-value);
}

fn keep<T, P: (&self, T) -> bool>(value: T, predicate: &P) -> bool {
	return predicate(value);
}

Here F and P remain their concrete closure, zero-state non-generic function-item, or explicitly instantiated generic function-item types. A bare generic function still requires explicit instantiation; the bound does not infer it. The compiler monomorphizes the generic declaration for that concrete type and calls it directly. The bound is not an erased-arrow expected-type and introduces no erased callable value, hidden environment allocation, vtable, runtime witness, or implicit ownership transfer. A generic struct field of type F therefore stores the concrete environment inline and derives Send and Sync structurally from that field.

The receiver in the shape describes the access available at each generic call. A (&self, ...) requirement admits a callable whose inferred environment role is self or &self, because exclusive access can perform either invocation; it rejects a ^self-only callable because repeated calls would consume it. Argument roles and the result contract remain exact after ordinary role normalization. In particular, ^T transfers an owner to the callable, while bare T gives it only a call-scoped shared loan. A bare Copy result is already a fresh owned value; no callable-bound ownership conversion is added.

This syntax has a deliberately context-sensitive meaning. (Args...) -> R in ordinary type position is an erased callable type. The same arrow following a generic parameter colon, as in F: (Args...) -> R, is a structural invocation requirement on F. An already erased callable may satisfy such a requirement, but erasure then occurred explicitly before or at the caller's chosen type, not because of the bound.

Erased Callable Types

The erased type uses the ordinary arrow. Shared/read environment access is implicit; only exclusive/mutating and consuming access write a leading &self or ^self marker:

Zynx
type Predicate = (Item) -> bool;
type Step = (&self) -> void;
type Job = (^self) -> void;
type Parser = (Bytes) throws(ParseError) -> Node;

Zero actual arguments are () -> R, (&self) -> R, and (^self) -> R. With arguments, the receiver marker uses an ordinary comma, never a semicolon: (&self, Event) -> void. It is hidden metadata and does not count toward call-site arity. The argument types, checked-error set, result, optional region, and capability qualification are all part of the static type. call is an ordinary identifier, not a type keyword. Closure literal syntax is unchanged.

Erasure occurs when a concrete closure or monomorphic named function is placed into an expected callable type:

Zynx
fn apply(value: i32, predicate: (i32) -> bool) -> bool {
	return predicate(value);
}

let limit = 10;
let accepted = apply(7, [limit] (value: i32) -> bool {
	return value < limit;
});

Argument and result contracts must match. The environment receiver must admit the body's inferred invocation role. Erasure never inserts cloning, allocation, or an ownership transfer not already written by the surrounding role.

Callable Ownership And Regions

Storage ownership is independent of invocation. A bare callable parameter borrows, an outer & role is an exclusive loan, and an outer ^ role owns the erased environment. A named owning transfer uses <-:

Zynx
type Work = () -> i32;

fn inspect(work: Work) -> i32 {
	return work();
}

fn store(work: ^Work) throws(AllocError) {
	queue = try (<-queue).push(<-work);
}

let value = 41;
let work: ^Work = [value] () -> i32 {
	return value + 1;
};
try store(<-work);

A closure capturing a checked reference has a region-bounded target, for example () -> void in L. The callable region is a compile-time no-escape contract. It cannot be omitted to return, store globally, or otherwise outlive the captured loan.

Zynx
fn call_now<region L>(body: () -> void in L) {
	body();
}

var value = 7;
call_now([value in _] () -> void {
	assert value == 7;
});

Capability-Qualified Erasure

An erased callable carries cross-thread proof only when its type says so:

Zynx
(Event) -> void where Send
(Event) -> void where Sync
(Event) -> void where Send + Sync

Send, Sync, and canonical Send + Sync are the complete qualifier set. Sync + Send, duplicate qualifiers, and any other qualifier are hard errors. The environment receiver remains independently self, &self, or ^self: invocation access neither implies nor replaces a capability qualifier.

A concrete closure's capabilities derive structurally from its explicit environment fields. Direct erasure to a qualified callable succeeds only while those fields prove every requested capability. A diagnostic reports the exact capture path that failed. Named functions and noncapturing closures have an empty environment and can prove Send + Sync.

Zynx
fn increment(value: i32) -> i32 {
	return value + 1;
}

let portable: ^(i32) -> i32 where Send + Sync = increment;

let base = 40;
let sendable: ^() -> i32 where Send =
	[base] () -> i32 { return base + 2; };

Qualification is static type identity. It is not a runtime flag, witness field, or property recovered by inspecting erased storage. An unqualified callable type carries neither capability even when its source environment had both.

The only capability conversions forget proof:

text
Send + Sync -> Send -> unqualified
Send + Sync -> Sync -> unqualified

There is no conversion in the other direction and no cast that recovers proof after unqualified erasure:

Zynx
let plain: ^() -> i32 = <-sendable; // allowed: forget Send
let restored: ^() -> i32 where Send = <-plain;
// error: an unqualified erased callable cannot be strengthened

Aliases, struct fields, containers, channel element types, and control-flow joins preserve the selected identity. A join may choose a common weaker type through the same forgetting rules, but cannot keep a hidden per-value witness and strengthen the result later.

Workers, Channels, And Borrowed Captures

Use an owned where Send callable when an erased callback must move through a worker or channel boundary:

Zynx
import std.channel;

type Job = ^(^self) -> i32 where Send;

let (tx, rx) = channel.bounded<Job>(1);
let base = 40;
let job: Job = [base] () -> i32 {
	return base + 2;
};

await tx.send(<-job); // accepted: Job retains the Send proof
_ = rx;

The same closure erased first to unqualified ^(^self) -> i32 remains rejected at that boundary. The compiler cannot recover its former capture proof.

A qualified callable does not create or extend a loan. Shared and exclusive captures keep their named regions and may cross a thread only through the existing scoped-concurrency rules: the referent must have the required structural capability, access must remain nonoverlapping, and every child must join before the region ends. There is no automatic unscoped cross-thread loan.

Short Closure Syntax

(x) => expression is the short body form when an expected signature supplies parameter and result types. Its capture list remains explicit:

Zynx
fn apply(value: i32, f: (i32) -> i32) -> i32 {
	return f(value);
}

let base = 1;
let result = apply(2, [base] (x) => x + base);
assert result == 3;

Without an expected callable signature, inferred short parameters are rejected.

Released under the MIT License.