Generics And Interfaces
This chapter documents Zynx generic scope, type packs, const generics, interfaces, dynamic interface values, and overload resolution.
Status: current public API unless a rule is marked unsupported or unspecified.
Generic Scope
The generic model is intentionally bounded. Ordinary generic parameters are type parameters unless they have an integer const-generic bound such as N: usize.
A bare generic function or method is not a first-class value. Explicitly supplying every generic argument, as in id<i32>, produces an ordinary monomorphic function-item value; a structural callable-shape bound can retain that concrete identity. A bare name such as id or module.id cannot be assigned or passed as a callable value, and neither an erased-arrow expected type nor a structural callable bound infers the missing instantiation. Monomorphic non-generic function values and closure values remain supported by the callable-value rules.
Type packs are supported only as the final generic parameter of function, method, intrinsic-function, and struct declarations:
fn tuple<T...>(xs: T...) -> (T...) {
return (xs...);
}
struct TupleBox<T...> {
let values: (T...),
}Type packs are not supported on type aliases, interfaces, enums, unions, error sets, or const generic parameters. A declaration may have at most one type pack, and it must be final. Interface-bounded type packs are unsupported. Empty packs are allowed; an empty tuple type/expression expansion normalizes to canonical void/void(), so tuple() has success type void. There is no source () type or Unit. Nonempty expansions preserve every position, including void.
Pack expansion is valid only in these positions:
- the final function parameter type, for example
xs: T... - tuple types, for example
(T...) - callable parameter lists, for example
(T...) -> R - generic argument lists for pack-backed declarations, for example
Box<T...> - tuple expressions, for example
(xs...) - call argument forwarding, for example
f(xs...)
Pack expansion is not valid as an array element type ([T...]), an array size, an error set, a const generic argument, or an arbitrary expression. Return types may contain packs only through a valid expansion position such as (T...) or a pack-backed generic instantiation such as Box<T...>; a bare T from a pack is rejected.
Pack inference is call-site based. Fixed generic parameters are inferred first. For a final pack parameter such as xs: T..., the pack is inferred from the remaining positional call arguments in order. Pack patterns inside tuple, callable, and generic-argument positions must infer the same pack length and same element types. If a pack cannot be matched consistently, overload resolution rejects that candidate and reports the normal no-match or ambiguity diagnostic. Return type context alone does not add new pack elements.
Qualified Callables In Generic Types
Callable capability qualification is preserved exactly by type aliases, generic arguments, substitution, fields, and containers:
type Worker<T> = (T) -> void where Send;
struct Queue<T> {
let pending: ^[T],
}
let queue: Queue<^Worker<Job>>;(T) -> U, where Send, where Sync, and where Send + Sync are distinct static type identities. A generic Send or Sync bound sees the qualification carried by the callable type; it does not inspect the erased environment. Substitution cannot strengthen an unqualified callable or recover a qualifier forgotten before the value entered the generic container. No generic dictionary or per-element witness is stored at runtime.
This is analogous to a dynamic interface value: an unannotated erased interface does not acquire Send or Sync merely because its hidden implementation has them. Callable qualification is still its own type rule, not interface conformance, and adds no coherence implementation.
The sealed Future identity follows the same preservation rule with its narrower surface. Future<T> and Future<T> where Send remain distinct through aliases, generic arguments, substitution, fields, and containers. A generic bound or expected type cannot inspect a frame to strengthen an unqualified Future, and there is no qualifier dictionary or runtime witness. Future's eventual T may independently be non-Send; parallel Join transport checks that payload at its own boundary.
Structural Callable-Shape Bounds
A callable-shaped generic bound requires an invocation surface while preserving the concrete type of the argument:
struct Mapper<T, U, F: (&self, ^T) -> ^U> {
let callback: ^F,
}
fn invoke<T, U, F: (&self, ^T) -> ^U>(callback: &F, value: ^T) -> ^U {
return callback(<-value);
}F is monomorphized as the concrete compiler-generated closure aggregate, zero-state non-generic function-item type, or explicitly instantiated generic function-item type. A field of type F stores that value inline; the constraint does not coerce it to an erased (Args...) -> R value, allocate its environment, add dynamic dispatch, or forget its structural Send/Sync properties. Explicitly choosing an erased callable as F remains legal when it satisfies the shape, but that erasure is visible in the caller's type.
The callable receiver is part of the requirement. F: (&self, A) -> B means generic code may invoke F repeatedly with exclusive access to its environment; callables that need only self also satisfy it, while a ^self-only callable does not. Parameter arity, order, and normalized roles remain exact: ^A moves an owner into the invocation, whereas bare A is a call-scoped shared loan. A bare -> B for Copy B already normalizes to a fresh owned result and can satisfy -> ^B; this is not a callable-bound coercion. Checked-error sets, regions, and qualifications are likewise part of the written shape.
The arrow is structural only in this bound context. In a parameter, field, alias, local, return, or generic argument type, (Args...) -> R continues to mean an erased callable type.
Const generics are limited to integer parameters:
struct SizedArray<T, N: usize> {
let arr: [T; N],
}The const-generic type must be a builtin integer type other than bool. Const generic packs are not supported. Static array, Vector, and Matrix dimensions are evaluated in the target's checked usize context. Atomic arguments are written directly (4, N); compound expressions require braces:
type PlusOne<T, N: usize> = Vector<T, {N + 1}>;
type Doubled<T, N: usize> = [T; {N << 1}];Shape expressions support parentheses, integer casts, unary + - ~, and + - * / % << >> & | ^. A parameter declared with another integer type must be cast explicitly with as usize. Every operation is checked for target-width overflow, negative results, division by zero, and invalid shift counts.
Named const aliases are expanded. Normalization folds constants, removes parentheses, and canonicalizes associative-commutative +, *, &, |, and ^, so N + 1 and 1 + N match. It deliberately does not distribute, cancel, or solve general algebra.
When two symbolic shapes cannot yet be proven equal, the compiler records a shape obligation with both source origins instead of accepting or rejecting it early. Obligations propagate through generic calls. At a concrete instantiation they are evaluated by value; a failure points to the call site and both constraint sources.
Generic Parameter Defaults
Structs, enums, interfaces, and type aliases may give trailing generic parameters defaults:
struct Box<T, U = i64> {
let value: T,
let extra: U,
}
struct Pair<T, U = T> {
let left: T,
let right: U,
}
struct Buf<T, N: usize = 4> {
let items: [T; N],
}
struct Extended<T, N: usize, M: usize = {N + 1}> {
let items: [T; M],
}When a use omits trailing generic arguments, defaults are filled in declaration order. A type default may reference only earlier generic parameters in the same list, as Pair<T, U = T> does. A const-generic default may be a valid shape expression and may reference only earlier parameters, as Extended does. Region generics and type packs cannot have defaults, and a non-defaulted parameter cannot follow a defaulted parameter. Function, method, and init generic parameters cannot have defaults.
Interfaces And Overload Resolution
Interfaces serve two roles: a static contract (generic bounds, struct conformance, method lookup, default methods) and a dynamic interface value that erases the concrete type behind a fat pointer. The role is determined by where the interface name appears.
- As a generic bound,
fn f<T: Writer>(x: T), dispatch is static: the call is monomorphized to the concrete type with no runtime indirection. - As a value or storage type,
Writer,&Writer, or^Writeris a dynamic interface value: a fixed-size erased handle whose methods dispatch through a per-(struct, interface) vtable at runtime. Its concrete layout is internal compiler ABI.
A dynamic interface value has three explicit roles:
- Bare
Writeris a shared borrowed fat loan. &Writeris an exclusive mutable borrowed fat loan.^Writeris a fixed-size owned erased value. It owns one heap-boxed concrete object, is move-only, and runs the concrete destructor plus box deallocation exactly once when still armed at drop.
Passing a conforming concrete value as Writer, or &value as &Writer, creates only the corresponding call-scoped loan; a static <T: Writer> candidate is preferred when both static and borrowed dynamic dispatch apply. There is no concrete-to-^Writer coercion. Owned erasure is exclusively the written fallible operation try std.mem.box<Writer>(<-value), which consumes one concrete input and produces one erased owner.
interface Writer {
fn write(self, data: str);
}
struct Buffer: Writer {
fn write(self, data: str) {
// ...
}
}
// static dispatch: monomorphized to the concrete type
fn write_static<W: Writer>(w: W) {
w.write("hi");
}
// dynamic dispatch: the concrete type is erased behind a fat pointer
fn write_dynamic(w: Writer) {
w.write("hi");
}
// owned dynamic value: takes ownership of the boxed object
fn consume(w: ^Writer) {
w.write("bye");
}
let buffer = Buffer {};
let erased: ^Writer = try std.mem.box<Writer>(<-buffer);
consume(<-erased);Only a fully instantiated object-safe interface can be used as a dynamic value. Object safety is checked after interface-level generic arguments are substituted. Dynamically callable methods must have a fixed vtable signature; an unresolved method-local generic operation must be explicitly static and is absent from the vtable. The marker interfaces and closed capabilities (Copy, Scalar, Integer, ByteAlignedInteger, Signed, Unsigned, Float) are never dynamic.
Owned dynamic storage is compositional, not whitelisted. ^Writer may be a local, parameter, result, struct field, tuple element, fixed-array element, dynamic-slice element, or the owned slot of a generic container wherever the same move-only role is otherwise legal. For example:
struct Writers {
let primary: ^Writer,
let all: ^[^Writer],
}
struct WriterLoan<region L> {
let value: Writer in L,
}
let first = Buffer {};
let second = Buffer {};
let writers: ^[^Writer] = try ^[
try std.mem.box<Writer>(<-first),
try std.mem.box<Writer>(<-second),
];[^Writer] is a shared slice view over stored owner elements. It owns and drops nothing; indexing, reading, and iteration borrow each element for shared dynamic access, and moving an element through the view is rejected. ^[^Writer] owns the buffer and every still-armed erased owner. It drops elements in reverse index order through their vtables, then frees the buffer exactly once. Extraction uses only ordinary owner rules: named fields, tuple positions, and compile-time-known fixed-array indices may use static partial moves, while runtime-indexed owned sequences use remove, pop, or drain. Reads and iteration remain borrows even for an owned container.
Stored borrowed Writer, &Writer, and views containing them require the same explicit named in L contract as other stored loans and views. Dynamic dispatch does not extend a referent lifetime. Dynamic values containing loans remain valid only while those declared regions are valid.
Each owned element must be boxed explicitly. Aggregate conversions such as [Buffer] to [Writer], ^[Buffer] to ^[^Writer], and corresponding generic container conversions are hard errors; the compiler never inserts an allocation loop or a fat-pointer-array coercion. If one written boxing operation fails, its consumed input is cleaned exactly once and already initialized earlier elements use ordinary reverse partial-construction cleanup.
fn rejected(concrete: ^[Buffer], owners: [^Writer]) {
let erased: ^[^Writer] = <-concrete;
// error: no container-wide Buffer -> Writer erasure; box each element
let stolen = <-owners[0];
// error: cannot move an owner through shared view [^Writer]
}
struct MissingRegion {
let writer: Writer,
// error: stored borrowed dynamic access requires an explicit named region
}There is no automatic variance or upcast between erased interfaces, including an interface and its base, and no container-wide upcast. A future upcast needs a separate explicit operation and language decision. Dynamic interface values are also rejected in C FFI signatures and nested C layouts. Their fixed-size Zynx storage, vtables, and calling shims are internal compiler ABI.
Where dynamic dispatch is unnecessary, use a generic parameter constrained by the interface:
fn read_from<R: Reader>(reader: R) -> usize {
return reader.read();
}An interface can declare required methods, default methods with bodies, static generic methods, and base interfaces. Interfaces do not declare stored or required fields; conforming structs keep storage private behind methods. A struct claims an interface by listing it in its base list:
interface Writer {
fn write(self, data: str);
fn write_line(self, data: str) {
self.write(data);
self.write("\n");
}
}
struct Buffer: Writer {
fn write(self, data: str) {
// ...
}
}Struct conformance is checked statically:
- Every required method must be present with a compatible signature after generic substitution. Receiver roles must match.
- Default interface methods may be omitted by the struct.
- A concrete struct method with the same name as an interface default method overrides that default method only when its signature is compatible.
- Base interfaces are checked recursively.
Copy,Scalar,Integer,ByteAlignedInteger,Signed,Unsigned, andFloatare builtin marker interfaces or closed capabilities with the special rules documented in the relevant sections; they are not ordinary method-bearing interfaces.
Bound names resolve normally before bound checking. Copyable and ImplicitCopy are available user identifiers, not legacy marker aliases: a declared or imported symbol with either name is resolved and checked normally. Only a truly unresolved Copyable in bound position may receive help suggesting canonical Copy; the suggestion never inserts a binding or rewrites a resolved user symbol.
Scalar is implemented by primitive integer, floating-point, and bool types; structs cannot opt in. Integer and Float imply Scalar, while Signed and Unsigned imply Integer. A Scalar bound permits scalar storage, including generic Vector/Matrix elements, but does not grant numeric operators. Use the more specific bounds when a generic body needs built-in operators or intrinsics:
fn rotate<T: Integer>(x: T, n: T) -> T {
return (x << n) | (x >> n);
}The marker bound enables the built-in operator rules for matching integer types. It does not create user-defined operator overloading.
ByteAlignedInteger is a closed compiler-known refinement of Integer. It is implemented exactly by scalar signed and unsigned integers whose semantic bit width is divisible by eight: standard integers including i8/u8, target-width isize/usize, and custom widths such as i24 or u40. bool, non-byte custom widths, Vector, Matrix, and user-defined types are excluded; user code cannot declare or forge a conformance.
This bound makes byte alignment available when a generic body is checked once:
fn reverse_order<T: ByteAlignedInteger>(value: T) -> T {
return bit.swap_bytes(value);
}Writing only T: Integer for this body is an error at the generic definition, not a deferred error for selected instantiations.
Integer literals assigned to a marker-bounded generic value (let x: T = 1;) are checked once, at the generic definition, against the intersection of every type the bound admits — the concrete range check does not rerun at instantiation:
T: IntegerandT: Unsignedaccept literals in[0, 127](unsigned types start at 0;i8tops out at 127).T: Signedaccepts literals in[-128, 127](thei8range), since every instantiation is a signed type.T: Floataccepts any float literal (checked against the widest float type).
Out-of-window literals are rejected at the definition with literal value ... does not fit in type T``; use a runtime conversion or a narrower bound when a generic body needs larger constants.
Method lookup is deterministic:
module.member(...)searches exported declarations in the resolved module namespace.value.method(...)on a struct value searches concrete struct methods first. If the struct has no concrete method with that name, default methods from the struct's implemented interfaces and their base interfaces are considered.value.method(...)on a generic value searches the methods provided by the value's interface bounds and their base interfaces.- Interface default methods are statically resolved against the concrete struct or constrained generic type; they are not virtual calls.
- When a selected method's first parameter is named
self, the receiver is inserted as that first argument. If the parameter expects a reference, the receiver is borrowed for the method call.
Cursor interfaces and structural sources
std.iter.Iterator<T> is the infallible owner-yield cursor interface:
interface Iterator<T> {
fn next(&self) -> ^T?;
}std.iter.ExclusiveIterator<T> is the separate complete-element lending cursor interface described below. Both abstract cursors, never collections or other sources. The accepted target has no Iterable<T, Iter>, source conversion interface, associated source cursor, or blanket source conformance.
A generic algorithm that consumes or borrows a sequence accepts its cursor explicitly under Iterator<T> or ExclusiveIterator<T>. Its caller chooses the source receiver role and passes the result of the concrete iter call. A generic operation needing two traversals receives two cursors explicitly; it cannot assume that an abstract source is restartable. Dynamic erasure likewise erases a cursor, not a source-to-cursor factory.
for may still adapt a concrete source ergonomically. If the expression has no direct synchronous cursor-shaped next, ordinary concrete method resolution selects iter(self), iter(&self), or iter(^self) solely from the written receiver role and acquires its cursor once. This structural language rule is not an interface bound and is unavailable as an implicit generic conversion. If a cursor-shaped next exists, it wins over iter; an effect or binding mismatch is a diagnostic rather than a fallback. An unrelated method merely named next but lacking every accepted synchronous cursor shape does not block structural source resolution.
Stateful standard cursors and adapters are affine, non-Copy, non-Clone, and next-only. A named direct cursor is exclusively borrowed by for, so break ends the loop loan and permits a later loop or direct next to resume the same cursor. Once any accepted next returns null normally, exhaustion is sticky and every later pull returns null without observable effects or checked failure.
Fallible cursors stay structural: next() -> T? throws(E) is selected only by for try, with no fallible standard interface. A checked failure is not exhaustion. Future<T> and Generator<T> retain their separate language contracts; a Future-returning method is not synchronous iteration, while a Generator<T> value is an ordinary owner-yield cursor when driven through its canonical synchronous next.
Concrete lending cursors do not require a new interface. The existing std.iter.ExclusiveIterator<T> contract is exactly next<region Step>(&self in Step) -> &T? in Step. When only selected projections may be mutable, any concrete cursor may instead return a region-typed affine aggregate:
struct View<region Step> {
let shared: K in Step,
let exclusive: &V in Step,
}
fn next<region Step>(&self in Step) -> View? in Step;Ordinary structural for resolution accepts this exact method shape and keeps the complete result inside the fresh Step. It does not inspect the nominal view or collection name. An eligible view contains only shared or exclusive loans plus copy metadata; it has no owned field and no destructor. The field roles are checked compositionally under the normal generic bounds; no associated item type, higher-kinded region parameter, or ViewIterator interface is introduced. Generic code that specifically needs complete-element &T lending continues to use ExclusiveIterator<T> unchanged.
Operators
Operators are built-in syntax with built-in type rules. User code cannot declare operator methods, overload arithmetic/comparison/indexing syntax, or intercept casts, address-of, raw dereference, short-circuiting, catch, try, or await. Use named methods for domain-specific behavior.
Structural equality of a generic payload enum follows the same separate checking rule. A comparison in a generic body is legal only when every symbolic payload field's built-in == -> bool is proved from the declared bounds at body check time; unresolved equality is a hard error and is not retried after monomorphization. This introduces no implicit Eq bound or interface. A concrete use of an instantiated enum may separately determine whole-enum eligibility after substituting its payload types. Generic APIs that need an otherwise unavailable equality operation accept an explicit comparator callable or a domain-specific named operation.
Overload resolution starts from the declaration set found by name lookup or method lookup. Declaration/source order is not a semantic tie-breaker. A candidate is considered only when all of these checks pass:
- The candidate has the requested name or operator.
- The call arity fits the required parameters, default parameters, variadic parameters, or type-pack parameter shape.
- Named arguments bind to parameters by name. Named arguments must appear after positional arguments, must follow parameter order, must not repeat a parameter, and must name an existing parameter. Omitted default arguments are filled at the call site in parameter order. Default expressions are type-checked in the declaration environment before parameter bindings are introduced, so a default cannot depend on a caller local or on another argument value.
- Explicit generic arguments and inferred generic arguments satisfy all generic bounds.
- Each explicit argument can convert to its matched parameter type. Contextual enum literals, short lambdas, tuple literals, struct literals, array literals, and vector literals are checked against each candidate's expected parameter type.
The selected overload is the candidate with the lowest total argument conversion cost. Per-argument conversion costs are ordered as:
| Cost | Meaning |
|---|---|
| 0 | exact type match |
| 1 | promotion, such as integer widening, reference dereference of Copy payloads, nullable wrapping, T to T throws(E), error variant to compatible error union, or error-union subset conversion |
| 2 | view conversion |
| 3 | numeric conversion, such as integer-to-float |
Candidates that require an impossible conversion are discarded. If two candidates have the same total cost, a candidate wins only when it is no worse for every explicit argument and better for at least one explicit argument. If neither candidate is strictly better by that rule, the call is ambiguous and is rejected.
The callable result type does not select among otherwise applicable overloads. Thrown error sets participate when an argument or contextual target type is an error union: T throws(A) can convert to T throws(A | B), but not the other way around. Copy and other interface constraints participate through generic bound satisfaction. Contextual enum literals participate by checking whether the candidate parameter type provides the named variant; if more than one surviving candidate remains equally good, the call is ambiguous.
The required diagnostic classes are:
- no applicable candidate:
no matching overload for \name`` - equally good candidates:
ambiguous call to function 'name' - named-argument errors:
unknown named parameter,duplicate named argument,named arguments must follow parameter order, orpositional arguments cannot follow named arguments