-
Notifications
You must be signed in to change notification settings - Fork 66
RFC: Type Guards #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
RFC: Type Guards #124
Conversation
|
Could we get this as a sort of typecast as well/instead? This could allow user-defined narrowing in a more natural inline manner. For example: type Entry = File | Directory | Symlink
local entry = getentry()
if str.endswith(entry.path, ".luau") :: entry is File then
print(entry:read()) -- a method only on `File`
endIn this case we want to assume the entry is a File if it ends with |
|
In terms of as well, I think that would be cool, though potentially worth a separate RFC. In terms of instead, you then lose many of the benefits of having dedicated guard functions, so I don't think I'd want to not have those. |
|
Is there a backwards compat issue with using Edit: On second thought |
|
Unfortunately you can't use But, eugh. Another case not being mentioned here is the ambiguity when a field of some table is a refinement target in these types. We would therefore need to hand-write new syntax that limits what gets parsed to just identifiers, no fields. This also avoids the left recursion problem when reusing |
| ```lua | ||
| type Tree = { value: number, left: Tree?, right: Tree? } | nil | ||
|
|
||
| function isLeaf(x: Tree): x is { left: nil, right: nil } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, see this part. It parses as a function call for some global is, with a table literal whose fields are obviously not a valid parse.
function isLeaf(x: Tree): x
is({ left: nil, right: nil }) -- error
-- no backtracking, and ambiguous even if
-- there were for some types, like strings.
return ...
endThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an outstandingly good point and I feel silly for not having noticed it. Do you think foo(...): (x is Bar) would work? It feels a little icky, but might be the simplest approach. I'm not sure I'd want to introduce any new special symbols for this, and a type function wouldn't properly convey the special semantics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe x = Bar?
function isLeaf(x: Tree): x = { left: nil, right: nil }
return ...
endI don't like to use = for types, but maybe we can justify this because the left-hand side is still a variable?
|
|
||
| Assigning the value of a type guard to a variable (`local foo = isCat(x)`) or its use in a more complex expression (`foo(isCat(x))`) is not disallowed, though the predicate returned by the type guard is demoted to a simple boolean value and no longer serves the narrow the type of the subject variable. It is suggested that lint rules may be used to warn about these cases. | ||
|
|
||
| Type guard functions are not permitted to have multiple return values. `function foo(x): (x is number, string)` is disallowed, as is `function foo(x): (x is number, x is string)`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks a bit like an arbitrary choice.
function foo(x): (x is number, x is string)
return typeof(x) == "number", typeof(x) == "string"
end
function bar(x: unknown)
local is_num, is_str = foo(x)
if is_num then
-- x : number
elseif is_str then
-- x : string
else
-- x : ~number & ~string
end
endThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to allow predicates to be stored as normal variables, but consider what happens if you make a modification to x after the call to foo but before the use of your predicate booleans. Your predicates are now meaningless. We can't simply track things that might change, as the purpose of type guards is to also allow more complex logic than can be trivially identified by the type narrowing system. This is why the use of type functions was exclusively restricted to control flow statements, and as such any return values other than the first would be ignored. Disallowing multiple return values nips this footgun at the bud.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's actually okay. Type refinements store a proposition to refine a specific version of a variable, so even if you update some variable and then apply an outdated proposition, it doesn't affect any variable whose version did not match. This gives us the effect of invalidating any outdated propositions (even if the type system will still commit those refinements)
| end | ||
| ``` | ||
|
|
||
| This is an acceptable compromise, as situations like these are uncommon. We also have no guarantee that `pet` was not further mutated to remove `bark()`, so `wasDog` can no longer be relied upon. `isDog()` could instead be replaced with a `canBark()` type guard, giving `if isCat(pet) and canBark(pet) then`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, effect systems are necessary to know which refinements survives any invalidation from side effects, but the top effect in the lattice of effects describes every effects. This means all functions, indexing, even something as basic as equality, will invalidate all refinements in relation to any globals and possibly all locals (except primitives that doesn't have mutation, e.g. strings and numbers, but not tables) since they could be reachable by some insidious function that mutates everything. Obviously that's just unusable, so you need to make a pragmatic call to assume the user isn't doing anything crazy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is why the decision was made to exclusively allow type guard calls to be used in control flow (and similar statements like assert). I was unable to come up with any system of rules to allow their use elsewhere that wouldn't immediately break if you looked at it wrong; incorrect types are more dangerous than no types at all.
|
Looking at this RFC again, I don't like that it's mixing up the runtime return type of the guard function (a boolean), with what it actually means to the user (that it refines x into Foo). Since type guard functions work moreso like type casts than type annotations, I feel that annotation syntax here might be confusing for users.
I think a many if not most of the usecases of type guard functions can already be solved (albeit less ergonomically) by casts, putting methods like |
Think of it this way, the runtime value of type |
|
Would it be better to do a special type function instead called Also I think assert is a more clear term rather than |
|
I don't think we can represent this as a type function. Parameters like |
|
One thing this RFC needs is a different kind of |
Wouldn't that mean any |
I don't think so. If The key is that some propositions can be applied positively and negatively (logical equivalence, the same kind of refinements this RFC wanted to see implemented), and some others can only be applied positively and never negatively (logical implications, e.g. |
Right. I think I understand it now and there is actually only two cases here: the case where Your syntax using |
|
Ah. Yeah. I 100% should have written down |
I think that's an interesting syntax for it, but I'm just wondering what happens if you have another type that isn't boolean on the left-hand side. Would it keep the same behavior but truthy and falsy instead? But if that's the case then I don't see why even have the boolean there, since any value can be truthy or falsy. With the implication syntax you proposed, I could also see how the left-hand side could be extended to other types as well, like maybe a function that only returns a |
|
That's kind of the same idea that I had with overloaded functions. |
That's beautiful. I really like that syntax. I also think the arrows work really well for implication since that's also the symbol for implies (⇒) in mathematics (I immediately read it as implies). But I think we definitely need some better syntax for the other case |
|
Crazy idea. Drop the |
I think this works even for type casts. Using @deviaze's example: local entry = getentry()
if str.endswith(entry.path, ".luau") :: entry: File then
print(entry:read()) -- a method only on `File`
endIs there any ambiguity here? I don't think so? |
|
No ambiguity, I'm pretty sure. |
It is kinda, as it looks like its casting onto the return of local entry = getentry()
if str.endswith(entry.path, ".luau") then
entry :: File
print(entry:read()) -- a method only on `File`
end |
|
Oh yeah, type casts as statements are definitely interesting, but it's just nice to know that the syntax works even in casts. And yeah it would actually be casting the return of if condition(entry) or str.endswith(entry.path, ".luau") :: entry: File then
-- .. snip ..
endIn this case, entry is only a file if |
|
How would this work with user defined type functions? |
-- For x: T syntax
function types.implies(variable: string, is: type): type
-- For T => x: U syntax
function type:implies(variable: string, is: type): typeThis should probably only work for variable names defined in user-defined type functions, for example with #137 |
Rendered
Adds type guards of the form
which can be used in control statements and assertions.