Luau typing - few years on, still glaring problems?

I recently started using Luau types and --!strict in my scripts as I’m a fan of strong typing and wanted to find out how it worked first hand. There are some significant advantages, as with all strong type systems and in general, I appreciate the effort to bring a valuable new tool into the eco system.

That said, there seems to be a glaring hole: the ability to export a type effectively.

From the Luau docs on typing:

A downside with this pattern is that it does not automatically
create a type binding for an instance of that class,
so one has to write:
type Account = typeof(Account.new("", 0)).

Two of the issues with this were reported by @hates_sundays over two years ago here: building types that use types is ugly and complex and there is also just the fundamental problem of having two different constructs named the exact same thing to make the code really hard to understand.

I want to add to this: the problem is worse - if my class constructor should require a complex Roblox type e.g. Player, it is impossible to use the pattern suggested.

Let’s say in the above example Account should take a player as a first parameter to new. Then the example changes to:

type Account = typeof(Account.new(_someplayersomehow_, "", 0))

This doesn’t work server-side - there is no player when the script starts up. Which effectively means I have to bend over backwards to design my class to incorporate the player another way or forgo using types for this class.

I have many years of development experience with professional experience using more than a half dozen languages. Every single one of those that is strongly typed provides a way to cleanly export types. Because without it, the ability to use the type system is severely compromised in non-trivial applications.

It should be as simple as creating a script that exports the type e.g. this should live in its own file and allow exporting the typename as different than the class. That is, the class extends the type allowing the type to be defined and used separately.

type Impl = {
	__index: Impl,
	new: (player: Player, name: string, balance: number) -> AccountType,
	deposit: (self: Account, credit: number) -> (),
	withdraw: (self: Account, debit: number) -> (),
}

type Proto = {
	player: Player,
	name: string,
	balance: number
}

local AccountType: Impl = {} :: Impl
AccountType.__index = AccountType

export type AccountType = typeof(setmetatable({} :: Proto, {} :: Impl))

Then in the Account script

local AccountType = require( path-to-AccountType )

[ Account extends AccountType ]

This would go a long way to improving the usefulness of Luau types as currently it looks like a significant percent of the code in my project will not be able to take advantage of typing given this shortcoming.

I’ve searched high and low to find a way to do this myself but come up short. If I’ve missed something and this is possible today, I’d love to see how!

2 Likes

Writing the OP here gave me an idea - it may be as simple as the example above, as a separate type file. Example:

File 1: AccountType (module)

type Impl = {
	__index: Impl,
	new: () -> AccountType,
	deposit: (self: Account, credit: number) -> (),
	withdraw: (self: Account, debit: number) -> (),
}

type Proto = {
	player: Player,
	name: string,
	balance: number
}

type AccountType = typeof(setmetatable({} :: Proto, {} :: Impl))

local AccountType: Impl = {} :: Impl
AccountType.__index = AccountType

return AccountType

Note the constructor takes no arguments - this “class” is going to act as the “interface definition” (like interface in Typescript).

File 2: The class implementation (module). Note: this is only related to AccountType in that the Impl & Proto should match except for the constructor

type Impl = {
	__index: Impl,
	new: (player: Player, , name: string, balance: number) -> Account,
	deposit: (self: Account, credit: number) -> (),
	withdraw: (self: Account, debit: number) -> (),
}

type Proto = {
	player: Player,
	name: string,
	balance: number
}

type Account = typeof(setmetatable({} :: Proto, {} :: Impl))

local Account: Impl = {} :: Impl
Account.__index = Account

return Account

File 3: some other file where a function operates on an existing Account type

local AccountType = require( [location of AccountType, file 1] )

type AccountType = typeof(AccountType.new())

local function useAccount(account: AccountType)
 ...now knows what Account is...

I believe this solves all of the previous problems with only a small amount of strangeness i.e. in terms of “setup” it is very similar to how interfaces and classes might be defined in a Typescript project. Obvious differences and strange that AccountType is not “actually” tied to Account so the definitions could drift apart unless maintained properly but seems to work well to provide typing across many scripts for “user defined” types without the drawbacks mentioned above.

If anyone has a better solution, would love to see it!