New Type Solver [Beta]

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!

340 Likes

This topic was automatically opened after 10 minutes.

Sweet, The new type solver is exactly what i was looking for!!

38 Likes

I think this is a great set of additions to the typechecker, I do wish to suggest for a way to make typecasting functions… I really want to be able to set up something like: (pseudocode)
typedef Framework.Get( string ) → require(game.ReplicatedStorage:FindFirstChild(string))
I think this would be beneficial into boosting luau frameworks created in studio without needing the additional step of creating a custom extension on another IDE to achieve the same result.

edit: for those of you mocking me in a server, progress works by suggesting things to be improved. i appreciate the work done, but it’s not like i can’t ask for something to be added as well. even more so if it’s something i’ve been wanting for a long time.

36 Likes

This is a monumental improvement to the Luau type checker, and it’s great that the team’s work is finally in beta for everyone else to see. I’ve been using it off and on through Luau LSP by enabling the FFlag manually, and it’s genuinely quite a bit better.

To anyone who isn’t sold on type checking because they have problems with the type checker, give it a go again in this beta. You’ll probably be pleasantly surprised by how much better it is.

To the Luau team: good job! Now will there be a syntax for write-only properties? The RFC for them has been merged for a long time, but still no syntax. :pensive:

Also, is there a timeline on us getting generic constraints? I’d love it if we could specify e.g.

type foo = <T: mul<T, number>>(n: T) -> ()
45 Likes

Thanks for the update. I really like the new keyof, it’s definitely a game changer for me.

I just really wish the type solver was smart enough to recognize this as {Part} instead of {nil}.

30 Likes

This may very well be one of the best Luau updates to the engine!!!

+ 1

I try to work around this by doing <T>(arg: T &Type) -> any, which isn’t successful for a lot of cases (particularly: tables, but does work for some). However, with the new solver, my use cases may become directly implemented

17 Likes

I’m not sure if this is type-related but please allow for the use of #{["X"] = 1}. Using # on a table that doesn’t have number type keys will create a warning. This should already be possible long ago idk why it isn’t a feature

17 Likes

When I saw this at the beginning I thought that Studio would be able to know whether a BoolValue was set to true or false

14 Likes

Same! I have many functions that return certain values based on the input and I wish the type solver would be able to return those for my autocomplete.

I also have a module which requires then returns other module scripts based on their names, e.g. getModule("Interactions") would return the interactions module. I wish I could preserve autocomplete with this type of solution within Studio rather than having to use external tools like VSCode if I want this level of autocomplete support.

17 Likes

This would be an absolute dream. I wouldn’t even think about using VSCode at that point.

12 Likes

thank god it becomes much more usable

good work fellas

11 Likes

FINALLY!!! I’ve been waiting for this solver for a very long time now, every time I looked at the Luau recaps I hoped to see it released and finally it’s here! This is going to improve my experience so much, thanks, team!

10 Likes

Great update! Appreciate it. It definitely is going to help.

11 Likes

that is a really great update but can be there a way that automatically (i donot know how to say it so i will just send an image)

13 Likes

Ive been waiting for these features patiently

Im so excited to try them out!

12 Likes

Hello. In my module, I have a dictionary which I store messages in, and I have another module that uses this module’s dictionary to access specific messages. But after I enabled the new type solver, It now produces a warning that says: “Cannot add indexer to table.” How can I solve this warning?

7 Likes

We’d like to have bounded generics completed before the full release of the new type solver, but I’m unsure of exactly what order things will be implemented in, or how long it will take to get things into shape for the full release. Likewise for write-only properties, they’re planned for the full release, but I don’t know when they’ll be done.

15 Likes

Not blocked-4378! Aggghhh!

blocked-4378

There also seems to be a strict-mode inference problem with closures:

--!strict

local A
A = 1

local Value = A + 2 -- fine, is number

local function B(): number
	return A + 3 -- not fine, is number?
end

Anyways, very excited for how this turns out! It definitely feels more stricter, although I’ll probably have to go over some code to make sure everything works properly.

10 Likes

I have to re-write my module from ground up with the new type system :sob: and it’s not even supporting many things I’m trying to do lol.

7 Likes