Shared self types

Summary

This RFC proposes sharing self types between method definitions which share a metatable.

Motivation

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

Design

Self types

For each table t, introduce:

These can be declared explicitly:

  type t<As> = U

which defines, when t has type T:

For example,

  type Set<E> = { [E] : boolean }

declares, when Set has type SetMT:

If there is no explicit declaration of the self type of t, then, when t has type T:

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):

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:

as a result Set.new has type <a>() -> { elements : { [a] : boolean }, @metatable SetMT }.

As another example, in setmetatable(result, Point):

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.

Method declarations

In method declarations function mt:m:

For example, in the method Set:add(el):

At this point, type inference proceeds as usual:

Drawbacks

Partially-constructed objects

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.

Methods called on both tables and metatables.

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,

Classes with multiple constructors

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.

Alternatives

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.