Associated types#
An interface may state a type it does not name. Whatever takes the contract names it, and the name is reached through the value that answered it.
local interface Reader
associated type Item
read: function(self): self.Item?
end
local record Lines is Reader
associated type Item = string
handle: LuaFile
endLines.Item is string. A function generic over readers reads it back through the type parameter:
local function collect<T is Reader>(source: T): {T.Item}Declaration kinds#
Where the member is written, and which operator it uses, is the whole of what it means.
| Where | Written | Means |
|---|---|---|
interface |
associated type Item |
a requirement |
interface |
associated type Item is Bound |
and what may answer it |
interface |
associated type Item = T |
an overridable default |
interface |
associated type Item == T |
a fixed equality |
| record or struct | associated type Item = T |
an answer |
== is refused outside an interface, because a concrete declaration already answers exactly with =. A requirement is refused inside one, because nothing inherits from a record and nobody could answer it.
Defaults and fixed equalities#
A default is a fallback. An implementor may answer otherwise, so a value known only as the interface cannot be said to answer it, and the projection stays opaque there:
local interface Holds
associated type Value = string
end
local record Otherwise is Holds
associated type Value = integer
end
local assumed: string = nil as Holds.Value -- refused: Holds.Value is opaque
local known: integer = nil as Otherwise.ValueA fixed equality is a promise the contract makes, so it resolves through the contract, and every implementor answers exactly it:
local interface Fixes
associated type Value == string
end
local settled: string = nil as Fixes.Value -- resolvesThat is the distinction the feature exists for. A base contract keeps an overridable default while a derived one fixes it:
local interface Component
componentId: integer
associated type Value = self
end
local interface ScalarComponent<E> is Component
componentId: integer
associated type Value == E
endComponent hands every implementor the answer "itself" without any of them being edited, and ScalarComponent<number>.Value is provably number, even for a value typed only as the interface.
Answering a requirement#
A record or struct answers by writing the name with =:
local interface Codec
associated type Encoded
associated type Decoded
end
local record JSON is Codec
associated type Encoded = string
associated type Decoded = any
endOne answer satisfies every contract that asked for that name, and has to fit every bound they gave. An explicit answer replaces an inherited default with no @override, because a type member has no body to replace.
A default that survives is copied to the implementor, with self rebound there, so associated type Value = self on the contract reads as the implementor:
local interface Holds
associated type Value = self
end
local record Node is Holds
tag: string
end
local itself: Node = nil as Node.ValueLeaving a requirement unanswered is reported, as is answering otherwise than a == fixes it, and so are two contracts defaulting it differently. None of those leaves one answer to take. Answering a name no contract declares, restating a bound, or stating a requirement outside an interface is reported too, and so is colliding with a nested alias or declaration; a field may still carry the same name, since fields and types are separate namespaces.
Associated types are not nested aliases#
A declaration body may also hold a plain type alias, and the two are different members:
local interface Shape
type Unit = number -- a static alias
associated type Scale -- a requirement
size: Unit
endUnit is lexically scoped, reachable from outside as Shape.Unit, and not inherited, so a declaration taking Shape cannot name it. Scale is the opposite on every count.
Dive deeper
associated type is a separate word from a nested type alias because they are different things. An alias is a static namespace member resolved where it is written, and an associated type is a contract member answered per implementor. Giving both one word would have changed the meaning of every existing alias the moment its declaration was inherited.
The workaround this replaces was writing the value type as a parameter of the bound, which produces no diagnostic and no information, because bounds are checked at instantiation rather than solved.
Reaching an associated type#
A projection reaches an answer through a concrete declaration by path, through a type parameter, or through the receiver:
Inside an interface body the name is never bare. self.Item is required, because what it stands for varies by implementor. In a declaration that answered it, the bare name resolves, because answering it makes it an alias like any other.
A projection that names nothing is reported: an unbounded binder has no contract to project through, a bounded one may not state the name, and a union states it only when every alternative does. A projection takes no type arguments.
Opaque projections#
A projection whose head is a contract stays opaque, and that is a normal form rather than a failure. It fits its effective bound, and reads that bound's members specialized to the projection, so a self-returning member answers T.Item:
local interface Cloneable
clone: function(self): self
end
local interface Copies
associated type Item is Cloneable
end
local function twice<T is Copies>(item: T.Item): T.Item
return item:clone():clone()
endThe direction matters. T.Item fits Cloneable; Cloneable does not fit T.Item, because an upper bound cannot manufacture the answer.
Through an intersection the requirements coalesce and their bounds intersect, so one answer satisfies every contract. Through a union every alternative has to state the name, the bounds unite, and the answers distribute: (A | B).Item is A.Item | B.Item when both resolve.
Structural values cannot answer#
An interface carrying associated requirements is nominal at that part. Members can still be satisfied by shape, but an answer is a type, nothing registers one later, and a structural value has nowhere to put it:
local interface Holder
count: integer
associated type Item
end
local lookalike: {
count: integer
} = {count = 1}
local held: Holder = lookalike -- refused: it answers nothingA declared is edge is trusted for members and still proves the answers, for the same reason. See Interfaces for what that edge does and does not check.
Runtime cost#
An associated type is erased exactly as a type parameter is, and an interface that adds only associated types emits nothing. That has three consequences worth knowing.
Reified positions need a resolved answer#
A reified position, meaning nupp.sizeof, layoutof, a struct field, or a fixed array, needs a representation, so a projection is legal there only once it resolves to a reifiable type. An opaque one is refused by the ordinary reification error. An array asks about its element and a pointer does not ask about its pointee, so an incomplete pointee is still fine.
Refinements need every requirement fixed#
A satisfies refinement is a runtime test, so a contract that leaves an associated type unsettled cannot carry one: an implementor may answer otherwise and the test cannot tell. Fixing every requirement settles it, including inherited ones. associated type Item == any is fixed and still settles nothing, because there is nothing for a test to check.
Cycles report once per component#
A cycle is reported once per component, wherever it is entered from. A cyclic default stays latent on the interface that states it and surfaces on the first concrete implementor.
Gradual projections#
A projection whose head inference never worked out is checked as any, which is the feature declining to say anything rather than saying the call is right. That is reported by the gradual-projection lint, once per call and member, where the erasure happened:
local erased = collect(nil as any) -- warning: gradual-projectionAn answer somebody wrote as any is a different thing and does not warn. Suppress the lint with @allow("gradual-projection") like any other. See Lints for the suppression rules.
Parameters are chosen by the caller#
Reader<T> says the same thing until you try to use it. A parameter is an input the caller chooses, so nothing stops one declaration from taking Reader<string> and Reader<integer> both, and collect(source) then has nothing to infer T from. An associated type is an output the implementor chooses, so it is a function of the argument and inference resolves it.
Parameters also propagate: every function that touches a reader carries the parameter whether or not it mentions the element type. An associated type stays where it was declared.
The rule is which side chooses. When the caller chooses, write a parameter. See Generics for how a bound is written.
FAQ#
Where does an associated answer live?#
A contract states a name, an implementor answers it, and callers project through a bound parameter. The answer lives in the declaration itself, so there is no separate implementation block or competing answer to resolve.
Should I write = or == on an interface?#
Write = when an implementor may reasonably answer otherwise, and == when every implementor has to answer exactly that. Only == lets a value typed as the interface read the projection, which is what a caller holding the contract rather than the concrete type needs. See Defaults and fixed equalities.
Why is my table literal refused where the interface is wanted?#
An interface with an associated requirement is nominal at that part, and a table literal has nowhere to record an answer. Declare a record with is and answer the requirement there. See Structural values cannot answer.