Optionally provide Luau type warnings during runtime in studio

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.

13 Likes

It actually was a bit confusing to me to see the lack of runtime warnings in studio. In editor it only works sometimes (I know this is a bug in and of itself that should be reported, I have made reports where necessary), and so having a warning go into the console saying that a parameter type is mismatched would be instrumental. The only thing I can see that’s bad, and this is speculation on my part, is negative performance implications from doing those checks whenever needed. That’s a lot of code to run and butchers the generally fast nature of the operations that would need to be checked.

The main purpose of strict Luau is to be just that: strict. When I go out of my way to enable strict type constraints I expect complaints to arise from the system if I do something that doesn’t comply with the type constraints of a field or method. I believe that this would be a very useful addition.

2 Likes