Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Generics and constraints

Functions, classes, enums, and methods can declare type parameters. They also accept value parameters, such as const N: i64, for compile-time integer and boolean specialization.

Type parameters are written as follows:

def identity[T](value: T) -> T:
    value

def add[T: IntType](a: T, b: T) -> T:
    a + b

Concrete uses preserve ordinary type and ownership rules. Generic bodies are checked for each used concrete instance. Calls remain statically dispatched; generic syntax adds no runtime type lookup or allocation. The toolchain documents specialization limits and caching.

Functions and methods accept unconstrained parameters, IntType, FloatType, CopyType, KeyType, Dimension, structural protocol constraints, and callable constraints. Dimension accepts compile-time dimensions, which have no values. IntType accepts the eight fixed-width integer types and FloatType accepts f32 and f64, including aliases; each is a constraint rather than a value type. No constraint admits both integers and floats; an unconstrained parameter does, because a body is checked for each instance. Callable capabilities follow functions.

KeyType

KeyType accepts exactly the supported dictionary keys and set members: integers and their supported domains, bool, str, bytes, and tuples, classes, enums or anonymous sums whose every field, payload or member is a supported key type. Floats, collections, boxes and recursive types remain excluded. Custom cleanup is allowed, but copying operations still reject such keys. It is a built-in constraint, not a protocol or a stored value type; no __hash__ method is needed or accepted.

Functions, methods, classes and enums can declare K: KeyType. Explicit and inferred arguments are checked against the same admission rule as dict[K, V] and set[K]. This makes the requirement visible and reports unsuitable arguments at the generic boundary, even when its body does not hash anything. Generic bodies remain checked per concrete instance: an unconstrained K can still be used with collections when its concrete type is valid.

def distinct[K: KeyType](values: list[K]) -> Result[set[K], AllocError]:
    mut result: set[K] = set()?
    for value in values:
        result.add(value)?
    Ok(result)

def main() -> Result[(), AllocError | IoError]:
    print(len(distinct(["red", "blue", "red"]?)?))?
    Ok(())
2

KeyType implies neither CopyType nor eligibility for explicit copy(). The loop consumes its input and transfers each key, so it also works for keys with custom cleanup. A generic body that copies keys or calls non-mutating set algebra is rejected when specialized with such a key type.

def accept[K: KeyType](value: K) -> ():
    pass

def main() -> ():
    accept(1.5)
f64 does not satisfy KeyType

Finding an unsupported key field

When a collection or KeyType constraint rejects a structured key, the diagnostic follows the first unsupported component in declaration order. It names class fields, enum variants and payload positions, and tuple element positions. Positions start at one. For example, a class field sample whose second tuple element is f64 reports field sample -> tuple element 2: f64 is not a supported key type. Unsupported sum members are named in the diagnostic path. This applies to type annotations, inferred collection literals and conversions, and explicit or inferred generic arguments.

CopyType

CopyType accepts every type argument that copies under the copy rule: numbers, bool, str, bytes, ranges, NonZero, unit (), named function values, tuples, enums, and anonymous sums whose parts copy, and @copy classes whose type arguments copy. Closures, collections, boxes, and other classes do not satisfy it. Type arguments are never references, so &T never satisfies it; a &T parameter borrows a T that does.

def twice[T: CopyType](value: T) -> (T, T):
    (value, value)

def main() -> Result[(), IoError]:
    print(twice(3), twice("a"), twice((1, Some(2.5))))?
    Ok(())
(3, 3) ("a", "a") ((1, Option[f64].Some(2.5)), (1, Option[f64].Some(2.5)))
def twice[T: CopyType](value: T) -> (T, T):
    (value, value)

def main() -> Result[(), AllocError]:
    pair = twice([1]?)
    Ok(())
list[i64] does not satisfy CopyType: it moves on ordinary use

A body is checked per instance, so twice with an unconstrained T also compiles for these calls. The bound states the requirement in the signature and reports a moving argument at the call. Functions, methods, classes, and enums accept it.

Argument inference determines parameters from inputs or a complete explicit argument list. Generic data and methods retain nominal identity. Protocols describe exact structural interfaces without inheritance or registration.

Generic function type arguments can be unit (), including when inferred from Result or Option payloads. Substituted signatures and bodies retain ordinary unit restrictions: unit can be a return type or built-in enum payload, but not a direct function parameter or stored collection element. A &T or &mut T parameter instantiated with () borrows unit, so a helper such as std.test.is_err accepts a Result[(), E]. Explicit calls and specialized function values also accept ().

Generic function type arguments cannot be references. A signature can borrow T with &T or &mut T. A callable-constrained parameter can borrow a concrete closure while keeping its capture loans live across the call. Data and protocol arguments have their additional restrictions on their own pages.

Diagnostics from instances

A generic body is not checked where it is declared. Each used instance is checked once, and an error found in it is reported at the use that first required the instance, in code that is not itself a generic instance. A note gives the position in the definition, the instance being checked, and the reason:

main.plenty:7:9: cannot instantiate `outer[i64]`: its definition is rejected
  pairs.plenty:5:13: note: in instantiation `pairs.scaled[i64]`: unknown binding `pairs.factor`

The first line names the instance the use asked for. The note names the instance whose definition failed; the two differ when generic code requires further instances, and the instances between them are not listed. Further notes belong to the reason. Names longer than 96 characters are cut short.

When the body is rejected only because of a type argument, the first line names that argument instead. Three causes are recognized: an implicit copy of an affine argument (moving it out of a field, an element, or a reference, or using a moved binding of it), an operator the argument does not support, and a method the argument does not have:

main.plenty:8:9: `outer[list[i64]]` requires `pairs.twice[list[i64]]`, which copies `T`, but `list[i64]` cannot be copied
  pairs.plenty:2:13: note: in instantiation `pairs.twice[list[i64]]`: use of moved binding `value`
  pairs.plenty:2:6: note: `value` is moved here

The note then describes the rejected operation without advice for changing the body, which the caller may not own. A cause is attributed when the rejected value comes from a type parameter and its type is exactly one parameter’s argument. Equal concrete types alone do not establish that origin: an inferred row = [1]? is not a T value merely because T = list[i64]. Conversely, an unrelated concrete annotation does not hide a failure on an actual T.

The diagnostic tracks parameter origins through local aliases, borrows, collection displays, and field or element projections whose declared shape identifies the parameter. Projecting inside an opaque T does not establish the origin of its contents. Attribution is conservative: mixed assignments, untracked transformations such as call results, or two parameters with the same concrete argument retain the first form. These rules affect diagnostic wording only, not which programs are accepted.

This covers generic functions, generic methods, functions written inside them, and the methods of a generic class or enum instance. A method of an instance is checked when it is first used: called, including by a constructor call for __init__, or required by a protocol bound that the instance satisfies. A __del__ method is checked when the instance is first required, because constructing a value arms it. An instance can therefore exist although one of its methods would be rejected for it, as long as that method is not used:

main.plenty:9:13: `Cell[list[i64]].get` copies `T`, but `list[i64]` cannot be copied
  main.plenty:5:9: note: in instantiation `Cell[list[i64]].get`: cannot move out of a field

A method required through a protocol bound is reported at the bounded use, naming the bounded instance, such as accept[Holder[Guard]]. Unused methods are not compiled for the instance either.

Since bodies are checked only per instance, a body error that no type argument causes, such as an unknown name, is reported in the first form, and not at all while the declaration is unused. Errors in the declaration itself (type parameters, constraints, signature types) and uses that do not satisfy a constraint are reported at one location. Instance field types, constraints on data arguments, and substituted method signatures are checked when the instance is first required.

Checking every method

Checking per use keeps compilation fast while code changes, but a library’s tests then check a method for a type argument only if they use it. The check modes accept --check-all-methods (plenty --check, --check-module, and plenty check): after the used methods, every remaining method of every class and enum instance the program creates is checked as if it were called. Such an error has no use, so it is reported where the program first required the method’s type, such as a constructor call or a type annotation, with a note naming the flag:

main.plenty:8:9: `Cell[list[i64]].get` copies `T`, but `list[i64]` cannot be copied
  main.plenty:4:9: note: in instantiation `Cell[list[i64]].get`: cannot move out of a field
  note: `Cell[list[i64]].get` is checked because of --check-all-methods; the program does not use it

The flag checks only types the program’s own package declares: the entry file’s directory (or --root) for plenty --check, and the package’s src/ for plenty check. Generic types from std and from dependencies keep per-use checking, because their authors chose which methods each instance supports. LruCache[K, V].get copies V, for example, so an LruCache[str, list[i64]] is accepted as long as the program does not call get. An own type is still checked when its type arguments come from std.

The flag checks only instances: a generic class or enum with no instance stays unchecked, since its bodies are checked with concrete type arguments and there are none. Compiling does not accept the flag, so it never changes generated code or the compile cache.