luau-lang / luau

A fast, small, safe, gradually typed embeddable scripting language derived from Lua
https://luau-lang.org
MIT License
3.79k stars 348 forks source link

Add a keyof(table) type #962

Open Sakyce opened 1 year ago

Sakyce commented 1 year ago

The problem

Currently, it's inconvenient to type check table content as I have to write the same thing twice, both in the type declaration and in the table.

For instance, a constant that use a bunch of strings as keys.

type PossibleOpinions = 'Cats'|'Dogs'|'Pizzas'|'Tacos'

local MY_OPINIONS = {
    ['Cats'] = 'I love cats!',
    ['Dogs'] = 'I like dogs! But I prefer cats!',
    ['Pizzas'] = 'When I think about pizzas I think about Peppino Spaghetti',
    ['Tacos'] = 'You mean that french dish where they put a bunch of stuff in a brick?'
}

function sayMyOpinion(about:PossibleOpinions)
    print(MY_OPINIONS[about])
end

sayMyOpinion('Cats')     -- ok
sayMyOpinion('Hexagons') -- not ok

The solution

Add a keyof type! It returns a union of all the keys of a table! It should be backward compatible like typeof and not so chaotic to integrate.

local MY_OPINIONS = {
    ['Cats'] = 'I love cats!',
    ['Dogs'] = 'I like dogs! But I prefer cats!',
    ['Pizzas'] = 'When I think about pizzas I think about Peppino Spaghetti',
    ['Tacos'] = 'You mean that french dish where they put a bunch of stuff in a brick?'
}

function sayMyOpinion(about:keyof(MY_OPINIONS))
    print(MY_OPINIONS[about])
end

sayMyOpinion('Cats')     -- ok
sayMyOpinion('Hexagons') -- not ok
MagmaBurnsV commented 1 year ago

This seems like it would be a really nice addition.

Currently, I'm trying to make a state wrapper that auto-types its elements for the methods. The problem is there is no current method of getting the types in table, so everything gets inferred as any:

local newState = State.new {
    A = 1,
    B = 2
}

newState:SetValue("A", 2) -- "A" is not inferred, and 2 cannot be typed
newState:GetValue("B") -- Returns 'any'

This proposal would solve half of the problem, as now I could type the Key params with keyof({} :: T), such as:

type State<T> = {
    GetValue: (Key: keyof({} :: T)) -> any
}

However, this does not fix the problem with the return type or the second param for SetValue, so I think there would need to be another operator to fix this, like a valueof function or something of the likes. I'm not sure how this would all fit together, but in my mind, it would look something like:

type GetValue<T = {}, K = string> = (T: keyof({} :: T)) -> valueof({} :: T, "" :: K)

Obviously, fixing my problem is not trivial, but I don't think the addition of keyof would hurt any future attempts at solving it.

goldenstein64 commented 1 year ago

The way TypeScript solves @MagmaBurnsV's problem is using keyof, a type space indexing operator, and constrained generics, shown here. Implementors of this interface would have type-safe access and mutation of key-value pairs present in T.

interface State<T> {
  GetValue<K extends keyof T>(k: K): T[K];
  SetValue<K extends keyof T>(k: K, v: T[K]): void;
}

// note that keyof accepts a type, so OP would need to use 'keyof typeof MY_OPINIONS'

I've seen discussion about constrained generics somewhere in this repo, like #784, but nothing about implementing keyof (other than this post) or a type space indexing op.


I want to note this RFC outlining what kind of syntax the team might prefer for type functions and why. TL;DR use keyof<T> over keyof T or keyof(T).

alexmccord commented 1 year ago

For the record, there's a Index<T, K> type family that's going to solve the type indexing problem, so I wouldn't bother working on this design space.

As for the Keyof use case, there's a bunch of nuances to think about. Should Keyof also include all accessible keys via __index metamethod? What about if the table has indexers? The story gets blurry in a quick hurry.

Sakyce commented 1 year ago

I think keyof should also include all accessible keys via __index metamethod because it's very similar than directly putting keys in the table. I first thought of keyof(T) because of Luau having typeof(T) would keep the syntax similar, but I don't mind any alternative syntaxes.

strawbberrys commented 1 year ago

For the record, there's a Index<T, K> type family that's going to solve the type indexing problem, so I wouldn't bother working on this design space.

As for the Keyof use case, there's a bunch of nuances to think about. Should Keyof also include all accessible keys via __index metamethod? What about if the table has indexers? The story gets blurry in a quick hurry.

I don't believe it should include keys from index because that isn't what you're asking for. If you wanted to do that you should have to directly specify it with keyof(t.index).

Gargafield commented 1 year ago

I think it's better to not include __index, since (maybe in the future) a user could implement this behavior themself:

local MyClass = { Foo = "Bar" }
local OtherClass = setmetatable({ Hello = "World" }, { __index = MyClass })

type MyClass = typeof(MyClass)
type OtherClass = typeof(OtherClass)
type TableIndexed = Keyof<OtherClass> & Keyof<typeof(getmetatable({} :: OtherClass).__index)>
alexmccord commented 1 year ago

It just occurred to me that RawKeyof could be a thing that explicitly ignores __index, then Keyof includes anything in __index and all of the types in the __index chain.

CompeyDev commented 10 months ago

Would be awesome to have this this, allows for much stronger and automatic types.