Interfaces#
An interface names a set of members that any type carrying them satisfies. The declaration emits nothing at run time unless it supplies a default implementation.
local interface Named
name: string
end
local record Circle
name: string
radius: number
end
local n: Named = new Circle(name = "c", radius = 1)Satisfaction is structural#
A type satisfies an interface by carrying its members. No declaration is required, and a plain table shape works the same way:
local n: Named = {name = "anonymous"}A member the interface implements with a body counts as one of its members here. The body is installed only on a declaration that names the interface with is, so a shape or an undeclared record has to carry that member itself to satisfy the interface, and one that lacks it is refused rather than left to find nothing at the call.
Members and indexers may be declared readonly and writeonly independently, which controls both access and variance. See Property capabilities for what each capability admits.
Sealed interfaces#
sealed closes structural satisfaction when an interface is a trust boundary:
sealed interface span.Span<T>
readonly count: integer
get: function(self: Span<T>, index: integer): T
endOnly a record, struct, or child interface declared with is span.Span<T> in the same module may satisfy that contract. A type in another module is rejected even when it has the same fields, and it cannot add the is claim itself. The owning module can therefore keep the representation private and export only constructors returning the interface.
Sealing is entirely static. It emits no tag, wrapper, virtual dispatch, or runtime test. It earns its place when the visible methods rely on facts that mere field shape cannot prove, such as a pointer agreeing with a count. An any value remains gradual and can cross the boundary with the usual loss of static guarantees.
When comparing member functions that both declare a receiver -- a leading self, or a first parameter typed as the declaring type -- the receiver is skipped on both sides, since each implementation names it for itself. A member without one is a plain callable field, and its first parameter is compared like any other: a plain field does not satisfy a method member, whose receiver would land in that parameter.
is is a claim, not a proof#
is does two things. It inherits the parent's members and metamethods, with self rebound from the parent to the child. And it declares satisfaction.
What that declaration is trusted for is exactly the members the record does not write. A record declaring is Component satisfies Component even if a runtime registrar has not installed the members yet. It is the same trust boundary as a declaration file or an FFI signature. If nothing ever installs them, the program still fails at run time.
Every member the record does write is held to the parent's, at the declaration, and a conflict is NUPP2118:
- A field or method has to fit the parent's member.
name: integerdoes not satisfyname: string, anddescribe: integerdoes not satisfy a method. The comparison is covariant: a record may narrow a parent's field, which is how a tagged interface refineskind: stringtokind: "circle", and a method is compared the way any implementation is, with the receiver skipped and a narrower result allowed. - A generic parent is compared as instantiated.
record StrSink is Sink<integer>cannot declarepush(self, v: string). - A capability the record withholds is withheld.
readonly valuebeside a parent whosevalueis writable is a conflict, not a field the parent's write entry quietly reopens, and the record's own writes to it are refused as well. - A stored field cannot stand where the parent provides a default method, whatever its type: on an instance the field would shadow the default. Replacing a default is written as a method with
@override. - An affine parent's terminal takes its receiver, so implementing
close(takes self)asclose(self)is refused. - A method implementing a member the parent declared
nosuspend functionmay not suspend. A method's own guarantee is inferred from its body, so this is checked once the file's effects have settled, and only a proven suspension is reported: a body whose calls cannot be followed keeps the claim, as a bodyless declaration would. Anosuspendregion asks more of what it calls, and says so at the call.
A struct is held to one more thing. Its layout is closed, so a parent member no struct field can hold, such as name: string, cannot be installed later either, and the claim is refused where a record's would be trusted.
Only interfaces may be named after is, and anything else is reported. Multiple parents are allowed:
local record Task is Named, Callable
endBounded generics#
An interface is the usual bound for a type parameter:
local function start<T is Callable>(task: T): T
return task()
endInside the body, fields, methods, and metamethods are read from the bound, and self specializes back to the type parameter. Bounds are checked where a generic is instantiated, not inside the subtyping relation. See Generics for inference and the rest of the bound rules.
is at runtime#
The is operator tests a value's type:
It compiles for nil, the primitives, function types, records, and structs. A record reaches its declaration through __index, so a value the declaration built answers yes whether it was stamped directly or linked back to; a struct uses ffi.istype. x is integer compiles to type(x) == "number", because integrality is not checked at run time.
Tests that do not run#
A test the subject's own type settles does not run at all:
local maybe: Shape? = pick()
if maybe is Shape then -- compiles to `maybe ~= nil`
use(maybe)
endc is Shape where c is a Circle and Circle is Shape is true by the declaration, and an optional's nil is the only part left to ask. This works whatever the interface can or cannot test at run time, and it is the usual reason is against an interface succeeds.
Because it trusts the type, an is in the tail of an exhaustive chain over a union is answered by what the earlier branches ruled out rather than re-checked. That is the same trust the checker already extends when it lets a branch read the narrowed type's fields without a test, so a value that reached the union through an as it did not deserve is answered by the cast, not by is.
Tags an interface already declares#
Where the subject's type does not settle it, an interface has no runtime table of its own. An interface whose fields carry literal types has already said what its test is, since the field admits that value and nothing else:
local interface Circle
kind: "circle"
radius: number
end-- `x is Circle` becomes
(type(x) == "table" and x.kind == "circle")That is what lets a decoded table answer is. It applies to interfaces only: a record and a struct already answer exactly, so a derived test beside either would be a second answer chosen by whether the fields happened to be literals.
Refinement tests#
A satisfies declaration states the test outright, and it wins over the tags when both are present. With neither, and against an alias, there is nothing to test, and code generation refuses it.
Dive deeper
An interface emits no runtime table, so is against one is answered by static elision, by a tag its own literal-typed fields already declare, or by a satisfies test it names. Registering conformance on every declaration was designed and verified, then declined: elision and tags already answered every case except an untagged interface against a subject whose type does not prove it, which is one case weighed against a runtime table on every interface in the language.
Default implementations#
An interface may implement what it declares, and a declaration that takes the contract takes the behavior with it:
const Greeter = {}
function Greeter.greet(self)
return "hello, " .. self.name
end
const Person = {} Person.__index = Person Person.greet = Greeter.greetThe body is emitted once and referenced, not copied. It is resolved where the implementor is written rather than looked up at run time, so there is no chain and no indirection. A struct takes it through its metatype's index table, and a chain of interfaces passes it along.
This is the one thing that gives an interface a runtime presence. An interface declaring only signatures still emits nothing at all, so the table is paid for by the feature rather than by every interface. It is also why an interface carrying defaults has to be reachable from an implementor in another module.
Two interfaces providing the same name is refused. They are two implementations and no reason to prefer either, so the declaration writes the member itself to say which behavior it means.
Replacing a default#
@override is required on a member that replaces an inherited default, and is equally an error on one that replaces nothing:
local record Shouter is Greeter
name: string
function greet(self): string
return "HELLO, " .. self.name
end
endThat catches a misspelled name that would define a new method and a later interface default that would shadow an implementor's method.
For an overloaded default, replacement is matched by parameter pack rather than source name alone. Each repeated method body is a separate entry, so a record may @override one signature and continue inheriting the others, and no @overload annotation is needed because repeated names form the overload set. See Overloads and overrides for complete examples, including bodyless contracts and defaults contributed by separate interfaces.
Metamethod contracts#
An interface or record may declare how an operator behaves on it:
local interface Component
componentName: string
__call: function(self, ...: any): self
end
local record Position is Component
x: number
y: number
endThe declaration is a static contract. It emits no __call field, builds no metatable, and decides nothing about what the call constructs. Ordinary Lua code installs the function with setmetatable, a registrar, or a foreign runtime. Inheriting the contract rebinds self, so Position(...) has type Position.
__eq and __tostring participate in conformance but do not change a result type: == is always boolean and tostring is always string. When the left operand has no contract, the checker consults the right one, matching Lua's fallback direction, and the declared parameters still describe the actual left and right values, so a right-hand-only contract has to type both positions. See Metamethods for the declarable set and the parameters each one takes.
Structs cannot declare metamethod contracts, because LuaJIT metatypes must be installed when ffi.metatype is called and an erased promise would leave no later fulfillment point. The bit-operator contracts (__band, __bor, __bxor, __bnot, __shl, __shr, __sar) and __idiv are rejected because the LuaJIT 2.1 backport does not dispatch them. __gc and __close are not contracts either, since deterministic cleanup is ownership.
FAQ#
When is an interface better than a record?#
When several unrelated types must answer the same contract, or when a module wants to publish a shape without publishing its representation. A concrete record API needs no interface merely to call its own methods. See records and structs for which representation a type wants.
Why does is against an interface usually compile to nothing?#
Because the subject's declared type has already answered it. An interface has no runtime table, so the checker elides the test wherever the type proves it, and falls back to a literal-field tag or a satisfies refinement where it does not.
Can an interface add fields to the types that satisfy it?#
No. An interface declares members and, optionally, default implementations for its own methods, but satisfaction is a fact about the implementor's own members. A default implementation is one shared function referenced from the implementor, not a field copied into it.