Generics#
A type parameter stands in for a type the caller supplies. It is written in angle brackets after the name it belongs to, and it goes on functions, function types, and declarations.
local function firstOr<T>(items: {T}, fallback: T): T
if #items > 0 then
return items[1]
end
return fallback
endParameter positions#
A declaration takes parameters after its name, and every member may use them:
local record Box<T>
value: T
endAn alias takes them too, which is how a family of function types gets one name:
local type Handler<E> = function(event: E): booleanA function type carries its own, so a binding can be generic without a declaration standing behind it:
local mapper: function<A, B>(xs: {A}, f: function(A): B): {B}Type-pack parameters#
A binder ending in ... is a type-pack parameter. It preserves a heterogeneous sequence rather than choosing one element type:
local function forward<A...>(...: A...): A...
return ...
endOrdinary binders precede pack binders, and explicit pack arguments use parentheses to delimit one pack from the next. Those parentheses are type-pack syntax, not a tuple allocation:
local type Adapter<A..., R...> = function(A...): R...
local type PairAdapter = Adapter<(number, string), (boolean, integer)>Pack parameters work uniformly on aliases, records, interfaces, and functions:
local interface Source<R...>
read: function(self): R...
end
local record Values<R...> is Source<R...>
read: function(self): R...
endSee Type packs for list adjustment, correlation, and ownership rules.
Computed pack tails#
A computed tuple or array can supply a pack tail with unpackof, which is how a comptime function decides what arguments a call accepts:
local function Arguments(Kind: type): typepack
local info = nupp.types.describe(Kind)
if info.kind == "literal" and info.value == "pair" then
return nupp.types.pack({nupp.types.string, nupp.types.number})
elseif info.kind == "literal" and info.value == "flag" then
return nupp.types.pack({nupp.types.boolean})
end
return nupp.types.pack({}, nupp.types.any)
end
local function apply<Kind is string>(kind: Kind, ...: unpackof Arguments(Kind)): string
return kind
end
apply('pair', 'x', 1)
apply('flag', true)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}, and a concrete result of any other shape is rejected at the call.
Assembling a tuple#
The same operator composes a tuple from a head and a computed tail:
local type Prepend<Value, Values> = {Value, unpackof Values}When Values reduces to a tuple its slots are appended, and {never}, the array that cannot contain an element, contributes zero slots. See Type packs for the runtime operator this mirrors.
Rejecting a computed contract#
A comptime type function can construct and inspect complete packs, and nupp.types.error(message) rejects one with an authored diagnostic:
local function Checked(T: type): typepack
if T == nupp.types.string then
return nupp.types.pack({T})
end
return nupp.types.error("expected string")
endSee Comptime types for what a type function may compute and when it runs.
Constraints use is#
A bound is an interface, named after 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 reported at the instantiation:
NUPP2116: type argument integer for T: integer is not a NamedAn any argument skips the bound check, which is what keeps a gradual value usable in a bounded position. An interface named as a bound may carry a refinement, which is the test is against that interface runs.
Inference at a call site#
Type arguments come from the arguments:
print(firstOr({1, 2, 3}, 0)) -- T = integerInference 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.
Unification makes three decisions a partly-inferred call depends on:
- 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 its declared default, or to
anywithout one. AT = stringbinder no argument reaches isstring, and a binder with no default 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:
-- assert: function<T>(v: T?, msg: any?): T
local name: string? = maybeName()
local sure = assert(name) -- sure is stringSee Narrowing for the other route from T? to T.
Call sites take no explicit type argument#
f<number>(x) parses as two comparisons, exactly as it does in Lua:
local n = id < number > (1)
-- NUPP2003: cannot compare boolean and 1 with '>'Type arguments appear in type position, as in Box<number> and a.b.Map<K, V>, and at the six FFI intrinsics, which are special-cased in the grammar:
local p = ffi.new<Point>()
local q = ffi.cast<Point*>(address)
local t = ffi.typeof<Point>()
local ok = ffi.istype<Point>(v)
local n = ffi.sizeof<Point>()
local a = ffi.alignof<Point>()To pin a parameter that inference will not reach, annotate the binding instead:
local empty: {string} = {}Dive deeper
Nupp's grammar is Lua's grammar with types added, so f<number>(x) already has a meaning that programs rely on and cannot be reinterpreted. Disambiguating it would need unbounded lookahead or a rule about what may follow >, and both give a reader two readings of a line where Lua has one. Annotating the binding reaches every case an explicit argument would, and it puts the type where the value is instead of where the call is. The FFI intrinsics are special-cased because their argument is a C type that never appears as a value, so no comparison is being displaced. See Calling C safely for what those six do.
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.
Two applications of one generic compare member by member, with the usual read and write variance: Box<integer> is accepted where Box<number> is wanted only as far as a writable value field lets it, which is not at all, since Box<number> would write a float into the integer box. Readonly members read covariantly, so {readonly value: T} applications are covariant in T. An application that exposes no members of its own is opaque, and its arguments compare covariantly, since nothing can be written through it.
Dive deeper
Mutable arrays likewise preserve their element type: {integer} does not fit a mutable {number}, which could write a fractional value into it. A const {number} parameter accepts the integer array for reading. A fresh array literal takes its context's element type because no earlier alias observes a narrower type. A generic declaration states each member's capability, so its variance falls out of the members rather than needing an annotation.
self#
self is a per-declaration type parameter, rebound to the actual receiver:
local record Counter
value: number
function increment(self, by: number): self
self.value = self.value + by
return self
end
endA 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:
local record Key<T>
end
local record Store
__index: function<T>(self, key: Key<T>): T
__newindex: function<T>(self, key: Key<T>, value: T)
end
local store: Store
local nameKey: Key<string>
local name: string = store[nameKey]
store[nameKey] = "saved"T is inferred from Key<T> for both the read and the write.