Search code examples
typescriptcompiler-errorscode-snippetstranspiler

Why is this snippet invalid TypeScript?


Consider the following TypeScript snippet:

function containsWord(): boolean {
    const fullText: string[] = ['This is a sentence with seven words'];
    const word: string = 'word';

    return (
        fullText.reduce((acc, sentence) => acc && sentence.includes(word), true)
    );
}

console.log(containsWord())

When trying to compile this snippet in VS Code or in the TypeScript playground, I get the following error:

Type 'string' is not assignable to type 'boolean'

Which I don't really understand. What's really surprising is that moving the content of the return statement to a variable fixes the issue:

function containsWord(): boolean {
    const fullText: string[] = ['This is a sentence with seven words'];
    const word: string = 'word';

    const result = fullText.reduce((acc, sentence) => acc && sentence.includes(word), true);

    return result;
}

console.log(containsWord());

Or explicitly setting the callback arguments types:

function containsWord(): boolean {
    const fullText: string[] = ['This is a sentence with seven words'];
    const word: string = 'word';

    return (
        fullText.reduce((acc: boolean, sentence: string) => acc && sentence.includes(word), true)
    );
}

console.log(containsWord());

I'm very confused by the fact that the first snippet is invalid while the two others are fine according to TypeScript. It looks like type inference is somehow failing in the first snippet. Shouldn't they all be equivalent?


Solution

  • UPDATE FOR TS4.7+

    This was a design limitation in TypeScript; see microsoft/TypeScript#46707 for details. It was fixed for TypeScript 4.7 by microsoft/TypeScript#48380.


    PREVIOUS ANSWER FOR TS4.6-

    This is a design limitation in TypeScript; see microsoft/TypeScript#46707 for details. It's sort of a cascading failure that results in some weird errors.

    This is a design limitation in TypeScript; see microsoft/TypeScript#46707 for details. It's sort of a cascading failure that results in some weird errors.


    A generally useful feature was implemented in microsoft/TypeScript#29478 allowing the compiler to use the expected return type of a generic function contextually to propagate a contextual type back to the generic function's arguments. That might not make much sense, but it sort of looks like this:

    function foo<T>(x: [T]): T { return x[0] }
    
    const bar = foo(["x"]);
    // function foo<string>(x: [string]): string
    
    const baz: "x" | "y" = foo(["x"]);
    // function foo<"x">(x: ["x"]): "x"
    

    Note how the type of bar is string, since the call to foo(["x"]) infers the type parameter T as just string. But baz has been annotated to be of type "x" | "y", and this gives the compiler some context that we want the T type in the call to foo(["x"]) to be narrower than string, so it infers it as the string literal type "x".

    This behavior has a lot of positive effects in TypeScript, and a few problems. You ran into one of the problems.


    The TypeScript typings for Array.prototype.reduce() look like this:

    interface Array<T> {
      reduce(cb: (prev: T, curr: T, idx: number, arr: T[]) => T): T;
      reduce(cb: (prev: T, curr: T, idx: number, arr: T[]) => T, init: T): T;
      reduce<U>(cb: (prev: U, curr: T, idx: number, arr: T[]) => U, init: U): U;
    }
    

    That's an overloaded method, with multiple call signatures. The first one is not relevant, since it doesn't have an init parameter. The second one is non-generic, and expects the accumulator to be of the same type as the array elements. The third one is generic and lets you specify a different type, U, for the accumulator.

    The one you want here is that third, generic call signature. Let's imagine that's the one the compiler chooses. In your call to reduce(), the compiler expects the return type to be boolean. Therefore the generic type parameter U has a boolean context, and that contextual type propagates back to the init parameter as per ms/TS#29478. The compiler looks at the init parameter being true and infers it to be the literal true type instead of boolean.

    Which is the first problem, because your callback returns acc && sentence.includes(word), which is boolean and not necessarily true:

    let b: boolean = fullText.reduce(
        (acc, sentence) => acc && sentence.includes(word), // error!
        // --------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // boolean is not assignable to true
        true
    );
    /* (method) Array<string>.reduce<true>(
         cb: (prev: true, curr: string, idx: number, arr: string[]) => true, 
         init: true
       ): true */
    

    This is the error you would see if reduce() only had the one call signature. And maybe from this it would be at least vaguely intuitive to employ a the workaround of writing true as boolean for init, or manually specifying U to be boolean like fullText.reduce<boolean>(...).

    But unfortunately other stuff is going on which makes the error less obvious.


    The compiler doesn't only try the generic call signature; it also tries the non-generic one, where init is expected to be the same type as the array elements. This one also failed, and this time for good reason, since it would mean that init would have to be a string, and that the callback returns a string, and that reduce() returns a string, which is wrong in a bunch of ways:

    b = fullText.reduce( // error!
    // <-- string is not assignable to boolean
        (acc, sentence) => acc && sentence.includes(word), // error!
        // --------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        // boolean is not assignable to string
        true);
    
    /* (method) Array<string>.reduce(
        cb: (prev: string, curr: string, idx: number, arr: string[]) => string, 
        init: string
      ): string */
    

    The compiler tries both overloaded call signatures and they both fail. If you look carefully at the error messages, the compiler is complaining about both of them:

    /* No overload matches this call.
      Overload 1 of 3, '(cb: (prev: string, curr: string, idx: number, array: string[]) => string, 
        init: string): string', gave the following error.
        Type 'boolean' is not assignable to type 'string'.
      Overload 2 of 3, '(cb: (prev: true, curr: string, idx: number, array: string[]) => true, 
        init: true): true', gave the following error.
        Type 'boolean' is not assignable to type 'true'. */
    

    Generally speaking when all overloads fail, the compiler picks the earliest one to use. And that unfortunately means that the call signature that returns string is what the compiler decides happens. So the return statement is, apparently, returning a string in your containsWord() function which is annotated to return a boolean. And that's what causes the error that's confusing you:

    Type 'string' is not assignable to type 'boolean'.
    

    Bizarre!


    Playground link to code