Intersections#
A & B describes a value that satisfies both types. An intersection is structural and erased at run time, so it composes capabilities at a boundary without a declaration that names the combination.
local type Identified = {
readonly id: integer
}
local type Labeled = {
readonly label: string
}
local type Item = Identified & Labeled
local function describe(item: Item): string
return tostring(item.id) .. ": " .. item.label
endAn interface is the other way to combine two contracts. It gives the combination a name that implementors declare, where an intersection states the combination at the one place that needs it.
Normalization#
& binds more tightly than |, so an intersection groups before a union does:
local type HasCode = {
readonly code: integer
}
local type HasMessage = {
readonly message: string
}
local type Timeout = {
readonly timedOut: true
}
local type Failure = Timeout | HasCode & HasMessageA Failure is a Timeout, or a value carrying both a code and a message.
Nested intersections flatten, duplicate members disappear, and member order does not affect identity, so A & (B & A) and B & A are one type. unknown and gradual any add no constraint. never makes the whole intersection never, since nothing can satisfy a member no value inhabits. See Primitive types for what never means elsewhere.
Capability composition#
An intersection exposes capabilities from every member. A readable property available through several members has the intersection of their read types, and a writable property accepts the union of the types its constituent views accept:
local type NarrowRead = {
readonly value: string
}
local type WideWrite = {
writeonly value: string | integer
}
local type Cell = NarrowRead & WideWriteRead-only and write-only views stay independent, and member completion contains the union of the available names. See Property capabilities for how the two directions compare.
The same composition applies to methods, property indexers, and metamethod contracts. Method receiver specialization preserves every callable member.
Subtyping#
A value fits A & B only when it fits both A and B. An intersection fits a target when one member already proves the target, or when the members jointly provide the target's structural surface:
local type A = {
readonly a: number
}
local type B = {
readonly b: string
}
local function combined(value: A & B): {
readonly a: number,
readonly b: string
}
return value
endFunction parameters remain contravariant and result packs remain covariant. See Type packs for how a result pack is compared.
Provable emptiness#
Nupp reports a written intersection when it can prove that no value can satisfy it. Distinct primitive runtime categories, distinct literals, distinct concrete nominal identities, unions whose every arm is disjoint, and incompatible required fields are all proofs:
local type Impossible = string & number
local type ConflictingTags = {
kind: 'file'
} & {
kind: 'socket'
}Two records have distinct nominal identities, so Circle & Square is empty however similar their fields are. Interfaces, any, unknown, and unsubstituted type parameters prove nothing about disjointness.
Dive deeper
The check is deliberately one-sided: it reports only what it can prove, and stays quiet everywhere else. An interface is satisfied structurally by any value carrying its members, so two interfaces with unrelated members have a perfectly ordinary implementation that the compiler has not been shown, and a type parameter has whatever inhabitants its eventual argument has. Reporting those as empty would make a generic library that intersects its own parameters unwritable, and the failure would arrive at the declaration rather than at the instantiation that actually conflicts. The cost is that a genuinely empty intersection between two interfaces stays silent until a value is required and no value fits.
Overload selection#
An intersection containing only function types is an overload set, and a call selects the single member that accepts the argument pack:
local type Parse = function(text: string): integer & function(text: string, base: integer): stringThe checker infers the argument pack once, probes every member without moving affine arguments or establishing borrows, and applies the one member that survives. There is no best-match ranking, declaration order never breaks a tie, and selection never adds runtime dispatch. A call is reported when no member accepts the pack, and reported differently when several do.
Everything else about overloading has a page of its own. See Overloads and overrides for repeated method bodies, generic entries, constructors, @override, and the diagnostics each of them reports.
FAQ#
Should this be an intersection or an interface?#
Write an interface when the combination has a name worth declaring and implementors should claim it with is. Write an intersection when one function needs two contracts at once and nothing else in the program cares about the pair.
Does an intersection cost anything at run time?#
No. Intersections are erased, exactly as the rest of the structural type layer is, so A & B lowers to whatever the underlying value already was. See Type system for what does and does not survive to run time.
Why is intersecting two records empty?#
A record is nominal, so a value comes from one declaration or another and never from both. Intersect the interfaces those records declare instead, or declare a third record carrying the members you need.