Skip to content

Generics & Bounds

Generics let one declaration work with multiple types. Generic parameters are written after the declaration name. Type parameters use a name such as T; const generic parameters use Name: const Type.

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

Int a = id<Int>(42);
_ b = id(42);
@region(a: A, b: B)
struct Pair<A, B> {
    @life(a)
    A first;

    @life(b)
    B second;
}

Pair<Int, UInt8[]&> pair = Pair<Int, UInt8[]&>(first = 1, second = "one");

a: A and b: B state that these public regions have the same lifetime shape as their respective type arguments. A generic field binding supplies one complete shape: @life(a) binds every lifetime carried by A; it does not bind only the first one.

Type arguments may include suffixes:

Pair<Int?, UInt8[]&> maybe_pair;
Pair<Int&!, Int[3]^> mutable_ref_and_owner;

A generic nominal initializer may omit repeated type arguments when the declared target supplies the same nominal type. _ creates an inference hole for only that argument:

Box<Int> value = Box(40);
Box<_> partial = Box(42);
Duo<Int, _> mixed = Duo(11, true);
Fixed<Int, _> fixed = Fixed([17, 18, 19]);

The compiler solves each hole from the declared type, initializer arguments, and generic constraints. Explicit arguments are never overwritten. Omitting all constructor arguments requires a compatible expected nominal type; this deliberately fails because neither side provides one:

_ value = Box(1); // error: generic initializer requires context

Value initializers and new owner initializers use the same inference rules.

Const generic parameters are compile-time values:

struct StaticCount<N: const Int> {
    Int value;
}

Int const_value<N: const Int>() {
    N
}

Const generic names are value-level names. They can be used in expressions, but not as type names. The inline form lowers to the same canonical predicate as a leading constraint:

@where(N: const Int)
struct CanonicalCount<N> {
    Int value;
}

@where(...) is a leading attribute:

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

Multiple constraints use commas:

@where(T: Hashable, U: Equatable)
Pair<T, U> make_pair<T, U>(T left, U right);

Intersection bounds use &:

@where(T: Hashable & Equatable)
UInt hash_key<T>(T value);

@alias(Name = Type) creates a local type alias for the current declaration. The alias is visible only in that declaration’s signature, constraints, members, and function body. It does not leak into the module namespace:

@alias(Item = Int)
@alias(Cmp = Fn<Bool, Item, Item>)
Bool compare_with(Cmp cmp, Item left, Item right) {
    cmp(left, right)
}

Leading attributes are applied in source order. Generic parameters for the current declaration are available before attributes are applied. Later attributes can use aliases introduced by earlier @alias attributes, but earlier attributes cannot use aliases introduced later. One @alias(...) can contain multiple comma-separated bindings. This is equivalent to splitting them into multiple ordered @alias attributes:

@alias(Items = T[]&, Cmp = Fn<Bool, T, T>)
@where(T: Hashable)
Bool contains_match<T>(Items values, Cmp cmp, T left, T right) {
    cmp(left, right)
}

Generic parameter equality:

@where(T == UInt8)
Int use_byte<T>(T value);

Use != to reject a concrete type shape:

@where(T != _^)
struct PlainSlot<T> {
    T value;
}

_ inside a type shape is an anonymous placeholder. It does not introduce a generic name; it only means that any type argument is accepted in that position. For example, _^ matches every owning pointer type, and Vector<_> matches a Vector with any element type:

@where(T == Vector<_>)
Int vector_only<T>(T value);

@where(T != _?)
struct NonOptional<T> {
    T value;
}

Use : for trait bounds. Use == or != for concrete type-shape matching:

@where(T: Sequence<Element = UInt8>)
Int count_bytes<T>(T value);

@where(T == _^)
Int box_only<T>(T value);

Negative trait bounds use ! before the trait name:

@where(T: !Mutable)
struct ImmutableOnly<T> {
    T value;
}

Projected associated type equality uses T.[Trait].Assoc == Type:

@where(T.[Sequence].Element == UInt8)
Int count_bytes<T>(T value);

Trait bounds can bind associated types:

@where(T: Sequence<Element = UInt8>)
Int count<T>(T value);

Use the built-in Mutable bound when a type parameter must carry a type-level write capability:

@where(T: Mutable)
struct Slot<T> {
    T value;
}

Slot<Int*!> slot;

Mutable currently matches T&! and T*!. Binding mutability such as Int value! is not a type argument and therefore is not checked by generic bounds.

Generic functions can return T@E in result position:

@where(T: Numeric)
T@ParseErr parse_number<T>(UInt8[]& text);

If the success value is optional, put ? on the success type:

T?@ParseErr parse_optional<T>(UInt8[]& text);
  • Generic type parameters accept types. Const generic parameters accept compile-time values with an explicit type, such as N: const Int.
  • @where(...) is written before the declaration, not after it.
  • Associated type projection constraints use the T.[Trait].Name == Type form.