Luau Type Checking Beta!

All Roblox types like Vector3, Part, etc. are exported with type definitions - if something doesn’t work right let us know!

4 Likes

Sorry about this :frowning: This was raised internally as a potential backwards compatibility concern but we didn’t follow up on that before activating the new parsing features globally.

I am assuming you have fixed the game by recompiling the source? We’ll try to figure out how to introduce this syntax or some variant thereof in a non-breaking fashion.

edit: I’ll reply to the other comment in the same post because “3 consecutive replies” rule got me again:

The idea is that when you say local Foo = require(Blah), we magically treat this as importing all types declared in Blah into Foo type namespace in the current script. So Foo.Moo would work if Blah had a declaration for it type Moo = number.

However, for this to work require has to be able to find the correct module through the game hiearchy, and we haven’t finished support for this yet.

5 Likes

Take a look at this:

Most likely not happening, also in 5.4 constants are made using attributes like this

local t<const> = 1
t = 2 -- error

If you want a keyword…

4 Likes

AAAAAAAAAAAAAAAAAAAAAAHHHHHHHHHHHHHHHHHHH

Thank you so much! I will use this feature with the best of my ability :slight_smile:

1 Like

I thought type was supposed to be a function.

type Point = {x: number, y: number}

The usage of type here doesn’t make sense to me.

1 Like

@zeuxcg
I am very grateful for how early this has been made available, and I can’t wait to use this! Good work so far on Luau and the upcoming type checking system.

That being said, prepare to read a lot of text, because there are lots of problems out of the box that I really hope get fixed before type checking is fully released. In particular, I really hope the focus of typed Luau is to make the process of gradual type introduction easy. I am simply going to critique that point, since if gradual introduction of types to existing code isn’t 100%, it’s going to be a real pain in the oof for beginners, and people just getting used to static typing.


Issues I’ve noticed without adding any types to my codebase

When I first enabled typed Luau, I expected a lot of warnings. In total, I had 87 with nonstrict mode, which is a little higher than I expected (I had assumed the “any” type would take care of a lot of potential warnings). Some of these warnings in particular are annoying to deal with given that they are opinionated about code that should be perfectly benign.

Since I have a lot to say, I’ve folded each of my critiques of the type checker and provided a place file at the end demonstrating all of them.

Table Type Inference Issues

A large number of my type warnings come from completely benign code that the type checker doesn’t like because it doesn’t consider certain conventions with table construction.

It seems that tables are automatically gradually typed rather than having a generic {[any]: any} type by default.

Hoisted member inconsistency with table type inference

Here’s a very simple example using a convention I’ve used in the past for OOP, which is perfectly benign, yet produces an warning due to gradual typing of tables (The big issue with gradual typing here is that you can’t forward declare members of tables unless you use noops, which affect the runtime code—that’s not good)

local Spring = {}
Spring.__index = Spring

Spring.new = function(...)
	local self = setmetatable({}, Spring)
	Spring.constructor(self, ...)
	return self
end

Spring.constructor = function(self, ...)
	-- ...
end

return Spring

Produces the following warning:
image

This is perfectly valid code similar to what was in my codebase (provided Spring.new is never called before Spring.constructor; however, you can silence the warning by using a different syntax to define the “constructor” function

local Spring = {}
Spring.__index = Spring

Spring.new = function(...)
	local self = setmetatable({}, Spring)
	Spring.constructor(self, ...)
	return self
end

function Spring:constructor(...)
	-- ...
end

return Spring

This is a very odd inconsistency and definitely needs to be fixed. In my opinion, tables should either hoist all of their defined information to the original table object’s type, or default to an {[any]: any} type.

Scoped class definitions

This is an issue I’ve found throughout my code; everyone does OOP differently, so typing for all of the OOP conventions is hard. However, there are certain cases where declaring an object, then initializing it in a closed scope is useful; Old roblox-ts code did this.

local MyClass; do
	MyClass = {}
	MyClass.__index = MyClass
	
	local SOME_CLOSED_VARIABLE = 100
	
	function MyClass.new(arg)
		local self = {}
		setmetatable(self, MyClass)
		self:method(SOME_CLOSED_VARIABLE * arg)
		return self
	end
	
	function MyClass:method(value)
		-- ...
	end
end

image

Misleading warnings with untyped class constructor arguments

local MyClass = {}
MyClass.__index = MyClass

function MyClass.new(arg)
	local self = {}
	setmetatable(self, MyClass)
	return self
end

-- For some reason "new" is treated as a class method, probably because it has a first argument (which is presumed to be "self")
-- However, it is perfectly reasonable for arg to be an optional argument
local x = MyClass.new()

image

Type checker hates reflective/dynamic use of classes

Admittedly, this system isn’t the best, but often times objects need to be serialized from server to client, and classes constructed off of them. This means passing along enumerated “type” information, so that another system can select a “type” of class to construct dynamically. The type checker hates this, and this accounts for a large number of my codebase’s warnings as well. I am especially concerned for metatable constructs that don’t follow the typechecker’s opinion of what a Lua class is supposed to be, since sometimes you might not be creating a “class” in the traditional sense; in my case, I have lots of classes that don’t have a “new” function (to prevent direct creation of these objects), and instead have a manager that creates these objects. Admittedly, this is a highly coupled system and not the best, but I don’t think that’s the type checker’s job to enforce.

local CLASS_REGISTRY = {}

-- Register classes
do
	local Class1 = {}
	Class1.__index = Class1
	function Class1:someMethod()
		-- ...
	end
end
do
	local Class2 = {}
	Class2.__index = Class2

	function Class2:someMethod()
		-- ...
	end
	
	CLASS_REGISTRY['Class2'] = Class2
end
-- ... Do some validation on all registered classes to make sure they match a basic interface


-- Dynamically create an object from its class type and 
local function createObject(classType, ...)
	local class = CLASS_REGISTRY[classType]
	
	local object = {}
	setmetatable(object, class)
	
	-- This is where a warning is produced, since the object has its metatable dynamically set
	object:someMethod()
	
	return object
end

image

UserData Typing issues

Overloaded constructors for roblox objects such as Color3, UDim2, BrickColor all seem to have issues with their typings; just these two lines of code produce 5 warnings, and this accounts for a large number of my warnings
image

Issues with Globals

I keep scripts that are meant to be run on the command line in my codebase, and I try to stuff as much together as possible (this means using global variables rather than local variables). It appears the type checker has no support for globals whatsoever (which may be for the best, but the “unknown symbol” warning is confusing). I think they should be supported in nonstrict mode at least


Produces these warnings:
image

Crashes

Certain scripts in particular crash completely when I try to open them; however, when I port these scripts (by copying their instance) to an empty place, I still get the crash in the empty place, but not if I save that place and re-load it, so this seems to be inconsistent, and I therefore can’t send a simplified repro. DM me for the full place file, since I don’t want to post it publicly.

edit: Interestingly enough, when I clear the terrain in that same place (and everything in the workspace), the file stops crashing when I open it. It must be a memory issue of some kind.

Misleading warnings

Built-in character script example

The core “LocalSound” script seems to declare a global function “stateUpdated”, which besides the unknown symbol warning, produces an odd “duplicate definition” warning. The full example can be found in the file at the end of this post.
image

Argument mismatch warnings don’t give argument numbers

local function myFunction(a: number)

end

-- "Type mismatch between nil and number"

myFunction(a, 1)

-- "Type mismatch between number and nil"

myFunction()

My Conclusion

Most of these examples are completely untyped vanilla code, so most of my critique is directed towards the user experience of gradually introducing types to their codebase, since I think that should be the primary goal for typed lua if it is to be non-instrusive. Particularly, certain opinionated type rules should be kept to “strict” mode, or left out entirely, especially those having to do with table type inference and classes, since there is no standard idiomatic way of using classes in lua, and unfortunately the type checker does not respect that. The inconsistency with hoisting members to the original type of gradually typed tables, and the “This function must be called with self. Did you mean to use a colon instead of a dot?” warning are the main culprits. In my opinion, non-strict mode should always type tables as {[any]: any} (I was hoping the convention would use ‘__index’ signatures, or a type syntax that respected metatables, since typed lua with representation of metatables in types would be extremely rich; I’m a bit disappointed with this syntax)`

I want the type checker to be useful and not a burden; while I am all for enabling strict mode and making sure all my code is typesafe, avoiding false positives (especially in nonstrict mode) needs to be a priority. If valid lua code is producing warnings, it’s going to take a lot of effort fighting the type checker, which will only slow development down, and often times this may affect runtime code as well, since certain opinions will require runtime-affecting workarounds (such as forward-declaring members of a table when relying on their gradual typing). If you can get rid of most of the warnings in the file linked at the end of this post, I will be very happy to integrate typed lua into my code.

All of the examples demonstrating my critiques here can be downloaded for reference:
TypeCheckerExamples.rbxl (25.9 KB)

16 Likes

Yeah it was an easy fix, it just caught me off guard. It only affected one script out of hundreds, so it’s a pretty rare edge case. I don’t expect anyone else will be affected.

So if ModuleB at the end of the hierarchy was swapped out with ModuleC after runtime, ModuleA would error upon requiring ModuleC when it expected ModuleB, but only if it ModuleC’s type definitions are different from ModuleB? This could work, but only if the type definitions were generated while the developer is coding and be saved somewhere in the script’s data, so that the time the types are evaluated isn’t arbitrary.

The confusing part would be assigning the assumed module type definitions to actual variables (requires) in the code. Some sort of special using or require header that pairs automatic type definitions with how that dependency is required/loaded would be simplest, although it does still have design challenges.

Type data could be associated with a specific unique variable assignment. The simplest solution would be to define all dependencies/requires in a header, and it would be as simple as processing the header to get a module’s external dependencies. Similar to this (ignore the exact syntax):

using
    Foo = require(script.Parent.Class)
    Bar = _G.req("Framework")

The types of Foo and Bar could be stored in a property separate from .Source.

These are just ideas. Explicit typing needs explicit requiring, and there isn’t really an obvious solution that fulfills the scalability (lazy replication) problem without using a very involved preprocessor.

In my system, for example, the scripts that show the map Gui only replicate with their dependencies when the player opens the map tab, and the script that runs a dragon’s logic only replicates when a dragon near the character is also replicated. This extends to non-script data as well, but it’s enormously valuable when attempting to create a truly massive MMO that remains lightweight on the client with fast join times.

4 Likes

What makes adding continue as keyword different from adding type as a keyword? type already is a variable, it has a value and can be reassigned and just used as a variable, but now it’s also a keyword. continue is already sometimes used as a variable, but I think any context where it is intended as a keyword is uniquely distinguishable from any context from where it is intended as a variable. e.g.

for i = 0, 1 do
	if i == 0 then
		continue
	end
end

this makes no sense if continue is not treated as a keyword, just like continue = true makes no sense if it is.
this makes sense:

for i = 1, 10 do
	local continue = i%2 == 0
	if continue then
		continue
	end
end
7 Likes

Right, when I said “keywords” above, I meant “true” keywords - not context-sensitive keywords (which type is one of, now). You are correct in that we could explore the option of continue becoming a context-sensitive keyword.

The challenge is that, since Lua doesn’t have statement terminators, and continue statement should work in isolation, after parsing continue in the beginning of the statement, the parser has to make sure that the next token isn’t starting a new statement.

What if the next statement starts from a parenthesis (()?

This is valid Lua:

function continue(f)
   return f
end

function foo()
    return print
end

for i=1,3 do
    continue(foo())(i)
end

If we introduce continue as a context-sensitive keyword, we would have two options:

  1. Parse continue in the loop body as a new loop continuation statement. This can break code since it changes semantics of the (valid) code above
  2. Say that since continue is followed by (, it’s a function call. This keeps compatibility, but makes code like this behave in surprising ways:
function foo()
    return print
end

for i=1,3 do
    continue
    (foo())(i)
end

Which is of course the same code as above, but it doesn’t work anymore because continue variable is nil.

4 Likes

(I should note that perhaps “continue that is followed by ( is a function call” is a reasonable compromise, and we already have a lint for the possible issue with parentheses on the new line, so it may be a viable path - but I wanted to highlight that it’s not as simple as it may sound like)

4 Likes

Experiencing a lot of random crashes with this feature.

1 Like

I think that your example isn’t ambiguous. continue should behave like break and return in that no statement can go after it in the same block. In your example it only makes sense to interpret the continue as a function call. if you wrote continue; (foo())(i) then it should error because there’s a statement after the continue.

also (unrelated) this is completely valid luau code

--!strict
type type={type:any}
local function type(type:type)=>type return type end
print(type{type=type})
10 Likes

Yeah, that’s fair. Meaning that programmers who write code would never expect continue on a separate line to work. Maybe continue is not doomed after all!

21 Likes

I think it does. From Lua to Luau, you have to start keeping track of scoping in order to determine whether type is a variable or a keyword. Consider the following snippets compiled with the Luau parser:

type Foo = nil -- `type` is a keyword.

vs.

local type
type Foo = nil -- Now it's a variable.

--> Expected '=' when parsing assignment, got 'Foo'

This might be fine for a parser that already plans on running the code, but it will be a non-trivial step up for syntax highlighters, beautifiers, linters, and so on. Programs that had enough foresight to separate the parser and scoping will now have to couple them together. On top of that, the documentation will have to somehow explain this concept to novice programmers and Lua veterans alike. It’s not as simple as “This word is a keyword only when the next thing is an identifier.”

The feeling I get overall is that this new syntax is being justified by the implementation. Roblox’s parser already handles scoping and a bunch of other things, so of course it’s trivial to add context-sensitive keywords. But that doesn’t make the concept itself trivial. This is why I keep asking for a specification. Writing a spec approaches the idea from a different angle, and reveals problems that would otherwise go unnoticed.


Bonus snippet:

local Foo, number = 1, 2
local _ = _ and
	type Foo = number
	type Foo = number
5 Likes

Small issue for me. It seems to be impossible to define the following “doubly” dependant types:

type Connection = {NodeA: Node, NodeB: Node} -- Depends on Node below
type Node = {Connections: Array<Connection>} -- Depends on Connection above
type System = {Nodes: Array<Node>}

I can’t just define Connection again or I get an error: cannot multiply define type

How would I do this with this type system? Is it possible?

Edit:
The following is possible, however it feels a bit hacky

type _Connection<Node> = {A: Node, B: Node}
type Node = {Connections: Array<_Connection<Node>>}
type Connection = _Connection<Node>
type System = {Nodes: Array<Node>}
4 Likes

As for where this addition is headed, I love it a TON! It could allow me to make executing user code with my sandbox so much more safe and will allow me to create very strict APIs which can be returned by user code. Very cool indeed!

This would require invalid types causing an error though which doesn’t seem to occur at all in command bar. Additionally, I have no idea how this will behave with loadstring. Could be nasty.

1 Like

We have a BNF internally for this but it won’t help you much because BNFs are not simple to parse.

You don’t have to track scoping (unless I misunderstand what you mean by scope), but you are entirely correct in that to correctly syntax-highlight the source, it’s no longer sufficient to write a lexer - you need a parser.

However, this is a general property of languages with context-sensitive keywords. I don’t see a way out of this due to the backward compatibility issues I mentioned before.

3 Likes

Would it be ever possible to use this outside Roblox???

2 Likes

This is a bug :slight_smile: We’ll fix this.

3 Likes

I found an interesting bug.
image
image
image

This seems to maybe be revealing some kind of internal naming somehow? Someone experienced something similar near the beginning of this post with CFrames.

Edit:
It actually appears that maybe setmetatable sets a __meta property temporarily??? I can’t test this since the only way to test this is metatables and metatables aren’t fired if this is the case. Would explain why setmetatable doesn’t accept userdatas (can’t rawset a value on a userdata).

3 Likes