It’s great to be able to specify types in Luau, however, for me it’s only really used for readability and VM optimizations. We need some way to see when type definitions / type assertions fail in studio. I’ve spent hours searching for elusive bugs that would have been found immediately with a feature like this. This mainly applies to providing incorrectly typed arguments to a function, and assigning the wrong type of value to a variable.
Here’s some context.
I’ve been working on my project full time since late 2015 and the full codebase currently has 200k lines spread across a few thousand ModuleScripts. I hope to reduce this by maybe 20%, but it takes time to refactor and remove legacy code. It’s only 125k (65k excluding empty lines) after compiling and stripping unused code / folding constant variables. Anyways, I can’t use inter-module type inferences so most of my code is type unsafe until after I compile it. This is because require(script.Parent.Parent.Utility)
is horrible boilerplate and impractical for a project of this scale.
I also use array-based objects instead of string-based table types to minify codegen, reduce memory used by these objects, reduce unique strings, and improve execution peformance. Here’s a basic ModuleScript example that might exist in my game’s codebase:
HealthClass module example
--!nonstrict
-- HealthClass - Not really used, just an example.
--[[
_G.mR is added immediately before requiring a module and reverted after. It's used for
explicitly requiring modules by their unique "dataId". This allows modules to be replicated
to the client lazily (along with dependencies) as-needed. Unused code also gets stripped
from the game after compiling. This statement is removed from the compiled game during the
compile process; Data is then accessed using auto-generated upvalues/globals defined at the top.
]]
local _R = _G.mR{
":std", -- Shortcut to a namespace. This one includes 'IsDebug' and other utilities.
-- Data is required explicitly via its dataId.
-- This can be a module, a custom animation data string, map data, or anything really.
InternalEvent = 2716, -- Refers to a ModuleScript named "InternalEvent[2716]"
}
-- Indices of the object's fields.
-- Only use vertical alignment if the values need to be consecutive or exclusive.
local iHealth = 1
local iMaxHealth = 2
local iDeathSignal = 3
local iDebugType = 4
local DebugTypeValue = "HealthClass"
-- This function checks if the object is valid.
-- It will be removed before publishing because '_R.IsDebug' is defined as false.
local Assert = function(self: {})
if _R.IsDebug and rawget(self, iDebugType) ~= DebugTypeValue then
error("HealthClass expected")
end
end
--[[
The "--$_M" comment lets my system know it's a static module. I have a plugin that will lex
modules and find these so the module loader can wrap this and raise errors if a non-existing field
is accessed. The game's custom compiler will also perform inter-module constant folding/inlining,
as well as remove the string keys so it's just an array.
This entire module can be stripped during compilation if its functions all have "--$inline" comments.
]]
return {--$_M
-- This constructor becomes "return {100, 100, nil}" before publishing.
new = function(): {}
local self = {
[iHealth] = 100,
[iMaxHealth] = 100,
[iDeathSignal] = nil, -- Created when used.
}
if _R.IsDebug then
self[iDebugType] = DebugTypeValue
end
return self
end,
--[[
Expose getters/setters. This is great because I can easily track down references/definitions
spread across a huge codebase. Searching through all cases of "object.CFrame" in a large codebase
using table types was a nightmare. Finding "object[_R.Object.iCFrame]" is much easier. Plus it
becomes stringless after compiling!
]]
iHealth = iHealth,
iMaxHealth = iMaxHealth,
-- Alternatively, getter/setter functions can be used instead of exposing indices.
-- I added support for expression inlining late June 2021.
GetHealth = function(self: {})--$inline
Assert(self)
return self[iHealth] :: number
end,
--[[
InternalEvent.new(argCount, key, assertObject)
A lightweight event. It will add the first connected function directly to 'key'.
It creates a special wrapper table once 2 functions are connected.
"InternalEvent.new" has a "--$pure" comment so the result can be reused by the data system.
]]
ConnectDeathSignal = _R.InternalEvent.new(0, iDeathSignal, Assert),
-- Example usage: "_R.HealthClass.Damage(self, 10)"
Damage = function(self: {}, amount: number)
Assert(self)
local health0: number = self[iHealth]
if health0 > 0 then
local health1: number = health0 - amount
if health1 > 0 then
self[iHealth] = health1
else -- Died
self[iHealth] = 0
local fire = self[iDeathSignal]
if fire then -- Call the connected function (or wrapper table) if it exists.
fire()
end
end
end
end,
}
My project has over 1000 modules similar to that one. It’s difficult to convey the scope of what I’ve done to make modules like that one simplify beautifully before I publish. The game’s compile process has saved me so many times by finding bugs before I publish (for example: “HealthClass.Heal not defined!”.)
I hope runtime type warnings get added soon if possible. It would be great to verify type annotations on code when it’s tested initially. The VM might need to start in a special mode that runs slower, so a studio setting would be great! Some way to get intellisense to work with custom module loaders would be great too, but I’m not sure how that would work.