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
end

Parameter positions#

A declaration takes parameters after its name, and every member may use them:

local record Box<T>
    value: T
end

An alias takes them too, which is how a family of function types gets one name:

local type Handler<E> = function(event: E): boolean

A 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 ...
end

Ordinary 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...
end

See 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 comptime 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 comptime function Checked(T: type): typepack
    if T == nupp.types.string then
        return nupp.types.pack({T})
    end
    return nupp.types.error("expected string")
end

See Comptime types for what a type function may compute and when it runs.

Constraints use is#

A bound is an interface, named after is:

local interface Named
    name: string
end

local record Registry<T is Named>
    entries: {T}
end

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 Named

An 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 = integer

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.

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.
  • any and nil arguments do not bind a parameter. They leave it open.
  • An unbound parameter substitutes to its declared default, or to any without one. A T = string binder no argument reaches is string, 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 string

See 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
end

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:

local record Key<T>
end

local record Store
    metamethod __index: function<T>(self, key: Key<T>): T
    metamethod __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.