luau/rfcs/syntax-type-predicates.md
2023-03-19 14:33:50 +00:00

3.7 KiB

User-defined Type Predicates

Summary

Support user-defined type predicates, which are runtime functions which a user can create and be used to refine the type of a particular expression in the type checker.

Motivation

The Luau type engine already provides built-in support for type predicates. In vanilla Luau, this can be seen when using typeof(X) == "type". Host environments can also specify their own predicate functions, such as Instance:IsA("ClassName") in Roblox.

These predicate functions can then be used in combination with type guards or assert(X) statements (and, more recently, control flow analysis). For example:

local x: unknown = ...
if typeof(x) == "string" then
    -- x is a string
    x ..= "value"
end

Right now, there is currently no way for a user to define their own predicate functions in code. If a user defines their own type, it cannot be easily refined through the type checker, without using a type assertion or just "assuming" the type is already correct. This is particularly prevelant in environments such as unsecure network code.

Consider the following function which is called by a uncontrolled client (i.e., any arguments can actually be passed into it):

type Pet = {
    name: string,
    age: number,
}

local function processClientEvent(pet: unknown)
    -- there is no way here to assert that pet is of type Pet
end

Design

We propose a new syntax for return types in a function: variable :: type, where variable must match the name of a function parameter. In context:

type Pet = {
    name: string,
    age: number,
}

local function isPet(x: unknown): x :: Pet
    return typeof(x) == "table" and typeof(x.name) == "string" and typeof(x.age) == "number"
end

Function type syntax can follow a similar structure:

type isPet = (x: unknown) -> x :: Pet

We can then use this in practice:

local function processClientEvent(pet: unknown)
    assert(isPet(pet), "invalid argument #1 to 'processClientEvent' (expected 'Pet')")
    -- here we now know that pet is of type Pet
    print(pet.name) -- no error
    print(pet.age) -- no error
    print(pet.size) -- TypeError
end

User defined type predicates can also be used in type guards, and control flow:

local function processClientEvent(pet: unknown)
    if isPet(pet) then
        -- pet is "Pet"
    end
    -- pet is "unknown"
end

local function processClientEvent(pet: unknown)
    if not isPet(pet) then return end
    -- pet is "Pet"
end

Drawbacks

One problem with user-defined type predicates, is that they do not completely solve the unsoundness issue, if the user incorrectly defines their type predicate function.

For example:

type Pet = {
    name: string,
    age: number,
    size: number,
}

local function isPet(x: unknown): x :: Pet
    -- bug: we have not verified that x contains a property "size"
    return typeof(x) == "table" and typeof(x.name) == "string" and typeof(x.age) == "number"
end

local function processClientEvent(pet: unknown)
    assert(isPet(pet))
    pet.size += 5 -- runtime error: attempt to perform arithmetic (add) on nil and number
end

processClientEvent({ name = "Cat", age = 5 })

Alternatives

We could follow the syntax of TypeScript type predicates:

local function isPet(x: unknown): x is Pet
    -- ...
end

This syntax could be feasible, with is being a context-sensitive keyword. We choose the type assertion operator :: to remain consistent with other syntax, and follow the idea that we are "asserting" the type of a parameter.

To fix the unsoundness drawback above, Luau could instead automatically generate type predicates for all custom defined user types.