Luau Recap: March 2023

How the time flies! The team has been busy since the last November Luau Recap working on some large updates that are coming in the future, but before those arrive, we have some improvements that you can already use!

Improved type refinements

Type refinements handle constraints placed on variables inside conditional blocks.

In the following example, while variable a is declared to have type number?, inside the if block we know that it cannot be nil:

local function f(a: number?)
    if a ~= nil then
        a *= 2 -- no type errors
    end
    ...
end

One limitation we had previously is that after a conditional block, refinements were discarded.

But there are cases where if is used to exit the function early, making the following code essentially act as a hidden else block.

We now correctly preserve such refinements and you should be able to remove assert function calls that were only used to get rid of false positive errors about types being nil.

local function f(x: string?)
    if not x then return end

    -- x is a 'string' here
end

Throwing calls like error() or assert(false) instead of a return statement are also recognized.

local function f(x: string?)
    if not x then error('first argument is nil') end

    -- x is 'string' here
end

Existing complex refinements like type/typeof, tagged union checks and others are to work as expected.

Another thing you can achieve with improved type refinements is to enforce ML-style exhaustive analysis, such as a Rust match.

Simply add a local whose type is never after the pattern matching, and assign the variable being tested to it.

local function f(x: string | number)
    if typeof(x) == "string" then
        return tonumber(x) or -1
    elseif typeof(x) == "number" then
        return x
    end

    local static_assert_exhaustive: never = x -- no type error
    error("inexhaustive!")
end

This means that if you added a new valid type as an input to the function, you will get a type error if you did not also implement a branch for that type, e.g.

local function f(x: string | number | boolean)
    if typeof(x) == "string" then
        return tonumber(x) or -1
    elseif typeof(x) == "number" then
        return x
    end

    local static_assert_exhaustive: never = x -- Type 'boolean' could not be converted into 'never'.
    error("inexhaustive!")
end

And of course, adding a branch for boolean that also short-circuits and returns an output removes the type error.

Marking table.getn/foreach/foreachi as deprecated

table.getn, table.foreach and table.foreachi were deprecated in Lua 5.1 that Luau is based on, and removed in Lua 5.2.

table.getn(x) is equivalent to rawlen(x) when ‘x’ is a table; when ‘x’ is not a table, table.getn produces an error.

It’s difficult to imagine code where table.getn(x) is better than either #x (idiomatic) or rawlen(x) (fully compatible replacement).

table.getn is also slower than both alternatives and was marked as deprecated.

table.foreach is equivalent to a for .. pairs loop; table.foreachi is equivalent to a for .. ipairs loop; both may also be replaced by generalized iteration.

Both functions are significantly slower than equivalent for loop replacements and are more restrictive because the function can’t yield.

Because both functions bring no value over other library or language alternatives, they were marked deprecated as well.

You may have noticed linter warnings about places where these functions are used. For compatibility, these functions are not going to be removed.

Autocomplete improvements

When the table key type is defined to be a union of string singletons, those keys can now autocomplete in locations marked as ‘^’:

type Direction = "north" | "south" | "east" | "west"

local a: {[Direction]: boolean} = {[^] = true}
local b: {[Direction]: boolean} = {["^"]}
local b: {[Direction]: boolean} = {^}

We also fixed incorrect and incomplete suggestions inside the header of if, for, and while statements.

Runtime improvements

On the runtime side, we added multiple optimizations.

table.sort is now ~4.1x faster (when not using a predicate) and ~2.1x faster when using a simple predicate.

We also have ideas on how to improve the sorting performance in the future.

math.floor, math.ceil and math.round now use specialized processor instructions. We have measured ~7-9% speedup in math benchmarks that heavily used those functions.

A small improvement was made to built-in library function calls, getting a 1-2% improvement in code that contains a lot of fastcalls.

Finally, a fix was made to table array part resizing that brings large improvement to the performance of large tables filled as an array, but at an offset (for example, starting at 10000 instead of 1).

Aside from performance, a correctness issue was fixed in multi-assignment expressions.

arr[1], n = n, n - 1

In this example, n - 1 was assigned to n before n was assigned to arr[1]. This issue has now been fixed.

Analysis improvements

Multiple changes were made to improve error messages and type presentation.

  • Table type strings are now shown with newlines, to make them easier to read
  • Fixed unions of nil types displaying as a single ? character
  • “Type pack A cannot be converted to B” error is now reported instead of a cryptic “Failed to unify type packs”
  • Improved error message for value count mismatch in assignments like local a, b = 2

You may have seen error messages like Type 'string' cannot be converted to 'string?' even though usually, it is valid to assign local s: string? = 'hello' because string is a sub-type of string?.

This is true in what is called Covariant use contexts, but doesn’t hold in Invariant use contexts, like in the example below:

local a: { x: Model }
local b: { x: Instance } = a -- Type 'Model' could not be converted into 'Instance' in an invariant context

In this example, Model is a sub-type of Instance and can be used where Instance is required.

The same is not true for a table field because when using table b, b.x can be assigned an Instance that is not a Model. When b is an alias to a, this assignment is not compatible with a’s type annotation.


Some other light changes to type inference include:

  • string.match and string.gmatch are now defined to return optional values as match is not guaranteed at runtime
  • Added an error when unrelated types are compared with ==/~=
  • Fixed issues where variable after typeof(x) == 'table' could not have been used as a table

And as always, we would like to thank the community for all the open-source contributions to Luau on GitHub!

119 Likes

This topic was automatically opened after 10 minutes.

The team keeps killing it with these updates, they are awesome!

9 Likes

very nice quality of life changes.

4 Likes

nice changes, looking forward to seeing this get handled properly as well

local function f(x: number?)
    x = x or 0

    -- currently x stays of type number?
    -- even though "x = x or 0" will make sure it is always of type number
end
13 Likes

Very very nice. Changing such things only can lead to making equivalent to modern game engines

4 Likes

A week ago, I came across what I assumed was a bug when ascribing multirets:

local function f()
	return 1, 2
end

print(f() :: any) -- Only 1 is returned

I wrote an issue but was told by staff it was intentional and it was just closed.

My question is, what? Why on earth is Type Ascription designed to change the runtime logic of variables? It’s not a Type Coercion operator and it would seem logical that the above annotation would ascribe any to each item because in similar annotations like function f(...: number), ... is denoted as a variadic with multiple numbers.

10 Likes

Thank god, I always hated doing sanity checks on values just to realise they’re not refined past if then return end block and the fact I would have to shadow value with local that has type explicitly defined as value that can’t be nil. :pray:

6 Likes

Sweet update.

Please implement the ability for the global unpack function to also unpack other datatypes like Vector3, Vector2, Color3. For example, unpack(Vector3.one) -> 1,1,1; come on, it just feels right, much better than indexing (and assigning) each value to a var. :smile: This will benefit the workflows of many developers.

8 Likes

Can we expect to make functions with autocomplete like this?


Basically a way to make a function like :FindFirstChild(), which returns exactly the instance you’re looking for via a string argument.

It’s also April.

2 Likes

Recap means previously.

Anyways, thanks for the needed update! If you are reading this, then please, add support for this:

local modules = {
    Module1 = require(script.Module1)
}

modules.Module1 -- no intellisense :(

Add intellisense for modules nested in dictionaries.

12 Likes

This is an interesting suggestion. I wonder how feasible it would be to do something similar to generalized iteration here; whereby, we’d be able to leave the existing unpack function untouched, but add a new generalized form of unpacking which comes with its own metamethod. That way we might have a universal unpacker for any array-like datatype, including custom ones. I of course wouldn’t know the implications of this, but the feature seems highly convenient in concept.

Continued Discussion

I should note that this is a valid criticism for the suggestion of any such metamethod. Does __tostring really need to exist if every class contains a :GetString() method? Does __iter have any place if classes contain member functions which iterate just the same? What about mathematical operations?

While I can’t refute that giving these problem datatypes their own variant of :GetCompoents(), would be working within the established precedent, it should not be without mention that there exists definite advantages to having a universal unpacker. Namely in the realm of modularity; if comparable cases can be handled in an identical fashion, then the same code can be repurposed without the need to make alterations.

This is another fair critique. Though, if I had to play devil’s advocate, I’d point out that there is some clarity as to what the return for these more ambiguous types should be, given that tostring must already make these distinctions. Converting a CFrame to a string lists every component, just as a Color3 becomes a string representative of its RGB values; the result takes after that class’s traditional constructor. A custom class resembling a dictionary, may well contain multiple iterators (perhaps one for its keys, one for its values, and one for key-value pairs), but assumedly only one can be made that class’s iterator with respect to generalized iteration. And of course, not every class needs to benefit from such utility either; we wouldn’t expect every type of collection to support __len, for example.

4 Likes

It would be better if these datatypes had their own :GetComponents() function to do the same job (And add a :ToRGB() for Color3). I mean, really the only data structures are tables, which is what unpack does.

2 Likes

Now, that is a great idea! :star_struck: Your idea sounds like it could have many uses, similarly to how __tostring among other metamethods/metafields do.
The problem, if I can go as far as to call it one, is that you could create an unpack method for any OOP you create to do this very thing. ← probably the exact argumentative stance any Roblox engineer would tenaciously stand behind

4 Likes

I agree with datatypes having their own methods to return their data in a neat tuple. However, I feel as though such datatypes should be able to be unpack()'d in the same way which, for example, a Vector3 can be tostring()'d to represent its data as a string.

4 Likes

Why did the warnings appear in the player module?

3 Likes

love to see it :clap: (onlythirtycharacters)

2 Likes

This is a must, I hate having to go out of my way to require the module manually rather than letting a central module be able to require it by just inputting a string instead of the entire path.

Please Roblox, let us have IntelliSense for modules required by sources like functions, and modules that are in dictionaries.

7 Likes

Destructuring and unpacking are two differnt things. Unpack unpacks an array (or a .n lenght table)

4 Likes

You’ve probably seen the answer on that issue already, but for other developers who would like an answer, I will copy it over here:

  • The only context in which an expression list returned by the function is expanded to the list instead of being treated like a single variable is when the function call expression (or ...) is present at the end of the function argument list or table constructor, unadorned. “Unadorned” is critical - for example, the fact that parentheses change the meaning of the call is a subset of this rule. Using :: operator means that the expression is no longer a function call or ..., and these are the only ones, syntactically, that produce multiple results.
  • :: ascribes the value on the left hand side to the type on the right hand side, and does not support type packs. Using :: thus means that the code declares that there’s just a single value on the left hand side by definition - foo :: number doesn’t mean "treat foo as if it evaluates to any number of numbers`, it means “treat foo as if it evaluates to a single number”.
  • In general, we consider automatic expansion in tail positions problematic. If we could make this expansion explicit instead of implicit (eg using a spread operator), we would - we currently don’t know how to do this cleanly without breaking compatibility, but if we figure out how to do that we absolutely will. The implicit expansion creates endless compatibility hazards for user-level code, when the code unknowingly passes an extra value, or adding an extra return value may break the code (eg table.insert(t, foo()) works when foo returns one value, but adding an extra return value to foo likely breaks the call); it results in a lot of problems for the implementation from the correctness and performance perspective; and it’s just ultimately not truly necessary. As such, while in principle you could imagine alternate designs for pt 1 and 2, we’re not really inclined to pursue these as we consider the fundamental design problematic.

This should’ve been ...number and it would have been consistent with the type pack syntax in all other locations, but unfortunately it was designed before we had those and now it’s the only special case.
If type ascription ever learns type packs it would also require ...number.

6 Likes