Introduce native ability to return custom Luau types' names via typeof

Right now, organizing custom object types in modules and providing a form of type enforcement between arbitrary modules is difficult, even in strongly typed Luau. I need the ability to constrain input object types across two or more arbitrary modules that are not necessarily designed in a manner that requires them to be together without having to explicitly define the type in both modules, and most importantly, in a manner where both modules are not necessarily required to have strict types enabled.

To resolve this problem, I think typeof should recognize user-defined Luau types, and return the user-defined name, albeit with an added prefix to prevent spoofing Roblox types (e.g. luaobj: as a prefix)

Take this for example:

--!strict
local MyModule = {}

export type TheTypeEtiMade = {
    Greeting: string
}

function MyModule.new(): TheTypeEtiMade
    return { Greeting = "Hello, Gordon!" }
end

return MyModule

If I use this in another script:

--!strict
local MyModule = require(path.to.MyModule)
local obj = MyModule.new()
print(typeof(obj))
-- ^ Expected output: TheTypeEtiMade

In general, the ability to define custom type names would be a good addition for publicly shipped APIs as it provides a more standardized method of identifying pseudo-objects created in pure Lua.

My solution right now is a sandboxed variant of typeof that looks at objects’ metatables for a __type index which should be set to a string. If this new variant of typeof receives a table with a metatable which has defined __type string, it will return that string instead of "table", and I would like to be able to drop this method in favor of the aforementioned feature. It is not a very clean solution and absolutely not optimal in terms of extreme performance benchmarks.

The code can be seen here:
--!strict
local oldtypeof = typeof
local function typeof(objIn: any): string
	local objType = oldtypeof(objIn)
	if objType ~= "table" then return objType end

	-- Could be a custom type if it's a table.
	local meta = getmetatable(objIn)
	if oldtypeof(meta) ~= "table" then return objType end

	-- Has a metatable that's an exposed table.
	local customType: string? = meta["__type"] -- I want to mandate that this is a string.
	if customType == nil then return objType end

	-- Has a type field. Ignore beta type checker warning of string | nil here.
	return customType
end
return typeof
16 Likes

I’m fairly sure that Roblox’s userdatas have a __type metamethod, but there’s a bit more to it when it comes to handling it.

I’d rather have my objects return ObjectiveObject (example) as their type, than userdata

Big :+1: from me

2 Likes

I actually suggested this/asked about it in the one of the Luau type-checking announcements. Don’t remember which unfortunately.

As cool as it sounds I don’t think this is even how it works. The types only exist at compile-time so this might not work :frowning: perhaps we could make typeof an operator. No existing code would break, since typeof(expr) would just wrap the expression in parentheses, typeof expr would then be valid therefore possibly making this work? Would still need to make it context dependent for scripts that might do local typeof = typeof

I can think of some situations where having typeof as an operator would break compatibility
typeof(t):sub(1,2)
If this was a unary prefix operator (like you suggested) then it would be equivalent to typeof(t:sub(1,2)), but it’s currently equivalent to (typeof(t)):sub(1,2)

An example would be with not:

local s = "abc"
print(not(s):sub(1,2)) --> false

This is because postfix operators have higher precedence, so for it to work the same way this prefix operator would need higher precedence than postfix operators, which would be weird

Using typeof at the end of expressions would be a problem, too.

local b = math.cos
local a = typeof
b(1)

Would a be assigned to the function typeof, or would a be the type of the value returned by b called with 1?

typeof with multiple arguments, typeof(1,2) is currently valid, but if it was an operator it wouldn’t be valid.

Subtracting from typeof, typeof-t would be interpreted as typeof(-t) if it was an operator, but it currently means (typeof)-(t).

typeof with 0 arguments would work, specifically typeof(f(...)) doesn’t currently work when f returns 0 values, but if it was an operator it would take the first value or nil, which would make it work (not terrible, but would be a bit unexpected).

And of course, over writing typeof

local function typeof()return "abc"end
print(typeof"abc") -- abc or string?
1 Like

ouch :grimacing:

then typeof (or any keyword operator) is out of the question.

Possibly a new symbol operator then?

1 Like

I think it would be super useful to have access to type information but I think a single type name may be too simple if you are going to be working with types internally (unforunately I can’t see any realistic solution to expose more than a type name).

Secondly, as mentioned above by @sjr04, its probably true that type checking is purely done at the compile time (after all, it is just called type checking) meaning in order to access this type information it would need to be baked into the script’s bytecode. That’s a bit overkill for one feature not to mention this would likely take a lot of work just to track object types properly.

I think that the best solution here is to add some sort of shorthand in type checking to define a property, e.g. __type, but, at that point it just feels like bloat to me.

I personally don’t see a good alternative to this, although, I do think it would be useful if a solution were more realistic.

Here is a way you can sort of achieve this that isn’t too ugly:

-- In some initial code
local types = setmetatable({}, {
	__mode = "k" -- Allow object keys to garbage collect so we don't cause memory leaks by storing types
})
function shared.settype(object, typeName, noProperties)
	if noProperties or typeof(object) ~= "table" then
		types[object] = typeName
		return
	end
	
	local success = pcall(function()
		local metatable = getmetatable(object) or object
		rawset(metatable, "__typeName", typeName)
	end)
	
	-- Fall back to using properties
	if not success then
		rawset(object, "__typeName", typeName)
	end
end
function shared.typeof(object)
	if typeof(object) ~= "table" or types[object] then
		return types[object] or typeof(object)
	end
	
	local subject = getmetatable(object) or object
	if typeof(subject) ~= "table" then
		subject = object
	end
	return rawget(subject, "__typeName") or typeof(object)
end

If it was a table it’d try to store an __typeName value on the object’s metatable first if it could, then the object if it could, otherwise, it’d track the object using it as an index in a table. All you’d really have to do then is set the type of objects you create when you return them, or, have your objects that share types also share metatables with __typeName set, or, have your objects that share types extend the properties of a shared object with __typeName set.

2 Likes

What happens if the metatable is locked however? For example with a lot of my userdatas which have their metatables locked, I’d have to either create a function to fetch the object’s type internally, which can be a bit bloaty, or share the type of the userdata around all scripts, which is infeasible.

I would take the former as it seems easier to pull off.
(felt the extra content was off topic)

I already account for if the metatable is locked or if the object is a userdata with what I provided which I explain below the code segment. Yes, its not perfect, and no, its not meant to be as good as an official feature would be.

My point was that while I do agree with the feature being useful, I do not personally see a viable way for the feature to exist and I can’t think of alternative ways that would really work well, so, I supplied some extra code because its the closest I can provide to the use case. I really don’t think its off topic in this case.

There are a lot of complications beyond technical ones too, like, how might this effect malicious code. Malicious code could effectively disguise objects however it wanted to if it could control what typeof returned. Additionally, with typeof specifically, you’re expecting to have consistent behaviour with types but that’s lost when you can name the type anything and potentially break code. It could lead to complications in debugging under some circumstances too, particularly when using third party code that might improperly use this. And, how can you control if the custom type is returned vs the luau one, if any?

1 Like