This is what I have been trying to do! Thank you so much for the tutorial i never knew type functions were a thing. Amazing tutorial ^^
Thank you for writing this topic, this reveals a whole world to the new type solver that I really didn’t know existed. One question:
Is there any way to suppress this underlining here? The only way to make it go away is to add --!strict
, but I don’t necessarily want to use strict. It seems like type functionality works perfectly fine anyways, and this is just a cosmetic thing, it’s just a bit weird.
Yeah, they’re currently working on making the non-strict mode better. Try suppressing the linting by casting to any with :: any
for now.
Thank you~! I’ve been kinda using types less as I wait for the new type-solver to complete. I use OOP with inheritance a lot, and keep running into snags which make things complicated or explode. setmetatable<>
will certainly be useful. I hope I’ll be able to make more robust class/subclass-types from now on. I’ll try it later. I also hope you will provide an example eventually for efficiently using types with classes, constructors, methods and subclasses, so I have an idea of whether I am implementing it in a decent way.
Updated the tutorial:
- Added the detailed concepts section.
- Added more examples.
- Added the general examples section with resources.
- Clarified certain sections.
Coming soon:
- What’s next?
- Easier ways of defining certain types.
quite like this.
though i wonder if there’s any way of including parameter names somehow when defining type functions.
say we do something like
type function New()
f = types.newfunction()
f:setreturns({string,boolean})
f:setparameters({string,number})
return f
end
results in:
-- (_:string, _:number)
i want to get rid of those _
placeholders.
any way of doing that? or am i gonna have to bite the bullet here?
There’s no way of doing that for now, unfortunately.
unfortunate. we are so close…
(i’m trying to make an oop framework, needed to keep arguments, and change returns)
You can check out the examples section above on how I transfer the function arguments. Or the Type API source code of my module. Maybe it’ll help you.
Hey, thanks for the tutorial and examples! I have an example of seemingly valid types with the New Luau type solver disabled, but as of yet I’ve been unable to get something similar working with the beta enabled:
In the past I have used types to create specific instance hierarchies. For example, if I wanted to define a specific type of Folder with a single child Part named RootPart, I could define a type like this:
type MyFolder = Folder & {
RootPart: Part
}
Assume I have 3 Folders under my script, “EmptyFolder” has 0 children, “ExactFolder” has 1 child Part named “RootPart”, and “ExtraFolder” has 2 child Part named “RootPart” and “OtherPart”. With the New Luau type solver disabled and --!strict
mode enabled, the following lines do not produce type errors:
local x: MyFolder = script.EmptyFolder --Valid, despite EmptyFolder not having a child "OtherPart"
local y: MyFolder = script.ExactFolder --Valid, meets expectations of the MyFolder type exactly
local z: MyFolder = script.ExtraFolder --Valid, despite ExtraFolder having an extra child "OtherPart"
The ability to essentially declare a referenced instance as a specific instance hierarchy (regardless of whether it actually matched) was useful for situations where a function might expect and/or operate on such a hierarchy that is consistent and exists prior to runtime (for example, as a descendant of multiple assets in ReplicatedStorage).
However, under the New Luau type solver, this approach now produces a type error for each of the lines above (casting also fails to address the type errors). The use of an intersection to mimic the instance hierarchy is likely incorrect, but I’m wondering if there is some way to achieve a similar effect under the New Luau type solver.
This does make sense, as the type of the EmptyFolder is Folder
, and MyFolder
is an intersection between Folder
and {RootPart: Part}
and is not exactly Folder
.
I believe maintainers said that this is one of the bugs that were fixed intentionally, and people should move away from these strategies.
Are you aware of any endorsed strategy that does something similar? The two alternatives I’m aware of (that don’t produce type errors under the New Luau type solver) aren’t as useful as I would hope:
--!strict
--Example interface module that expects a certain instance hierarchy on parameter `myFolder`
local module = {}
-- Option 1: Don't use types
function module.FuncA(myFolder)
-- No code completion on `myFolder` within the function
-- User of the module is left guessing as to what `myFolder` is allowed to be
myFolder.RootPart.Color = Color3.new(math.random(), math.random(), math.random())
end
-- Option 2: Assign vague class type to parameters and cast located descendants
function module.FuncB(myFolder: Folder)
-- Overly verbose when accessing descendant instances
-- User of the module is given a vague and imprecise expectation of what `myFolder` can be
local rootPart = myFolder:FindFirstChild("RootPart") :: Part --Cast required even though the programmer knows (or can reasonably assume) it will exist
rootPart.Color = Color3.new(math.random(), math.random(), math.random())
end
return module
That type-error occurs when you directly reference the Instance, so the second function option should be fine to do.
function module.FuncB(myFolder: Folder & {RootPart: Part})
local rootPart = myFolder.RootPart
rootPart.Color = Color3.new(math.random(), math.random(), math.random())
end
The addition of that intersection on the parameter just pushes the type error issue back to the function call though.
module.FuncB(script.ExactFolder)
--^ Type Error: Type 'Part' from 'DataModel' could not be converted into 'Part' from 'Roblox' in an invariant context
Do you have any idea how to create unsealed tables with type functions?
Can you give examples showing, or explain, how unions work with tables and functions? An example of function unions is provided in Luau Semantic Subtyping.
Negations do not work with tables or functions, if we try to bypass it using unions or intersections that simplify to a table or function, we get “Code is too complex to typecheck”.
What is the default indexer for {}
? For intersections of tables, you mentioned above that {a: number} & {b: number} = {a: number, b: number}
. The results don’t align with the default indexer being nil, since number & nil
would result in never
. Instead they align with it being some set of types that conform to the rules laid out by the unions and intersections of tables.
Huh. That might be a bug, I’ll be reporting it.
For now, you can do it like this:
function module.FuncB(myFolder: any)
local myFolder: Folder & {RootPart: Part} = myFolder
local rootPart = myFolder.RootPart
rootPart.Color = Color3.new(math.random(), math.random(), math.random())
end
All created and returned types are immutable and final, therefore all the created table types are sealed. Think of it in this way, annotating a table variable like this x: {}
seals the table, because you’ve now created a table type that you’ve set to x, inference cannot change this.
I’ll be including more examples of this later, thank you.
I believe this is an intentional limitation for now, but may be changed in the future when the new solver is more stable.
nil
.
{a: number} & {b: number} = {a: number, b: number}
. Is true, and both of these tables have an indexer of nil
. In-fact, you can even check this by using :indexer()
method on both of these types.
I don’t quite understand how number & nil
would result in never
in this case, as that will never happen in the first place. Property a
and b
have the type of number
, and only when you try to access a
or b
, you will get a number
type, any other property access will result in nil
(or a type error), which is not number & nil
.
It took me awhile to find it, in the documentation of Lute (here) it mentions:
FIXME(luau):
{[unknown]: unknown}
should be treated identically totable
Which is definitely up to interpretation, but how I take it is that all table
types will by default be considered {[unknown]: unknown}
unless otherwise stated. [string]: number
will not override [unknown]: unknown
, however [unknown]: string
will. It could just mean that we will be able to use table
as a type.
The point was to understand what :indexer()
meant when the value was nil
. If you do {a: number} & {a: string}
you get {a: number & string} = {a: never}
. Thus the intuition that intersection of tables is defined as the type of each element within the table intersected with its corresponding type in the other table. We can jump to the conclusion that all types that reside in one table and not the other will be filled in with the key paired with nil. This assumption is incorrect, since it should actually be filled in with the default type within the indexer, thus my inquiry. Should the indexer be [string]: string
, then all string singleton keys will be filled with string as their value type within that table. Try:
type A = {[string]: string, b: number} & {a: number}
local a: A
a.a = 1 -- a: never (the type message is very long)
Then try:
type A = {b: number} & {[string]: string, a: number}
local a: A
a.a = 1 -- No longer a type error here
I must note that if a key’s type isn’t specified by the indexer’s key type, then it will use the default type. So if the indexer is [number]: string
in our previous two examples, we will get wildly different results, the exact same results that would have occurred whenever there was no indexer. For some odd reason, [unknown]: string
does not produce the expected results, but [any]: string
does, so maybe there are more layers to this.
The question was moreso what an indexer of nil meant, since it must mean something.
That is the point, nil and number have no similar points, so an intersection thereof would become nothing, or never
. My above statements should hopefully help in your understanding. But to clarify, I was saying that we need to know the default indexer of any given table without an indexer specified such that we may correctly interpret the resulting simplified type. Some would assume that since there is no key a
in one of the tables, the type for that key would be inferred as nil by default, since if we print out the value of a
we get nil. I was playing into that incorrect assumption to showcase that knowing the default indexer is important to understand how table relationships work.
Your points are correct.
Indexers in intersections are highly dependent on the structure of the intersection.
In the first example, the indexer [string]: string
, is in the first table type, meaning if a property does not exist in the first type, it will always result to string
. (If the indexer is a string
) This is why when we index the first table type with a
, we get string
. However, in the second type, indexing with a
results in number
, which means we get number & string
, which results in never
.
In the second example, however, the first type is a sealed table type, which has an indexer of nil
. Because the key a
also does not correspond to a type in this table, it moves on to the second type of the intersection, which it sees that the key a
now results in the type number
, and thus a.a
results in number
. In this example, inference did not look at the indexer, since the key a
already corresponds to a certain type.
This is the reason why number & nil
does not occur, indexer being nil
means the table does not have an indexer, which means the index operation will fail if the index does not correspond to a type in the table. In intersections, inference will simply move on if the indexer does not have a corresponding type within the table type. Though I should clarify it a bit further in the original post.
It is not an odd reason, a.a == a["a"]
which means the indexer a
is a string
, and string
is not unknown
, unknown
will not let itself be used by other types, unlike any
.