javascripttypescripttypesfrontendtypescript-generics

Why is the return type of a generic TypeScript function inferred differently for a variable initialization and an assignment?


Given a Typescript function with a generic type that is used for the return type and has a default value, the return type of the function is inferred differently when using the function's return value to initialize a variable and assigning it to a typed variable.

Here is a simplified example :

function test<T extends Element =  HTMLElement>(elementName:string):T{
  // irrelevant, just returning something
  return document.createElement(elementName) as unknown as T;
}

const e1 = test('div'); // fine, e1's inferred type is HTMLElement
let e2: HTMLElement | undefined;
e2 = test('div'); // Type error

In the first case (e1), everything is fine, the default generic type HTMLElement is used and the type of e1 is inferred to HTMLElement.

In the second case, the type of the return value is inferred to Element and it causes a type error, as Element can't be assigned to HTMLElement.

Why is the behavior different? Why is the generic default value not used in the second case? Is it expected?

Example in TS playground


Solution

  • If the type parameters in a generic function have any default type arguments, they will only ever be used if the type argument inference algorithm doesn't come up with any viable candidates. That is, you either get type argument inference, or you get the type argument default, but not a combination of the two.

    For const e1 = test('div'); there is nowhere from which T can be inferred, so you get the default, as expected.

    But that's not the case for let e2: HTMLElement | undefined = test('div');.


    Function return types are inference targets as implemented in microsoft/TypeScript#16072. That means if a generic function has a return type that depends on a generic type parameter, it's possible that TypeScript could use the contextual type of the result of a call to that function as a way to infer the type argument:

    function foo<T>(): T[] { return [] }
    const strs: string[] = foo(); // T inferred as string
    //    ^? const strs: string[]
    const nums: number[] = foo(); // T inferred as number
    //    ^? const nums: number[]
    

    Of course if there is no contextual type, then this won't happen:

    const oops = foo(); // T inference fails, you get unknown[]
    //    ^? const oops: unknown[]
    

    So with let e2: HTMLElement | undefined = test('div'), TypeScript sees that the return type T is going to be assigned to HTMLElement | undefined. So HTMLElement | undefined is the inference candidate and you don't get the default.

    But you don't get HTMLElement | undefined either, because unfortunately HTMLElement | undefined fails to meet the constraint of Element. So then TypeScript falls back to the constraint, and now T is inferred as Element instead, and the call succeeds. But now the assignment fails, because Element is not assignable to HTMLElement | undefined.

    This is behaving as designed. It would perhaps be nice if TypeScript would fallback to the default instead of the constraint, but that doesn't happen. This isn't a bug in TypeScript, but it's certainly not making you happy here.


    That's why it's happening, which answers the question as asked. As for how to deal with it, that depends on use case:

    If the return type inference is never desirable, then you can use the NoInfer utility type to block it:

    declare function test<T extends Element = HTMLElement>(
        elementName: string): NoInfer<T>;
    
    const e1 = test('div'); // okay
    let e2: HTMLElement | undefined = test('div'); // okay
    

    If you sometimes want return type inference, then you'll need things to become more complicated and there might well be edge cases that won't work. One possibility is to loosen your constraint so that T and Element just have some kind of overlap, and use the Extract utility type to restrict the return type to a subtype of Element:

    declare function test<T extends (
        Extract<T, Element> extends never ? never : unknown
    ) = HTMLElement>(
        elementName: string
    ): Extract<T, Element>;
    
    const e1 = test('div'); // okay, T is HTMLElement
    let e2: HTMLElement | undefined = test('div'); // okay, T is HTMLElement | undefined
    const e3 = test<number>('div'); // error, number has no overlap with Element
    

    And maybe other use cases would need some other solution... but at this point I'm just wildly conjecturing so I'll stop.

    Playground link to code