[DRAFT] Introduction to Flow: Type Refinements

January 2018 ยท 4 minute read

Type Refinements

This is a continuation of the Flow series in which we will be looking into something we have already used but haven’t covered: Type Refinement.

Let’s say we have the following type:

// @flow
type User = {
    id?: string,
    name: string
}
declare var u: User;

console.log(u.id.includes("-"))

It will emit an error:

console.log(u.id.includes("-"))
            ^ call of method `includes`. Method cannot be called on possibly undefined value

The above is easily fixed by checking if id is indeed defined:

/* [...] */
if (u.id) {
    console.log(u.id.includes("-"))
}

In the above scenario, Flow analysed the code and concluded that outside of the if, u.id is potentially undefined while inside, it must be a string.

Consider the following example:

// ...
function logId({ id }: {id: string}) {
    // ...
}

// Will not work
logId({id: u.id})
      ^
      string | undefined

if (u) {
    // Will work
    logId({id: u.id})
                ^
                string
}

There is nothing new in this. There is however, something quite new in this:

// ....
if (u) {
    logId({id: u.id})
    console.log(u.id.includes("-"))
}

will output this:

console.log(u.id.includes("-"))
               ^ call of method `includes`. Method cannot be called on possibly undefined value

Why in the world would u.id become undefined? We just used it!

This is due to a notion mentionned in the very first post of the series: Flow has an extremely pessemist view of your code. It has absolutely no trust for you or your code.

In this case, Flow does not trust you that calling logId() didn’t mutate the u object. And for good reason as nothing prevents my // ... from being id = null or otherwise mutating the object. In fact, there are things which will prevent it in my code snippet, but not in this one:

// @flow
type User = {
    id?: string,
    name: string
}

function logId(entry: { id: ?string}) {
    entry.id = null
}

declare var u: User;

if (u) {
    logId(u)
    console.log(u.id.includes("-"))
}

You may think that marking the id input parameter as read-only with the + sign will make it a non-issue:

function logId(entry: { +id: ?string}) {
    // Empty function
}

Yet the issue will persist. While logically, it should no longer be possible be an issue, yet it is. This is because Flow simply doesn’t analyse that far and simply invalidates whatever refinements you have done after calling a function with a refined object.

The proper way to conserve the refinements is to mark that object as a constant:

// ...
if (u) {
    const { id } = u;
    logId({ id })
    console.log(u.id.includes("-"))
}

In all the examples above, we deal with fairly trivial guarding. However, in some instances we will want to do something involving more complex validation and as such would like to call another function to do it. Imagine the following simplification:

// ...
function ensureId(id: mixed): boolean {
    return (typeof id === 'string')
}
if (ensureId(u.id)) {
    const { id } = u;
    logId({ id })
    console.log(u.id.includes("-"))
}

The will not work because a function inside the if statement itself will invalidate the refinements it is suppose to be making.

To deal with this, there exists a certain type syntax for functions we want to use to refine which amounts to adding “%check” after the function header.

// @flow
function ensureId(id: mixed): boolean %check {
    return (typeof id === 'string')
}

This called a predicate function and they have a very rigid rule: the entire function must be a single logical statement. This means that doing something as simple as…

// @flow
function ensureId(id: mixed): boolean %check {
    let s = 'string'
    return (typeof id === s)
}

…will complain:

Invalid body for predicate function. Expected a single return statement.

This is because, as you probably inferred (pun unintented), the entire issue is due to technical limitations and a single return statement of a function allows direct substition such that inference could work as if a macro was called instead of a function.

Refining a Union of Objects