Generics#
Type parameters go on functions, function types, and declarations.
A binder ending in ... is a type-pack parameter. It preserves a heterogeneous sequence rather than choosing one element type:
Ordinary binders precede pack binders. Explicit pack arguments use parentheses to delimit one pack from the next; those parentheses are type-pack syntax, not a tuple allocation. Pack parameters work uniformly on aliases, records, interfaces, and functions. See Type packs for list adjustment, correlation, and ownership rules.
A computed tuple or array can supply a pack tail with unpackof:
The inverse operation is available in tuple match patterns. A final unpackof infer Tail captures every slot after the fixed prefix, preserving a heterogeneous tuple (and binding {never} when that suffix is empty):
Expansion happens after inference and finite type reduction. A tuple contributes fixed slots, an array contributes a homogeneous rest tail, and an undecidable result becomes ...any. The trailing comma distinguishes the one-slot tuple {T,} from the array {T}. A concrete result of any other shape is rejected at the call.
Computed tuples can be assembled recursively with the same operator inside a tuple: {Head, unpackof Tail}. When Tail reduces to a tuple, its slots are appended; {never}, the array that cannot contain an element, contributes zero slots. This permits a recursive reducer to build a parameter list without an arbitrary arity ladder.
typeerror<Message> is the failure result for a type-level computation. Its message must become concrete before a consumer such as unpackof needs the result. The consumer reports that message directly instead of exposing the intermediate type used to carry it:
Constraints use is#
Inside the body, the parameter's fields, methods, and metamethods are read from its bound, with self specialized back to the parameter.
Bounds are checked where a generic is instantiated, not inside the subtyping relation. Violating one is NUPP2116:
NUPP2116: type argument string for T: string is not a numberAn any argument skips the bound check.
Refinements#
An interface may carry a matches block, which names the runtime test that decides whether a value is one of these. x is T compiles to that test:
-- `s is Circle` becomes
(type(s) == "table" and s.kind == "circle")Only an interface. A record is identified by the metatable new stamps and a struct by its ctype, so both already answer is exactly — a refinement beside either would be a second answer to a settled question, and which answer is R gave would depend on whether a body happened to carry one. An interface has no runtime table at all, so this is the only identity it can have.
That is also what lets an interface answer is for values this program did not build — a table off a decoder, or anything an untyped library returned. Such a value never received a metatable, so nothing else could identify it.
The test has to run wherever is is written, so it reads the declaration's own fields through self and nothing else: comparisons against literals, type() tests, and and / or / not. A call, arithmetic, or a name from outside the subject is NUPP2122, and so is a refinement that always answers the same way — always true identifies every value, always false leaves the type uninhabited.
A subject that is not a plain name is evaluated once and handed to the test, since a refinement may read it more than once. Reaching through a field guards the step before it with ?., because the test runs against values that are not of the type yet — matches self.a.b.c == "x" end compiles to s.a?.b?.c == "x".
A declaration is held to the refinements of the interfaces it declares. record C is Shape is a claim the checker proves, and Shape's refinement is what is Shape runs, so fields that provably fail it are NUPP2122 — the alternative is a value the checker calls a Shape and is calls otherwise.
Inference at a call site#
Type arguments come from the arguments:
Inference is structural unification over parameters against argument types. It sees through arrays, tuples, maps, unions, shapes, function types, pointers, and nominal applications, and it strips ownership wrappers first.
Three behaviours are worth knowing:
- A binder appearing twice unions the two arguments rather than failing or picking the more specific one.
anyandnilarguments do not bind a parameter. They leave it open.- An unbound parameter substitutes to
any, which keeps a partly-inferred call gradual instead of wrong.
A T? parameter subtracts the concrete members from the argument, so the residue binds. That is how assert is typed:
There is no explicit type argument at a call site#
f<number>(x) parses as two comparisons, exactly as it does in Lua:
Type arguments appear in type position — Box<number>, a.b.Map<K, V> — and at the six FFI intrinsics, which are special-cased in the grammar:
When you need to pin a parameter that inference will not reach, annotate the binding instead:
Instantiation#
Box<number> is one type everywhere — instantiations are memoized, and the cache is populated before members are filled in, so a self-referential generic terminates.
Generic nominals are covariant in every argument, so Box<integer> is accepted where Box<number> is wanted. Like array covariance, this is deliberately unsound for mutable contents and chosen for compatibility.
self#
self is a per-declaration type parameter, rebound to the actual receiver:
A subtype inheriting a self-returning contract gets its own type back rather than the declaring type. This is what makes an inherited metamethod __call: function(self, ...): self return the concrete record.
Generic metamethods#
A metamethod contract may carry its own type parameters, which lets a typed key determine the result of an index:
T is inferred from Key<T> for both the read and the write.