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.