-
Notifications
You must be signed in to change notification settings - Fork 59
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`
end In 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 ...
end
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 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.
|
||
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
end
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 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 |
Rendered
Adds type guards of the form
which can be used in control statements and assertions.