Hi everyone,
The Luau team has been hard at work on a major refresh of our Type Inference Engine and we’re excited to announce our new Type Solver Beta!
This beta enables creators to get early access to upcoming type systems features and improvements, and provide feedback on how it’s working for them. Like the type system itself, these changes seek to provide creators with tooling improvements (like more accurate autocompletion) and safety improvements (like more accurately identifying potential runtime bugs in their scripts ahead of time).
There’s a lot of new features and improvements to old features included in this effort, so let’s jump into some of the details!
New Features
Read-only Table Properties
Expand to read more
The new solver affords both inferring and explicitly annotating table properties as read-only.
type T = { read x: number }
Read-only table properties are really useful because they are covariant.
For instance, we really wish that the following could be allowed, but we cannot because the type of foo
allows for it to mutate its argument:
function foo(x: { part: Instance })
print(x.part.Name)
x.part = Instance.new("Folder")
end
local bar: { part: Part } = { part = Instance.new('Part') }
foo(bar) -- Forbidden. Cannot convert Part to Instance in an invariant context
If we can guarantee that the property will not change, then this conversion is completely fine.
local function foo(x: { read part: Instance })
print(x.part.Name)
x.part = Instance.new("Folder") -- Forbidden. x.part is read-only
end
local bar: { part: Part } = { part = Instance.new("Part") }
foo(bar)
Additionally, functions declared with function T.name()
syntax are considered by the new solver to be read-only. This allows us to correctly check certain polymorphic scenarios in a sensible way:
local T = {}
function T.foo(arg: unknown)
end
function bar(t: { foo: (number) -> () })
t.foo = function(x: number)
math.abs(x)
end
end
bar(T) -- Should be an error, but works in the old solver because of a bug.
function baz(t: { read foo: (number) -> () })
t.foo(0)
end
baz(T) -- Ok
Typestate
Expand to read more
Due to the highly dynamic nature of Luau, a variable can notionally change its type over the course of its use in a function or script. The new Luau solver now tracks this in cases where there are no intervening functions or loops using typestate.
local foo -- foo has type nil here
if some_flag then
foo = Instance.new("Folder") -- foo is a Folder here
end
print(foo.Name) -- Warning: foo is a Folder? here.
Type Refinements
Expand to read more
Refinements are a lot smarter in the new solver. For instance, in the following function, we can see that the parameter x
must flow into the call to math.abs
and therefore must be number
exactly. Because of this, the type test narrows the type of x
to never
indicating that there’s no possible values of x
in that branch.
local function f(x)
if typeof(x) == "string" then
-- old solver, x : string
-- new solver, x : never
end
return math.abs(x)
end
Refinements are implemented using negation types. Negation types do not currently have a syntax in the language, but they sometimes appear in error messages. When they do, it is almost always part of an intersection type. The notation we provide for them is ~T
, describing any type except T.
The new solver is also smart enough to refine the types of specific properties of tables.
local function non_nilable(props: { str: string })
return props.str
end
local function nilable(props: { str: string? })
if props.str ~= nil then
return non_nilable(props) -- Broken in the old solver. Now fixed!
end
return "default"
end
It is also smarter about how builtin operators like or affect the types of variables.
num = num or 1
num += 1 -- We used to error here because you cannot add number? to number
end
Type Functions
Expand to read more
Something that the old solver cannot accurately infer is code involving the use of operators. This is tricky to infer because most unary and binary operators in Luau can be overloaded via metamethods.
function add(a, b)
return a + b
end
With the new type solver, we’ve introduced type functions. A type function is like an ordinary function, but it operates on types to produce another type. There are new built-in type functions for most builtin operators in the Luau language. For example, the type add<number, number>
evaluates to the type number
and mul<CFrame, Vector3>
evaluates to Vector3
.
For the above function, the new solver infers <A, B>(A, B) -> add<A, B>
. This is very powerful because we can now infer types for calls to this function sensibly:
local a = add(2, 3) -- ok!
local b = add(2, "three") -- error!
local c = add(Vector3.one, Vector3.one) -- ok!
New Type Functions: keyof, rawkeyof
Expand to read more
Type functions were initially introduced as a way to model overloadable operators, but it turns out that they can be useful for all sorts of things.
We’ve introduced four new type functions for extracting and reasoning about properties of table types.
local animals = {
cat = { speak = function() print "meow" end },
dog = { speak = function() print "woof woof" end },
monkey = { speak = function() print "oo oo" end },
fox = { speak = function() print "gekk gekk" end }
}
type AnimalType = keyof<typeof(animals)>
function speakByType(animal: AnimalType)
animals[animal].speak()
end
speakByType("dog") -- ok
speakByType("cactus") -- errors
rawkeyof
is just like keyof
, but it ignores metatables.
New Type Functions: index, rawget
Expand to read more
These are the flip side to keyof and rawkeyof. They can be used to extract the types of property values.
type Person = {
age: number,
name: string,
alive: boolean
}
local function doSmt(param: index<Person, "age">) -- param = number
-- rest of code
end
type idxType = index<Person, keyof<Person>> -- idxType = number | string | boolean
type idxType2 = index<Person, "age" | "name"> -- idxType2 = number | string
Similarly, index will do a metatable lookup if necessary and rawget will not.
Singleton Type Improvements
Expand to read more
The current solver is very quick to decide whether a particular string literal is a singleton type. It is also not very accurate, which results in a lot of spurious warnings. The new solver uses more of the program’s context to make decisions about whether a literal should be given a primitive type like string
or the appropriate singleton type.
--!strict
type Color = "Red" | "Green" | "Blue"
local t: { color: Color } = { color = "Blue" }
function foo(c: Color) end
local color = t.color
foo(color) -- Used to be broken. Now fixed!
Relaxed Casting Rules
Expand to read more
The intended mental model we had for programmers on when a cast expr :: type
would be allowed by Luau is “whenever the underlying value (from evaluating expr
) could potentially be part of the type given in the cast.” The old solver implemented this by checking for a subtyping relationship between the type of the expression and the type of the annotation in either direction, but we found this to be overly restrictive, especially in situations where you’re interacting with optional userdata that’s part of a class hierarchy (like Instance
in the Roblox API). The new solver relaxes the rules for casting to more directly capture the desired semantics here: a cast will be allowed whenever the two types have any common inhabitants.
--!strict
type A = { x: ChildClass }
type B = { x: BaseClass }
function optionalAToB(a: A?): B?
return a :: B?
end
function optionalBToA(b: B?): A?
return b :: A?
end
A New Nonstrict Mode
Expand to read more
Nonstrict mode has always been intended to be something that we’d like to turn on by default for all Luau scripts. This is quite ambitious because we need to be very certain that something is actually a problem before we report to the user. A large percentage of our devs aren’t interested in statically typing their code and we don’t want to get in the way.
The original design of nonstrict mode was simple in concept: We inferred the type any for most expressions. This approach does make a lot of error messages go away, but it comes at the cost of also rendering that type checking result unusable for things like autocomplete and hovertype. This causes a performance problem in Studio because it has to do a secret second type checking pass to populate the autocomplete database. It also still produces warnings in a lot of otherwise working code.
With the new solver, we’ve redesigned nonstrict mode from the ground up. Instead of having different type inference rules just for nonstrict mode, we use one, unified type inference pass with differing rules for error reporting. The new rule for error reporting nonstrict mode is simple: If we can prove that a bit of code will definitely fail at runtime, we will produce a warning.
As a simple example:
function foo(x)
math.abs(x)
string.lower(x)
end
We will report a warning for this function in nonstrict mode because there is no possible way to call the function without causing a runtime error. If we pass anything other than a number, math.abs
will fail, and if we pass anything other than a string, string.lower
will fail.
By contrast, the following program will not warn because it might work:
function bar(x)
if math.random() then
math.abs(x)
else
string.lower(x)
end
end
The new nonstrict mode is very permissive right now as a foundation. We’re looking forward to extending it to catch more classes of errors, but we’ll be doing so under the same guiding principle of proving that a program will definitely fail.
Known Issues
There are a few details that we know that we need to iron out before this is ready for prime time.
The error messages aren’t always as good as what the old solver produces. In particular, there are certain cases where the new solver infers a much more verbose type than it needs to.
Performance is also something we are working to improve. It should be fine for small-to-medium sized scripts, but type checking and autocomplete may be unacceptably slow for larger scripts.
You might see very long and strange types being inferred within long functions that do a lot of arithmetic. Performance may also be bad in this case. You may be able to work around this by annotating local variables and parameters.
There are still some known bugs where the new solver will fail to infer any type at all. You’ll see something like *blocked-1234*
. If you run into this, please report it! We’re still actively working to fix this.
If your project uses React or Fusion, you may experience some crashing right now with the beta enabled, but we’ll investigate this as soon as possible to make sure it gets fixed!
Since we’re releasing alongside a new release of Studio today, we’d like to note that there’s a handful of small issues with the keyof
and index
type functions, and type checking for string.format
that you may encounter if you enable the beta before updating Studio! Apologies for any confusion this may cause, but for the best experience, definitely be sure to keep Studio up to date!
Resources
If you would like to know more about the Luau’s type inference engine or hey — if you’re just starting out and want to know more about Luau — be sure to check out the following links!