Luau Recap: March 2022

Luau is our new language that you can read more about at https://luau-lang.org.

Singleton types

We added support for singleton types! These allow you to use string or boolean literals in types. These types are only inhabited by the literal, for example, if a variable x has type "foo" , then x == "foo" is guaranteed to be true.

Singleton types are particularly useful when combined with union types, for example:

type Animals = "Dog" | "Cat" | "Bird"

or:

type Falsey = false | nil

In particular, singleton types play well with unions of tables, allowing tagged unions (also known as discriminated unions):

type Ok<T> = { type: "ok", value: T } 
type Err<E> = { type: "error", error: E } 
type Result<T, E> = Ok<T> | Err<E> 

local result: Result<number, string> = ... 
if result.type == "ok" then 
     -- result :: Ok<number> 
     print(result.value) 
elseif result.type == "error" then 
     -- result :: Err<string> 
     error(result.error) 
end

The RFC for singleton types is luau/syntax-singleton-types.md at master · Roblox/luau · GitHub

Width subtyping

A common idiom for programming with tables is to provide a public interface type, but to keep some of the concrete implementation private, for example:

type Interface = { 
     name: string, 
} 

type Concrete = { 
     name: string, 
     id: number, 
}

Within a module, a developer might use the concrete type, but export functions using the interface type:

local x: Concrete = { 
     name = "foo", 
     id = 123, 
} 

local function get(): Interface 
     return x 
end

Previously examples like this did not typecheck but now they do!

This language feature is called width subtyping (it allows tables to get wider, that is to have more properties).

The RFC for width subtyping is luau/sealed-table-subtyping.md at master · Roblox/luau · GitHub

Typechecking improvements

  • Generic function type inference now works the same for generic types and generic type packs.
  • We improved some error messages.
  • There are now fewer crashes (hopefully none!) due to mutating types inside the Luau type checker.
  • We fixed a bug that could cause two incompatible copies of the same class to be created.
  • Luau now copes better with cyclic metatable types (it gives a type error rather than hanging).
  • Fixed a case where types are not properly bound to all of the subtypes when the subtype is a union.
  • We fixed a bug that confused union and intersection types of table properties.
  • Functions declared as function f(x : any) can now be called as f() without a type error.

API improvements

  • Implement table.clone which takes a table and returns a new table that has the same keys/values/metatable. The cloning is shallow - if some keys refer to tables that need to be cloned, that can be done manually by modifying the resulting table.

Debugger improvements

  • Use the property name as the name of methods in the debugger.

Performance improvements

  • Optimize table rehashing (~15% faster dictionary table resize on average)
  • Improve performance of freeing tables (~5% lift on some GC benchmarks)
  • Improve gathering performance metrics for GC.
  • Reduce stack memory reallocation.
104 Likes

This topic was automatically opened after 9 minutes.

Singleton types with type checking is literally one of the best features ever. It has made developing so much easier and has increased the range of things a developer can do. Can’t wait to see more. Amazing job to the Roblox staff!

3 Likes

Next Luau recap: implementing ownership-based memory management. :grin:

14 Likes

Nice, will definitely help scripters.

1 Like

Width subtyping seems counterproductive to the type system in general. Implicitly downcasting without actually downcasting is just a way of saying “this type error is allowed”. Luau already features a type cast operator, if someone wants their type downcast and they don’t do it, then they mean something by not doing that. In the example, the type returned isn’t even a subtype of what is said to be returned. The programmer is told that the result will be Interface but in reality it is the supertype Concrete that is returned. Any code which calls get() will be under the impression that it’s returning somehting which may only contian string values, but Concrete could just as easily implement table values and strange indexer types resulting in errors when it’s passed to, for example, some serialization function.

I’m not trained in complier design so maybe my use of “supertype” isn’t completely correct according to type theory but that doesn’t stop the fact that the object returned is a larger set of information than what is stated to be returned by the type annotations, and it is permissable for it to contain additional types and fields which are incompatible with code that is compatible with Interface

How will the typechecker ever predict errors related to returning Interface from the function? If this type of type error is not part of its design goals, then what utility are its design goals?

3 Likes

Trouble is that without this it’s extremely frustrating to write any form of Interface based code.

Imagine I have an interface like:

type Zone = {
    getContents: () -> {any},
    getVisibleTo: () -> Visibility,
}

If I have a bunch of different types of zone that all have those two functions on them, should I have to explicitly cast them to this type every single time I want to pass them into a general API that uses Zones?

That’s extremely frustrating and even worse than it seems at first, because I may not even have the Zone type in scope! In which case I literally have to add a pointless require for the sole purpose of getting a type that I didn’t even want to cast to in the first place into scope.

I get that it’s not free, for instance it will be harder to make “atom types” that don’t have any members at all, but that should still be possible once you can specify metatables in a type definition.

6 Likes

The workaround for that that my projects have been using is to implement “Compliant types” which are just like the interface in the example, in that they specify a minimum amount of functionality guaranteed by all the supertypes, except that we would NEVER say we’re returning one of the compliant types when we are actually returning a supertype of it, because that’s factually misleading. Instead it is the API-like areas that deal with these supertypes that say they actually need the compliant type. E.G., anything in any game that has a position can be said to be “QTCompliant” in regards to a quad tree because a position is all the necessary data we need to insert it into a quad tree. It would be silly, however, to refer to anything with a position as a QTCompliant type when it could more accurately be called a projectile, enemy, etc. It isn’t until the quad tree comes into play that we start thinking of the type as inherently related to a quad tree, so it is also the quad tree that is responsible for the type restrictions involved with viewing some class as a QTCompliant

In this setup, a concrete type is only treated as its interface when it is used by functions that treat it as such an interface, but a getter function will never return a concrete type while telling you that it is only an interface. It just lacks some syntax/vocabulary for associating a concrete type with its various interface interpretations.

1 Like

It sounds like you might want to have a look at the proposal for opaque types at API evolution RFC by asajeffrey · Pull Request #294 · Roblox/luau · GitHub, which are intended to address the kind of API compatibility issues you’re talking about.

--!strict
local m:{[string]:number} = {a=1,b=2,c=3,4}

Why is this allowed? Directly stating the key of the last value ([1]=4) will cause this to generate a warning.

This is a bug that’s on our list to fix (see Analysis ignores table key types if the keys are not created · Issue #423 · Roblox/luau · GitHub)

3 Likes

When using singleton, it would be great if it could autocomplete like the :IsA() function. q(≧▽≦q)

function f(str:"Loser"|"Winner") end

f("Loser") --autocomplete "Loser"
5 Likes

I love to see Luau grow ever since roblox moved on from Lua 5.1!

:slight_smile:

Keep on moving!

image

image

image

When singleton is applied to a table’s value, it is replaced by the type of variable.

1 Like

This is by design, otherwise types in certain places would wound up as too restrictive. See:

local t = {}
table.insert(t, x) -- where x was of type "hello"

If we didn’t widen, type inference would claim t to have the type {"hello"}, a list of "hello" strings! Attempting to insert a different value would error. This doesn’t seem useful, so we’d rather take the stance that the upper bound of t is {string}.

In this case, you need to spell out the type of your table for either Mode or Name.

For us to actually do the right thing here, we need type states which should be able to track the set of values that are possible in a given scope. This is planned.

1 Like

I didn’t know it was intended.
I understood. Thank you!

1 Like

When using singleton, it would be great if it could autocomplete like the :IsA() function.

That’s on our radar!

Defining interfaces is too hard. I want a way to assert that a sealed table meets a type without shadowing a variable and provoking a linter, or defining a new variable and making it harder to search for. It gets worse when you want to assert that a table meets multiple interfaces.

There are cases where a member of the table can only easily be assigned once the table has been sealed, so assigning the type when the table is first defined is not a solution.

local module = {}

function module.get_unit_test_description()
end
-- ....

-- I use this to assert that `module` meets `test_module_interface`.
local module: test_module_interface = module
return module

Great work roblox, it is good to see you are improving these things and what developers can create with them :slight_smile:

1 Like