This RFC proposes sharing self
types between method definitions which share a metatable.
Currently, metamethods are type-inferred independently, and so give
completely separate types to self
. This has poor ergonomics, as type
errors for inconsistent methods are produced on method calls rather
than method definitions. It also has poor performance, as the independent
self types have separate memory footprints, and there is idiomatic code with
exponential blowup in the size of the type graph.
For example, Point
class can be simulated using metatables:
--!strict
local Point = {}
Point.__index = Point
function Point.new()
local result = {}
setmetatable(result, Point)
result.x = 0
result.y = 0
return result
end
function Point:getX()
return self.x
end
function Point:getY()
return self.Y
end
function Point:abs()
return math.sqrt(self:getX() * self:getX() + self:getY() * self:getY())
end
Currently, this code is problematic, since there is no connection between the types
of the Point
metamethods. For example, the inferred type for Point.getX
is <a>({ x : a }) -> a
, rather than the expected (Point) -> number
.
Even worse, the method Point.abs
does not type-check, since the type
of self.x * self.x
is unknown. If Luau had subtyping constraints and type families
for overloaded operators, the inferred type would be something like:
type PointMT = {
new : () -> Point,
getX : <a>({ x : a }) -> a,
getY : <a>({ y : a }) -> a,
abs : <a, b, c>(a) -> number where
a <: { getX : (a) -> b, getY : (a) -> c }
Add<Mul<b, b>, Mul<c, c>> <: number,
}
type Point = {
x : number,
y : number,
@metatable PointMT
}
but this type is not great ergonomically, since this type may be presented to users in type hover or type error messages, and will surprise users expecting a simpler type such as:
type PointMT = {
new : () -> Point
getX : (Point) -> number,
getY : (Point) -> number,
abs : (Point) -> number
}
type Point = {
x : number,
y : number,
@metatable PointMT
}
This is the type inferred by shared self types. Rather than
inferring the self
type separately for each metamethod declared on a
table, and for each use of setmetatable
the same type is used.
Unfortunately, while this change is fairly straightforward for
monomorphic types like Point
, it is problematic for generic classes
such as containers. For example:
local Set = {}
Set.__index = Set
function Set.new()
return setmetatable({
elements={}
}, Set)
end
function Set:add(el)
self.elements[el] = true
end
function Set:contains(el)
return self.elements[el] ~= nil
end
In this case, the expected type would be something like:
type SetMT = {
new : <E>() -> Set<E>,
add : <E>(Set<E>, E) -> (),
contains : <E>(Set<E>, E) -> boolean
}
type Set<E> = {
elements : { [E] : boolean },
@metatable SetMT
}
Inferring this type is beyond the scope of this RFC, though. Initially, we propose only inferring self
monotypes,
in this case:
type SetMT = {
new : () -> Set,
add : (Set, unknown) -> (),
contains : (Set, unknown) -> boolean
}
type Set = {
elements : { [unknown] : boolean },
@metatable SetMT
}
and propose allowing explicit declaration of the shared self type, following the common practice of naming the self type after the metatable:
type Set<E> = { elements : { [E] : boolean } }
This type (and its generic type parameters) are used to derive the
type of self
in methods declared using function Set:m()
declarations:
type SetSelf<E> = {
elements : { [E] : boolean },
@metatable SetMT
}
In cases where shared self types are just getting in the way, there
are two work-arounds. Firstly, the shared self type can be declared to
be any
, which will silence type errors:
type Foo = any
Secondly, the self type can be declared explicitly:
function Foo.m(self : Bar) ... end
For each table t
, introduce:
t
, a sequence of generic type and typepack variables,t
, a type which can use the type parameters of t
, andt
, a type which can use the type parameters of t
.These can be declared explicitly:
type t<As> = U
which defines, when t
has type T
:
t
to be As
,t
to be U
, andt
to be U
extended with @metatable T
.For example,
type Set<E> = { [E] : boolean }
declares, when Set
has type SetMT
:
Set
to be E
,Set
to be { [E] : boolean }
, andSet
to be { [E] : boolean, @metatable SetMT }
.If there is no explicit declaration of the self type of t
, then, when t
has type T
:
t
are empty,t
is a free table type U
,t
has an __index
property of type MT
, then the self type of t
is
a metatable type, whose metatable is MT
, and whose table is U
, andt
does not have an __index
property, then the self type of t
is U
.The free table type is unified in the usual fashion.
Self types are used in two ways: in calls to setmetatable
, and in metamethod declarations.
setmetatable
In calls to setmetatable(t, mt)
:
mt
has self type parameters As
, self type definition T
and self type S
,t
has (final) type T [ Ts/As ]
setmetatable(t, mt)
has type S [ Ts/As ]
.As currently, this has a side-effect of updating the type state of t
from
T [ Ts/As ]
to S [ Ts/As ]
.
For example, in setmetatable({ elements = {} }, Set)
, we have:
Set
has type SetMT
, self type parameter E
, self type definition { elements : { [E] : boolean } }
, and self type { elements : { [E] : boolean }, @metatable SetMT }
,{ elements = {} }
has type { elements : { [X] : boolean } }
for a free X
,setmetatable({ elements = {} }, Set)
has type { elements : { [X] : boolean }, @metatable SetMT }
.as a result Set.new
has type <a>() -> { elements : { [a] : boolean }, @metatable SetMT }
.
As another example, in setmetatable(result, Point)
:
Point
has type PointMT
, no self type parameters, a free self type definition (call it T
), and a self type whose table type is T
and whose metatable is PointMT
,result
has final type { x : number, y : number }
,T
with { x : number, y : number }
) setmetatable(Point, result)
has type { x : number, y : number, @metatable PointMT }
.As a side-effect, the type state of result
is updated to be { x : number, y : number, @metatable PointMT }
,
and unification causes the self type of Point
to be { x : number, y : number, @metatable PointMT }
.
Note that this relies on the type of result
being { x : number, y : number }
, which is why we use the final
long-lived type of t
rather than its current type state.
In method declarations function mt:m
:
mt
has self type parameters As
and self type S
,self
parameter of mt:m
the type S [ Xs/As ]
for fresh free Xs (these types are quantified as all other free types are).For example, in the method Set:add(el)
:
Set
has self type parameter E
, and self type { elements : { [E] : boolean }, @metatable SetMT }
,self
has type { elements : { [X] : boolean }, @metatable SetMT }
when type checking the body of Set:add(el)
.At this point, type inference proceeds as usual:
el
is given fresh free type Y
,self.elements[el] = true
will unifies X
and Y
, and<a>({ elements : { [a] : boolean }, @metatable SetMT }) -> ()
.Shared self types capture an idiom where tables with metatables have all of their fields initialized before any methods are called. In cases where methods are called before all the fields are initialized, this will result in optional types being inferred. For example:
function Point.new()
local result = setmetatable({}, Point)
result.x = 0
print(result:getX())
result.y = 0
print(result:getY())
return result
end
the call to result:getY()
uses the current type state of result
, which is { x : number, @metatable PointMT }
.
Unification will then cause Point
to consider y
to be optional:
type PointMT = {
new : () -> Point
getX : (Point) -> number,
getY : (Point) -> number?,
abs : (Point) -> number -- with a type error!
}
type Point = {
x : number,
y : number?,
@metatable PointMT
}
Since y
has type number?
rather than number
, the abs
method will fail to type check.
As a workaround, developers can declare different self types for different methods:
function Point.getX(self : { x : number }) : number
return self.x
end
function Point.getY(self : { y : number }) : number
return self.y
end
resulting in:
type PointMT = {
new : () -> Point
getX : ({ x : number }) -> number,
getY : ({ y : number }) -> number,
abs : (Point) -> number -- without a type error!
}
type Point = {
x : number,
y : number,
@metatable PointMT
}
or can switch off type checking self
by declaring type Point = any
.
This is a similar problem, caused by calling methods directly on
metatables as well as tables. For example calling Point:abs()
will
result in inferring that both x
and y
are optional,
With the current greedy unifier, classes with constructors of different types will fail:
local Foo = {}
Foo.__index = Foo
function Foo.from(x) return setmetatable({ msg = tostring(x) }, Foo) end
function Foo.new() return setmetatable({}, Foo) end
rather than inferring an optional field, this will report a type error.
We could use new syntax for declaring self types, rather than using the convention that they have the same name as the metatable.
We could do nothing, but at a performance and ergonomics cost.
We could introduce special syntax for classes or records, though this doesn’t address type checking current code.