typescripttype-inference

Why does Typescript only widen the return type from a Promise.catch() but not from a synchronous catch?


Steps to reproduce:

  1. Define a function that returns a promise of string or false.
  2. Call and return an async function and return false from the catch method.

Problem:

Typescript claims it doesn't match the type definition because true is not assignable to the type string|false. This only seems to occur in the catch of a promise.

Question:

Why does the type widen to boolean only in the catch of the async example?

Further thoughts:

When returning false as false from the async function's catch method, this makes the error go away. It is not necessary to write as false in the then() method, only the catch(), why?

Code example:

const log = (...data:(
    |string
    |Error
)[]):Promise<string|false> => {

    return fetch('/logs', {
        method: 'POST',
        body: 'minimal reproducible example',
    })
    .then(r=>{
        if(!r.ok){
            return false
        }else{
            return 'stuff and things'
        }
    })
    .catch(e=>{
        return false // `as false` resolves the warning.
    })
    .finally(()=>{
        console.log('ok')
    })
}

Error message:

Type 'Promise<boolean | "stuff and things">' is not assignable to type 'Promise<string | false>'.

Type 'boolean | "stuff and things"' is not assignable to type 'string | false'.

Type 'true' is not assignable to type 'string | false'.(2322)

No quick fixes available

Synchronous example:

A non async catch returning false does not widen to true|false:

// No warnings:
const log = (...data:(
    |string
    |Error
)[]):string|false => {
    try{
        if(Math.random()>.5){
            throw 'Error'
        }
        return 'stuff'
    }catch(e){
        return false
    }
}

Solution

  • The values true and false in TypeScript have corresponding literal types also called true and false. But quite often when people write true and assign it to a variable or return it from a function, they intend for the type of that variable or the return type of the function to be the boolean type. So usually, TypeScript's inference algorithm will widen true or false to boolean (which is actually defined as the union true | false). But sometimes this isn't what people want, and TypeScript uses some heuristic rules to determine when true/false or other literals should remain narrow. If the value is in a context that includes boolean or true or false, then boolean literals tend to remain narrow. Details are documented in microsoft/TypeScript#10676.

    Generally speaking when you have a situation where a type is undesirably widened, it's more important to fix it than to know exactly why. The fix for literal values is often to use a const assertion, since false as const is explicitly providing the context for narrower types.

    But here you're asking why, so let's go through the cases.


    In the callback for then() you've got

    {
        if (!r.ok) {
            return false
        } else {
            return 'stuff and things'
        }
    }
    

    which, according to microsoft/TypeScript#10676, gets a union of literal types:

    • In a function with no return type annotation and multiple return statements, the inferred return type is a union of the return expression types.
    • In a function with no return type annotation, if the inferred return type is a literal type (but not a literal union type) and the function does not have a contextual type with a return type that includes literal types, the return type is widened to its widened literal type.

    It's a union because there are multiple return statements, and it's unwidened because literal union types are not widened.


    In the callback for catch() you've got

    e => { return false }
    

    which by the last bullet point above, is widened from false to boolean.


    Finally in your synchronous example, you have annotated the function as returning string | false, so each return statement is checked against that. But if you left off the annotation, you'd get a union of literal types again:

    const log2 = (...data: (string | Error)[]) => {
        try {
            if (Math.random() > .5) {
                throw 'Error'
            }
            return 'stuff'
        } catch (e) {
            return false
        }
    }
    // const log2: (...data: (string | Error)[]) => false | "stuff"
    

    So that's "why" it happens, at least to the extent that it is documented to behave that way. As for why the rules were chosen to behave that way, that's not something I can answer except that evidently it meets people's needs in a wide range of real world use cases, and for those cases where it doesn't, you can use const assertions or other techniques to control the types.

    Playground link to code