Introduction
Hi, the original purpose of this post was to gather feedback (and hopefully some assistence) on a class wrapper I was making using a mix of metatables and some automatic inference using the new type solver. End goal was to simplify the current idiom without changing it too much, while also providing some extra features.
After mostly getting backlash I figured this wasn’t worth the hassle, so I decided to resume the post, only leaving the important parts for future reference. Even though it’s functional, I consider this incomplete, so I won’t be moving it to the resources category.
Below you’ll find the original goal of the wrapper, a class comparision using the standard idiom and the one originally proposed, some type functions I made in order to accomplish the former (some functional, some not so much), and at the end of the post you’ll find a link to the utilities class featuring the full implementation as well as a working example of what little from this wrapper was salvaged.
Main goals for the wrapper were to:
- Provide better class autocompletion out of the box.
- This includes removing metamethods from class/object types, hiding both declared and inferred private properties, moving object functions declared in the main class to the object, etc.
- Provide events and listeners for class/object properties.
- Provide static, private and nullable “rules” for class properties.
Only the later 2 were accomplished.
Intended Wrapper
Class Example
Below is an example of a class using what I believe is the standard idiom (but asserting it’s types so it autocompletes as expected):
local Car = {}
Car.__index = Car
type Car = {
Brand: string,
Model: string,
Plate: string,
Year: number,
Speed: number,
Fuel: number,
Owner: Player?,
Start: (self: Car) -> ()
}
type InternalCar = Car & {
_EngineOn: boolean,
_Trove: Trove.Trove
}
Car.MAX_FUEL = 60
Car.WHEEL_COUNT = 4
Car.EngineStarted = Signal.new()
Car.EngineStopped = Signal.new()
Car.Refueled = Signal.new()
function Car.new(): Car
local self = {} :: InternalCar
self.Brand = "Unknown"
self.Model = "Unknown"
self.Plate = "Unknown"
self.Year = 0
self.Speed = 0
self.Fuel = Car.MAX_FUEL
self.Owner = nil
self._EngineOn = false
self._Trove = Trove.new()
return setmetatable(self, Car) :: any
end
-- Start --
function Car.Start(self: InternalCar): ()
assert(self ~= Car, "Start can only be called from car objects.")
if not self.EngineOn then
self.EngineOn = true
print(self.Plate.."'s engine started.")
Car.EngineStarted:Fire(self.Plate)
else
print(self.Plate.."'s engine is already on.")
end
end
return Car
This works, but can get a little redundant on bigger classes.
Wrapper Example
Here is an example of the class syntax the original post proposed after several iterations:
local Car = {}
local Definition = {
Rules = { --Rules aren't meant to affect autocompletion (except for 'Constant'), they are meant meant to allow you to enforce preset rules at runtime
Class = {
EngineStarted = "Constant", --Constants can't be edited
EngineStopped = "Constant",
Refueled = "Constant"
},
Object = {
Brand = "Static", --Static properties can't be set to a different type
Model = "Static",
Plate = "Static",
Year = "Static",
Speed = "Static",
Fuel = "Static",
Owner = "Nullable", --Nullables can't be set to a different type, but can be nil
_EngineOn = "Static",
Trove = "Static"
}
},
Filters = { --Filters work much like 'Rules', but are user defined
Object = {
Plate = function(self, Value, OldValue) --This is just an example, 'Plates' is not defined
if not Plates[Value] then
Plates[Value] = true
return true
end
return false, "Plate '"..tostring(Value).."' is already taken."
end
}
},
Callbacks = { --Callbacks are called after a property changes successfully, otherwise the error signal is triggered
Object = {
Brand = function(self, Value, OldValue)
print("The car's brand changed from '"..OldValue.."' to '"..Value.."'.")
end
}
}
}
Car.MAX_FUEL = 60 --No need to define, gets inferred to CONSTANT
Car.WHEEL_COUNT = 4
Car.EngineStarted = Signal.new()
Car.EngineStopped = Signal.new()
Car.Refueled = Signal.new()
function Car.new()
local self = {}
self.Brand = "Unknown"
self.Model = "Unknown"
self.Plate = "Unknown"
self.Year = 0
self.Speed = 0
self.Fuel = Car.MAX_FUEL
self.Owner = nil :: Player?
self._EngineOn = false
self.Trove = Trove.new()
return setmetatable(self, Car)
end
-- Start --
function Car:Start(self): () --'self' should get automatically inferred to InternalCar in this context
assert(self ~= Car, "Start can only be called from car objects.")
if not self.EngineOn then
self.EngineOn = true
print(self.Plate.."'s engine started.")
Car.EngineStarted:Fire(self.Plate)
else
print(self.Plate.."'s engine is already on.")
end
end
return CmeUtil.ToInterface(Car, Definition) --Users should not see 'Start' on class, they should see 'Start' on car, they should see no metamethods, and they should see no private fields on their objects
Definition was originally planned to be an optional addition for those wishing to have a luau equivalent to getters and setters, with ToInterface still autocompleting classes as expected even if it wasn’t provided (removing metamethods, private fields it can infer such as underscores, etc. albeit with a little less control than interfaces with definitions).
Type Functions
As stated earlier the inference part of the wrapper wasn’t finished (both due to backlash and engine limitations), and the final version only retained definitions. As a replacement for this the following type functions were added instead.
ReturnsOf
-- ReturnsOf --
--[[
Returns the resulting types from the passed function in
vararg form, which is useful.
--]]
export type function returnsOf(func: type): ...type
assert(func:is("function"), "Func expected to be a function.")
local returns = func:returns()
local head, tail = returns.head, returns.tail
assert(returns.head or returns.tail, "Returns is empty!")
local result = head or {}
if tail then
table.insert(result, tail)
end
return table.unpack(result)
end
Usage:
export type InternalCar = returnsOf<typeof(Car.new)> --Gets the type returned by the constructor without the need to pass arguments
FilterKeys
--[[
Performs a filter operation on the table against
the specified filter and keys, returning the result.
--]]
export type function filterKeys(tbl: type, filter: type, keys: type): type
assert(tbl:is("table"), "Invalid type: 'Tbl' must be a table.")
assert(keys:is("union") or keys:is("singleton"), "Invalid type: 'Keys' must be an union or a string.")
local filterValue = assert(filter:is("singleton") and filter:value(), "Invalid type: 'Filter' didn't resolve to a singleton.")
assert(filterValue == "include" or filterValue == "exclude", "Invalid type: Filter's value must be either 'include' or 'exclude'.")
--We iterate over each property in our table
local keysList = keys:is("union") and keys:components() or {keys}
for keyType, valueTypes in tbl:properties() do
local found = false
--Now we iterate over each key we wanna filter
for _, patternType in pairs(keysList) do
local key = keyType:value()
local pattern = patternType:value()
assert(typeof(key) == "string", "Invalid type: 'key' didn't resolve to a valid string.")
assert(typeof(pattern) == "string", "Invalid type: 'pattern' didn't resolve to a valid string.")
--If we find the current property in the filter list, we mark it as found and stop iterating over our filters
if key:find(pattern, 1, true) then
found = true
break
end
end
--If the current property was or not found in the list, we apply the appropiate filter
if (filterValue == "exclude" and found) or (filterValue == "include" and not found) then
tbl:setproperty(keyType, types.singleton(nil))
else --Otherwise, we check if our property is a table, in which case repeat recursively
local valueType = (valueTypes.read or valueTypes.write) or types.singleton(nil)
if valueType ~= types.singleton(nil) and valueType:is("table") then
tbl:setproperty(keyType, filterKeys(valueType, filter, keys))
end
end
end
return tbl
end
Usage:
export type Car = filterKeys<InternalCar, "exclude", "_" | "Trove"> --Removes properties matching the passed unions if present
inheritMethods
--[[
Looks for functions in 'Class', removes them if appropiate, and inserts
them into 'Object' after replacing the argument 'self: Class' for 'self: Object'.
--]]
type function inheritMethods(class: type, object: type): type
assert(class:is("table"), "Invalid Type: 'Class' needs to be a table.")
assert(object:is("table"), "Invalid Type: 'Object' needs to be a table.")
--Iterate over the class
for keyType, valueTypes in class:properties() do
local valueType = valueTypes.read or valueTypes.write
local key = keyType:value()
--Look for methods
if valueType and valueType:is("function") then
local params = valueType:parameters()
local head, tail = params.head, params.tail
--Override self
if head and (head[1] == class or head[1] == object) then
if head[1] == class then
head[1] = object
end
valueType:setparameters(head, tail)
--Insert to obj
object:setproperty(keyType, valueType)
end
end
end
return object
end
Usage:
type Object = inheritMethods<Class, returnsOf<Class.new>> --Returns of should get the properties of the class, while inheritMethods should get it's functions
Conclusion
Sadly from my testing the first 2 type functions only work on contained tests, and won’t beheave as expected on actual classes. ‘inheritMethods’ is currently not supported due to engine limitations, but may be on the future.
At the end the only thing I managed to finish was the definition functionality at runtime, which was meant to be more of an extra from the start, but it’s better than nothing nonetheless.
You can find the full ‘ToInterface’ implementation here near the end of the module (I dropped support upstream, but here’s an old version with it):
And you can find a real example of usage on my ‘Binder’ module (once again on an older version):
In the future please abstain from bumping this post, i’ve only rewritten it to keep it for future reference.

