Type-Checking Metatables

Heyy! :V

Tiny Note: I’ll be editing this post as I come across new methods, some replies may seem out of date :3
It’s recommended you know the basics of type-checking, here’s an awesome post to check out!

This tutorial covers two methods (so far) and it’s useful to know both!

✧・:ָ₊・ 1. Seperate Type-Checks 𓂃ָ-࣪☁

This approach type-checks the basetable and metatable seperately, then combines them using setmetatable() while preserving each table’s type.

-- Method 1. Seperate Type-Checks

-- Create  basetable and  metatable,  specifing a  custom 
-- table  type  for  each.   We  are  using  assertation,
-- which allows us to set the type during declaration. 
local Basetable = {
	foo = "x_o"
} :: {
	foo : string
}

local Metatable = {
	__index = {
		bar = true
	}
} :: {
	__index : {
		bar : boolean
	}
}

-- Combine our tables, this will preserve our type-checking!
local Composite = setmetatable(Basetable, Metatable)

This method allows the basetable and metatable to retain their custom types, even if we later split them apart. This method becomes problematic when the composite table is the return parameter; as type-checking a return result requires a combination basetable-metatable type.

.

✧・:ָ₊・ 2. Combined Custom Type 𓂃ָ-࣪☁

This approach combines the basetable and metatable types into one single custom type. This custom type can be used to type-check the return result of functions or modules. See documentation on this approach here (recommended) :3

--  Method 2. Combined Custom Type

-- Define a single custom type  containing our basetable 
-- and metatable structures. We are using the same table
-- structure as the last approach.
type Composite = typeof(setmetatable(
	{} :: { -- Basetable structure.
		foo : string
	}, 
	{} :: { -- Metatable structure.
		__index : {
			bar : boolean
		}
	}))

-- Create our table using the defined layout of our type.
local Composite : Composite = setmetatable(
	{ -- Basetable
		foo = "xwo"
	}, 
	{ -- Metatable.
		__index = {
			bar = true
		}
	})

.

It’s pretty simple, but lots of the documentation is buried -w\ Hopefully this will assist developers by providing an easier to find reference :3

Good luck on your procrastinative perfectionist coding!! ^w^

8 Likes

Or you can do this, if you wanna save some time:

type Basetable = typeof(setmetatable({} :: {foo: boolean, bar: {string}}, {}))
local newTable: Basetable = setmetatable({foo = true, bar = {"OwO"}}, {})
3 Likes

I appreciate your help on this tricky MT stuff.

I actually found out another little trick similar to what you did that doesn’t raise errors. Here’s an example using purely typechecking.

Lets say you have a Car (property Table) and you want to apply a CarControls (metatable) onto it. Naturally you would do this.

--!strict

-- naive version
type Car = {
	Name    : string,  
	MaxSpeed: number,
	Weight  : number,  
	Object  : Model,  

	Internals: {
		CurrentSpeed: number,
		CurrentSteering: number, --     (-90 | 0 | 90)    left to right
	}
}

type CarControls = {
	__index       : CarControls,
	__tostring    : (self: Car) -> (string), 
	UpdateSteering: (self: Car) -> (),  
	MoveForward   : (self: Car) -> (),
	Brakes        : (self: Car) -> (),
}

This is wrong because Car:UpdateSteering() will warn you stating: “Key ‘UpdateSteering’ not found in table ‘Car’” because it’s not considered a Metatable.

Now if you follow the tutorial you can make a couple of changes and you get closer with:

--!strict
-- Closer!
type Car = typeof(setmetatable(
	{}::{
		Name    : string,  
		MaxSpeed: number,
		Weight  : number,  
		Object  : Model,  

		Internals: {
			CurrentSpeed: number,
			CurrentSteering: number, --     (-90 | 0 | 90)    left to right
		}
	},

    -- CarControls
	{}::{
		__index       : CarControls,           -- Problematic line.             
		__tostring    : (self: Car) -> (string), 
		UpdateSteering: (self: Car) -> (),  
		MoveForward   : (self: Car) -> (),
		Brakes        : (self: Car) -> (),
	}
))

Now my metamethods mostly work but that doesn’t fix my main warning. It still doesn’t know what Car:UpdateSteering() is. This is where what I found comes into play.

Simply use getmetatable() like this

-- CORRECT WAY
type Car = typeof(setmetatable(
	{}::{
		Name    : string,  
		MaxSpeed: number,
		Weight  : number,  
		Object  : Model,  

		Internals: {
			CurrentSpeed: number,
			CurrentSteering: number, --     (-90 | 0 | 90)    left to right
		}
	},

    -- CarControls
	{}::{
		__index       : typeof(getmetatable(   ({}::Car)  )),    -- returns CarControls
		__tostring    : (self: Car) -> (string), 
		UpdateSteering: (self: Car) -> (),  
		MoveForward   : (self: Car) -> (),
		Brakes        : (self: Car) -> (),
	}
))

That fixes all warnings allowing you to correctly typecheck a MT with a cyclic __index
If you use any other method (such as cloning the MT manually), it will emit a warning.
Hope this helps.

6 Likes

Well this is going to be useful, thanks everyone, im joining the cool scripters club.

1 Like

Just so you know, both the old and new type-solvers support recursive types now, so you can do this:

type self = {
	Value: number
}

type Metatable = {
	__index: Metatable,
	__add: (self: Class, Other: Class) -> number,
	Method: (self: Class) -> ()
}

-- or setmetable<self, Metatable> in the new solver
type Class = typeof(setmetatable({} :: self, {} :: Metatable))

local Object: Class = nil :: any

print(Object.Value)
print(Object:Method())
print(Object + Object)

Don’t expect it to work with any OOP idiom, though. At best you’d have to litter a bunch of :: any everywhere.

I learned recently that if you use

-- in the Annotations (ModuleScript)

type VehicleProperties = {
  Name: string,
  MaxSpeed: number,
  Weight: number,
  Object: Model,

  Internals: {
    CurrentSpeed: number,
    CurrentSteering: number,
  }
}

type VehicleControls = {
  __index       : VehicleControls,
  __tostring    : (self: Car) -> (string),
  UpdateSteering: (self: Car) -> (),
  MoveForward   : (self: Car) -> (),
  Brakes        : (self: Car) -> (),
}

export type Car = typeof(setmetatable( {}::VehicleProperties, {}::VehicleControls  ))

return 1 -- ignore this line

it works, just not within the same script (last time I checked).
if you run

local Annotations = require("@self/Annotations") -- the above ModuleScript
local TestCar: Annotations.Car

You will see the autofill work without using :: any

Why not just do like this

--!strict
local car = {}
car.__index = car

local carData = {
	Name ="" :: string , 
	MaxSpeed =2 ::number , 
	Weight=0 :: number ,  
	Object=nil::Model,
	Internals = {
		CurrentSpeed=0:: number,
		CurrentSteering=0 ::number,
	}
}

function car.new(model:Model?):Car
	local self = table.clone(carData)
	self.MaxSpeed=100
	
	return setmetatable( self, car )
end

function car:UpdateSteering()
	print("UpdateSteering")
end
function car:MoveForward()
	print("MoveForward")
end
function car:Brakes()
	print("Brakes")
end

export type Car = typeof(setmetatable( carData, car  ))
return car

Everything autocompletes and has correct types

Maybe I am missing something

Adding onto this, you could also use typeof() if you don’t want to make a custom type for the metatable

For example:

local Metatable = {}
Metatable.__index = Metatable

type TableData = {
	text:string,
	amount:number,
} 
export type TableType = typeof(setmetatable({} :: TableData, Metatable))

function Metatable.Test()
	print("test works!")
end

Then you can do

function NewTable() : TableType
	local tableThing:TableType = setmetatable({
		text = "aaaaa",
		amount = 3,
	}, Metatable)
	return tableThing
end

And tableThing will autocomplete show the Test() function in its metatable

well this would work but don’t use table.clone since that creates a shallow copy, use a deep copy function