Skip to content

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions docs/type-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Type Guards
## Summary

This RFC proposes adding type guards to Luau. Type guards are a function that returns a single boolean predicate, narrowing the type of one of their arguments. They can be used exclusively within control flow statements, at the top level of expressions.

```lua
function isFoo(x): x is Foo
return x.type == "foo"
end
```

## Motivation
Luau currently has a powerful inference engine capable of type narrowing. This allows for `if pet.type == "dog" then pet.meow() end` statements to correctly raise type errors.

Type narrowing is however currently limited to inline statements. More complex narrowing conditions must be duplicated everywhere a narrowing is required.

Type guards allow for the narrowing logic for type to be encapsulated within a single reusable function.

In some instances, the logic being performed for narrowing may not be immediately obvious. Consider the following example:

```lua
type LegacyAction = { id: number, metadata: {} }
type ModernAction = { id: string, data: {} }
type Action = LegacyAction | ModernAction

local action: Action = getAction()

if typeof(action.id) == "number" then
-- Use action.metadata
else
-- Use action.data
end
```

A type guard instead allows us to write this code as:

```lua
function isLegacy(x: Action): x is LegacyAction
return typeof(x.id) == "number"
end

if isLegacy(action) then
-- Use action.metadata
else
-- Use action.data
end
```

Additionally, if the behaviour required to discriminate legacy actions changes in the future (for example, a third type is added that returns to using numbers as the ID), only the single guard function needs amended.

Type guards also serve to simplify code when operating with more complex types. For example:

```lua
type Tree = { value: number, left: Tree?, right: Tree? } | nil

function isLeaf(x: Tree): x is { left: nil, right: nil }
Copy link
Contributor

@alexmccord alexmccord Jun 10, 2025

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

Copy link
Author

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.

return x ~= nil and x.left == nil and x.right == nil
end

local t: Tree = getTree()
if isLeaf(t) then
print("Leaf:", t.value)
print(t.left, t.right) -- Both known to be nil by the type solver
end
```

## Design
### Syntax
The proposed syntax is for functions to optionally have a return type of `: x is T`, where `x` must be one of the arguments to the function, and `T` is the type the function narrows to. This amends the grammar to:

```
ReturnType ::= Type | TypePack | GenericTypePack | VariadicTypePack | TypePredicate
TypePredicate ::= NAME 'is' Type
```

Type guards are allowed to be defined a members of tables, and the implicit `self` variable on methods is allowed to be used on self-call functions. The previous tree example could utilise this as:

```lua
local Tree = {}
Tree.__index = Tree
type Tree = setmetatable<{ value: number, left: Tree?, right: Tree? }, Tree>

function Tree.new(value: number): Tree
return setmetatable({ value = value }, Tree)
end
function Tree:isLeaf(): self is { left: nil, right: nil }
return self.left == nil and self.right == nil
end
```

### Semantics
When type guards evaluate as true, the type at the call site should be intersected with the restriction present in the predicate. The above `isLeaf` function called as `if tree:isLeaf() then` results in a new type of `tree: typeof(tree) & { left: nil, right: nil }`, which will simplify to `{ @metatable Tree, { value: number, left: nil, right: nil } }`. (In reality this example currently ends up with some pretty nasty types, but it serves as illustration.)

### Restrictions
Type guards may only be used in the control flow statements `if`, `elseif` and `while`, the condition of an `if ... then ... else ...` expression, and assert statements. Retaining the value of a guard in a variable may lead to stale predicates no longer holding true.

Multiple type guards may be combined using boolean operators. `and` performs intersection, `or` performs union, and `not` performs a compliment. This does not introduce new compliment logic to luau types, rather performing the same basic compliments that can be found in a statement such as `if (not (typeof(foo.x) == "number")) and foo.y == nil then`.

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)`.
Copy link
Contributor

@alexmccord alexmccord Jun 10, 2025

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

Copy link
Author

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.

Copy link
Contributor

@alexmccord alexmccord Jun 11, 2025

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)


### Additional type checks
A type guard returning any value other than a boolean is considered a type error. Within the type function, standard narrowing will be occurring. If the type solver identifies a `return true` but the narrowed subject variable cannot inhabit the type being asserted, a type error is raised. On the contrary, a `return false` has no additional checks performed.

If the annotated type of the subject variable in a type guard's function definition is not assignable to the type in the predicate, a type error is immediately raised.

### Runtime semantics
At runtime, the type information is not retained. The predicates returned by type guards are treated as simple booleans.

## Drawbacks
The restriction to exclusively control flow only prevents some more powerful patterns from being utilised. For example, the following pattern would not work:

```lua
local pet: Pet = getPet()
local wasDog = isDog(pet)
pet:mutateIntoCat() -- Adds meow(), changes the discriminator used by isDog, but retains bark()
if isCat(pet) and wasDog then
print(`My pet can {pet.meow()} and also {pet.bark()}!`)
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`.
Copy link
Contributor

@alexmccord alexmccord Jun 10, 2025

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.

Copy link
Author

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.


## Alternatives
Not adding type guards, retaining the existing status quo, still allows for narrowing of types using inline statements instead.

When more complex logic is desired, programmers could write their own type guard-esque functions, returning booleans, then manually perform casts on types based on the return value.