typescripttypesdiscriminated-union

Why do TS conditional returns require ambiguous casts over precise casts?


I have a function which should conditionally return either an array of elements or a single element depending on the argument type

    get<T extends Base>(argument: Class<T>): T extends Child ? T[] : T {
        if (argument instanceof Child) {
            return this.arguments as T[]
        }
        return this.arguments[0] as T
    }
}

However, the as T[], as T raise the alerts

Type 'T[]' is not assignable to type 'T extends Child ? T[] : T'.ts(2322)
Type 'T' is not assignable to type 'T extends Child ? T[] : T'.
  Type 'Foo' is not assignable to type 'T extends Child ? T[] : T'.ts(2322)

I can fix this by replacing them with T extends Child ? T[] : T

    get<T extends Base>(argument: Class<T>): T extends Child ? T[] : T {
        if (argument instanceof Child) {
            return this.arguments as T extends Child ? T[] : T
        }
        return this.arguments[0] as T extends Child ? T[] : T
    }

but that is repetitive, and actually seems less accurate and semantically descriptive to what the code is doing.

Why doesn't TS accept the as T[], as T?


Solution

  • Currently TypeScript cannot use control flow analysis to affect the apparent constraint of a generic type parameter. If you check a value t of a generic type T, the compiler might narrow the type of t to something narrower than T, but it will not do anything to T itself:

    function foo<T>(t: T): T extends string ? "a" : "b" {
        if (typeof t === "string") {
            t // (parameter) t: T & string;
            return "a"; // error!
        }
        return "b"; // error!
    }
    

    Here t has been narrowed but T has not. Since neither "a" nor "b" is assignable to T extends string ? "a" : "b" for generic T, both return lines produce an error.


    It turns out not to be easy to implement some kind of control flow analysis on generic type parameters. If you establish that t is a string, does it mean that T is some subtype of string? No. T could be wider than string, or some type that overlaps with string. All we know is that T & string is not never:

    foo<unknown>("x"); // T is wider than string
    foo<"x" | number>("x"); // T has overlap with string
    

    This issue becomes even more apparent if there are multiple values of type T:

    function bar<T>(t: T, u: T): void { }
    bar<unknown>("x", 123);
    bar<"x" | number>("x", 123);
    

    Here you cannot use the fact that t is a string to conclude that u is a string.

    There are times when it's safe to assume that the type of t can be used to reconstrain T, but identifying those and implementing it isn't trivial. There have been various suggestions to try to support something like this, such as microsoft/TypeScript#27808 asking for a way to prohibit type arguments from themselves being unions, or microsoft/TypeScript#33014 asking for this behavior specifically when using generic keylike types and indexed access types.

    The general suggestion is microsoft/TypeScript#33912 asking for some way to return generic conditional types. It's been open for quite a while and although it continues to be discussed by the TypeScript team, it's not clear whether or not it will ever be addressed.


    For now, all you can do is work around the problem by loosening the type checking. You can use type assertions, as you've seen. This is a bit tedious if you have multiple return statements. Another approach is to use a single-call-signature overload:

    function foo2<T>(t: T): T extends string ? "a" : "b";
    function foo2(t: unknown) {
        if (typeof t === "string") {
            return "a"; // okay
        }
        return "b"; // okay
    }
    

    This works because function overload implementations are checked fairly loosely against their call signatures. It's no safer than type assertions, just possibly more convenient.

    Playground link to code