TypeScript: Generics extending unions cannot be narrowed

TypeScript Version: 2.2.0-dev.20170126

Code

declare function takeA(val: 'A'): void;
export function bounceAndTakeIfA<AB extends 'A' | 'B'>(value: AB): AB {
    if (value === 'A') {
        takeA(value);
        return value;
    }
    else {
        return value;
    }
}

Expected behavior: Compiles without error.

Actual behavior:

Argument of type ‘AB’ is not assignable to parameter of type ‘“A”’.

It works correctly if I just use value: 'A' | 'B' as the argument to bounceAndTakeIfA, but since I want the return value to type-match the input, I need to use the generic (overloading could do it, but overloading is brittle and error-prone, since there is no error-checking that the overload signatures are actually correct; my team has banned them for that reason). But (I’m guessing) since AB extends 'A' | 'B', narrowing doesn’t happen. In reality, I am just using extends to mean AB ⊂ 'A' | 'B', but extends can mean more than that, which (I suspect) is why TS is refusing to narrow on it.

Some alternative to extends that more specifically means ⊂ (subsets maybe?) would probably be the best solution here?

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 188
  • Comments: 56 (12 by maintainers)

Most upvoted comments

This issue is now fixed in #43183.

It’s somewhat obvious what the “right” thing to do is, but would require a large rework of how we treat type parameters, with concordant negative perf impacts (…)

@RyanCavanaugh I feel like prioritizing speed over a sound type-system is kind of a contradiction here. We use TypeScript to have a type-system. If there are large gaps in it’s type-system, and I do consider this a large gap, TypeScript should correct them. Every omission like this hurts its usefulness as a tool we can rely on. It is its one, singular job to reason about types correctly.

That said: two years ago, it might’ve been true that this was not a common pattern. But two years later, these patterns are now everywhere. There’s even examples of these kinds of generics in TypeScript’s standard definitions and utility types.

The thing is, definitions don’t necessarily check against an implementation. You can just say something takes a type-parameter with a union type constraint, and this issue is essentially swept under the rug from a user-facing perspective. The external API-level side all type-checks fine.

But the pattern can’t be repeated in a real function implementation and have those types do their intended work. We should not need to slap // @ts-ignore - TS being dumb again to force it to swallow its own valid types.

It’s really time to fix this one. At least TS has incremental compilation now, so hopefully speed isn’t an issue now either.

TL;DR from design discussion: It’s somewhat obvious what the “right” thing to do is, but would require a large rework of how we treat type parameters, with concordant negative perf impacts, without a corresponding large positive impact on the actual user-facing behavior side.

If new patterns emerge that make this more frequently problematic, we can take another look.

Here is an example that demonstrates the difference in behaviour between non-generic and generic functions:

type Common = { id: number };
type A = { tag: 'A' } & Common;
type B = { tag: 'B' } & Common & { foo: number };

type MyUnion = A | B;

const fn = (value: MyUnion) => {
    value.foo; // error, good!
    if ('foo' in value) {
        value.foo; // no error, good!
    }
    if (value.tag === 'B') {
        value.foo; // no error, good!
    }
};

const fn2 = <T extends MyUnion>(value: T) => {
    value.foo; // error, good!
    if ('foo' in value) {
        value.foo; // error, bad!
    }
    if (value.tag === 'B') {
        value.foo; // error, bad!
    }
};

@RyanCavanaugh please consider fixing this again

  • tsc version: 3.2.2

I have encountered this problem. It’s much odd because it says “‘a’ does not exist” even if it’s inside of if ("a" in t) block.

// Definition
type A = { a: number }
type B = { b: number }
type AorB = A | B

// Try
function f<T extends AorB>(ab: AorB, t: T): void {
    if ("a" in ab) {
        ab.a
    }
    if ("a" in t) {
        t.a // ⚠️ `a` does not exist
    }
}

Playground

4 years later. I’m gonna cry I think 😃

@RyanCavanaugh, I have a more hypothetical question 🙂 Are you open to receiving suggestions for this issue? In principle, I would be interested in looking into this, but I have a few questions.

  • From your previous comment,

    would require a large rework of how we treat type parameters

    I gather that you think fixing this would be a large undertaking, but how big of an undertaking you think it would be? From everything, you can see, do you think it would be feasible to come up with a first draft within a month?

  • Are there any higher-level concepts that would have to be affected by a fix?

  • Of course, better type inference here would certainly incur some kind of performance cost, but are there any performance-pitfalls you can already see us running into with a fix?

Thank you in advance for spending time on those questions!

@RyanCavanaugh is there any plan to fix that? I encounter it a lot and it would be great if you could address that.

This would be great for specializing Props to React components. Contrived example:

type Type = 'a' | 'b';
type AShape = { a: 'a' };
type BShape = { b: 'b' };
type Props<T extends Type> = {
  type: T,
  shape: T extends 'a' ? AShape : BShape,
};

class Test<T extends ID> extends React.Component<Props<T>> {
  render() {
    const { type, shape } = this.props;
    switch (type) {
      case 'a':
        return <>{shape.a}</>; // Ideally would narrow `shape` here, instead of `AShape | BShape`
      default:
        return <>{shape.b}</>;
    }
  }
}

<T type="a" shape={{ a: 'a' }} /> // No error in ideal case
<T type="a" shape={{ b: 'b' }} /> // error in ideal case

Got the same (I think) problem with a more elaborated example:

interface WithNumber {
  foo: number;
}

interface WithString {
  bar: string;
}

type MyType = WithNumber | WithString;

interface Parameter<C extends MyType = MyType> {
  container: C
} 

function isParameterWithNumberContainer(arg: Parameter): arg is Parameter<WithNumber> {
  return typeof (arg.container as WithNumber).foo === "number";
}

function test(arg: Parameter<WithNumber | WithString>) {
  if (isParameterWithNumberContainer(arg)) {
    arg.container.foo;
    return;
  }
  /*
   * Error:
   *   Property 'bar' does not exist on type 'MyType'.
   *     Property 'bar' does not exist on type 'WithNumber'.
   */
  arg.container.bar;
}

In my opinion, it is impossible that arg will be something else than a Parameter<WithString>. Typescript however still thinks that it is a Parameter<WithNumber | WithString> and thus throws an error when trying to access arg.container.bar.

Another simple example of a problem. With generic extending a union type, automatic type guards don’t work. See code in the playground.

function fn1<T extends "a" | "b" | "c">(t: T) {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error not highlited
    }
  }
}

function fn2(t: "a" | "b" | "c") {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error highlited
    }
  }
}

A very high proportion of comments here are misunderstanding a fundamental aspect of how union-constrained generics work.

It is not safe to write code like this

function f<T extends number | string>(x: T, y: T): string {
    if (typeof x === "string") {
        return y; // <- No!
    }
    return "ok";
}

because a caller can legally write

f<number | string>("hello", 42);

Narrowing one variable of a type parameter’s type does not give you sound information about another variable of the same type parameter’s type.

same issue, different example:

interface AllowedMapTypings {
    'str': string;
    'lon': number;
}

const obj: AllowedMapTypings = {
    'str': 'foo',
    'lon': 123
};

function foo<T extends keyof AllowedMapTypings>(key: T): AllowedMapTypings[T] {
    return obj[key];
}
let str = foo('str'); // this works fine, str is a string

function fn<T extends keyof AllowedMapTypings>(key: string, kind: T, value: AllowedMapTypings[T]) {
    if (kind === 'str') {
        console.log(value.length); // Property 'length' does not exist on type 'AllowedMapTypings[T]'.
    }
}

I realize this issue is closed, but I’m still getting this behavior. I’ll try and post a minimal working example but the full usage can be seen here.

TS playground link here

I have this type, PrimitiveOrConstructor:

interface typeMap { // for mapping from strings to types
  string: string;
  number: number;
  boolean: boolean;
}

type Constructor = { new(...args: any[]): any };

type PrimitiveOrConstructor =
  | Constructor
  | keyof typeMap;

and I’m using it as the generic constraint in this function, and I have to assign the variable with type T extends PrimitiveOrConstructor in order to get narrowing:

function typeGuard<T extends PrimitiveOrConstructor>(o: unknown, className: T): boolean {

  const localPrimitiveOrConstructor: PrimitiveOrConstructor = className;

  if (typeof localPrimitiveOrConstructor === 'string') {
    return typeof o === localPrimitiveOrConstructor;
  }

  return o instanceof localPrimitiveOrConstructor;
}

If I try to skip using the type variable it doesn’t work, even if I define an explicit type guard function:

function typeGuard<T extends PrimitiveOrConstructor>(o: unknown, className: T): o is GuardedType<T> {

  if (isPrimitiveName(className)) {
    return typeof o === className; // className inferred as T & keyof typeMap
  }

  return o instanceof className; // error here
}


function isPrimitiveName(className: PrimitiveOrConstructor): className is keyof typeMap {
    return (typeof className === 'string')
}

@jhnns This is actually more closely related to another issue I’d raised, #21879. Basically, Typescript never considers the narrowing of a value, e.g. value as inplying anything about the type, e.g. T, that value has. The reason why is this:

interface A { foo: 42; }
interface B { bar: 'hello world'; }
declare function isB(value: A | B): value is B;

type Foobar<T extends A | B> = T extends A ? 'foo' : 'bar';

function demo<T extends A | B>(value: T): Foobar<T> {
    if (isB(value)) {
        return 'bar'; // errors
    }
    else {
        return 'foo'; // errors
    }
}

// here is the reason those lines error
const foo: 'foo' = demo({ foo: 42, bar: 'hello world' }); // does NOT error, but foo would be set to 'bar'

In that function call, T is A & B, which means it will pass isB and return 'bar'. But the definition of Foobar says that Foobar<A & B> should be 'foo' instead.

The “solution,” such as it is, is to use casting. Whether you do that by defining a particular type (e.g. MapNullUndefined or by just writing out as T extends null ? undefined : T, either works, but you basically are forced to tell the compiler that you have done this right, which means the compiler cannot correct you if you have not, in fact, done it right.

Which drastically limits the value of conditional types, in my opinion, since they will almost-always, necessarily, be unsafe at least within the internals of the function. And plenty of cases where A & B is never exist, which could be handled safely, but the TS team seems to be uninterested in going down that road.

Can confirm generic union type guards aren’t working

Playground link

type Types = "1" | "2" | "3"

type XYZ<T extends Types> = {
  type: T;
  x: T extends "1"
    ? number
    : T extends "2"
    ? string
    : T extends "3"
    ? boolean
    : never;
};

function y<T extends Types>(x: XYZ<T>) {
  if(x.type === '1') {
    x.x + 1 // fails because it's number or string or boolean
  }
}

Another simple example of a problem. With generic extending a union type, automatic type guards don’t work. See code in the playground.

function fn1<T extends "a" | "b" | "c">(t: T) {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error not highlited
    }
  }
}

function fn2(t: "a" | "b" | "c") {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error highlited
    }
  }
}

Another example. Code

function add(a: string | number, b: number) {
  if (typeof a === "string") {
    return `${a}${b}`
  }
  return a + b // ✔
}

function add2<T extends string | number>(a: T, b: number) {
  if (typeof a === "string") {
    return `${a}${b}`
  }
  return a + b // ⚠ Operator '+' cannot be applied to types 'T' and 'number'
}

Thank you for the response!

To me that looks like self-sabotage by intentionally casting to something not intended? I’m not sure if I would consider that a case that needs defending against for that example, but I’m very likely wrong 😅

I guess this spawns 2 questions for you:

  1. Is this sort of narrowing even possible/supported in TS?
  2. If it is. Is there further constraint that I need to make this safe? Or how can this be done? (Does it have a name?)

Hooray!

But ugh I think I’ve been referencing this issue for years now without realizing that this issue is specifically about narrowing values whose types are generic, and not about the type parameters themselves. Looks like I’ll have to go change a bunch of my SO answers to point to something more appropriate like #24085, #25879, #27808, and #33014.

Trying to work around the fact that generic types extending union types don’t get narrowed, I came up with the following code:

type Union = "A" | "B";

function differentiate(value: "A"): Array<"A">;
function differentiate(value: "B"): Array<"B">;
function differentiate(
  value: Union
): Array<"A"> | Array<"B"> {
  switch (value) {
    case "A":
      return [value];
    case "B":
      return [value];
  }
}

const arrayA = differentiate("A"); // resulting type is Array<"A"> ✅
const arrayB = differentiate("B"); // resulting type is Array<"B"> ✅

function calling(u: Union) {
  differentiate(u); // ⛔ Argument of type 'Union' is not assignable to parameter of type '"B"'.
  return u === "A" ? differentiate(u) : differentiate(u); // Works but problematic when Union includes more litterals
}

I would have expected this code to work:

type Union = "A" | "B";

function differentiate<T extends Union>(
  value: T
): Array<T> {
  switch (value) {
    case "A":
      return [value]; // value should be of type "A"
    case "B":
      return [value]; // value should be of type "B"
  }
}

const arrayA = differentiate("A"); // resulting type should be Array<"A">
const arrayB = differentiate("B"); // resulting type should be Array<"B">

function calling(u: Union) {
  return differentiate(u); // this call should be accepted
}

Thank you in advance for any input you may have!

By using return num * 2 as T; and return () => num() * 2 as T;, unfortunately. There is no better way, because while TS will narrow num it won’t narrow T for you.

But extends already does mean subset. If I’m not mistaken, extends is simply TypeScript’s implementation of bounded quantification.

Regarding:

but extends can mean more than that

Do you have a specific example of behavior where you’d do T extends U for some T not assignable to U?

I think this is related:

type Payload = {
    data: {
        id: string;
        hi: {
            id: string;
        };
    }
}

type Filter<T extends Payload> = {
    [K in keyof T]: T[K] extends { id: any } | undefined | null ? K : never;
};
type HasId<T extends Payload> = Filter<T>[keyof T];

const genericVersion = <T extends Payload> (something: T): HasId<T> => {
    return 'data'; // Errors
}

const staticVersion = (): HasId<Payload> => {
    return 'data'; // Success
}

const main = () => {
    let d = genericVersion({ data: { id: '', hi: { id: '' } } });
}

Basically, it works if there’s a concrete type, but ignores the extends type bound when used as a generic.

This is the work-around I’m using. The key function in the following is the asUnion function, and its example use in discriminateExample. Explanation below.

type Types = {
  'Num': number
  'Str': string
}
type Names = keyof Types // 'Num' | 'Str'
type Inner<T extends Names> = Types[T]

interface Box<T extends Names> {
  type: T
  value: Inner<T>
}

// Example of generic operation on Box
function unboxExample<T extends Names>(box: Box<T>): Inner<T> {
  // `box.value` is correctly inferred with type `Inner<T>`
  return box.value
}

function asUnion<T extends Names>(box: Box<T>): T extends any ? Box<T> : never {
  return box as any
}

function discriminateExample<T extends Names>(box: Box<T>) {
  const { type, value } = asUnion(box);
  switch (type) {
    case 'Num': return value + 1; // `value` narrowed to `number`
    case 'Str': return value + ' text'; // `value` narrowed to `string`
  }
}

For my use case, I have a generic Box<T> container, where T is a type name (Num or Str in the example, to keep things simple).

I have some operations on boxes that I want to implement generically – unboxExample is an example. And I have some operations on boxes where I want to discriminate on the different types of boxes and implement each case individually – discriminateExample is an example. The issue, as raised in this thread, is that it’s hard to do both kinds of operations on the same types: if Box<T> is a union then generic operations require casting, and if Box<T> is not a union then you can’t discriminate on the individual possibilities.

The solution here is the asUnion function, which internally does a cast from the generic form to the union form, but at least the cast is only in one place. The return type of asUnion uses a conditional type to expand out the discriminated union based on all the possible type names T.

For example, if the argument of asUnion is Box<'Num' | 'Str'> then the return type is Box<'Num'> | Box<'Str'>, which is then compatible with TypeScript’s narrowing logic.

image

Same issue here but user defined type: Code


// Type definitions

enum CarType {
    sedan = 'sedan',
    suv = 'suv',
    truck = 'truck'
}

type CarSedan = {
    type: CarType.sedan;
    randomKey1?: string;
}

type CarSUV = {
    type: CarType.suv;
    randomKey2?: number;
}

type CarTruck = {
    type: CarType.truck;
    randomKey3?: string;
}

//. Type guard using the key "type" to differentiate the union types

const isCarTruck = <
    Truck extends CarTruck,
    Others extends CarSedan | CarSUV,
    All extends Truck | Others
>(
    car: All
): car is Exclude<All, Others> =>
    (car as CarTruck)?.type === CarType.truck;


// Subset
type Union = CarTruck | CarSedan | CarSUV


// Example 1: In a normal usage its working

const func1 = (car: Union) => {
    isCarTruck(car)
        ?
        car // CarTruck
        :
        car // CarSedan | CarSUV
}

// Example 2: With generic extends it doesnt work

const func = <T extends Union>(car: T) => {
    isCarTruck(car)
        ?
        car // "Exclude<T, CarSedan | CarSUV>"" ----> CarTruck
        :
        car; // "T extends Union" ----> ?????
}

Quoting the previous comment

Could we get better compiler feedback for this issue? What I got did not lead me to think that it was a limitation.

In case doing the “right” thing proves to be too complex and will take (and most likely it will) a lot of time (or won’t be implemented at all) I’d be grateful (and probably many others) for considering the above suggestion.

I’ve spent most of my day today trying to figure out what mistake I have made in my types, then a couple of hours searching through the internet and in the end stumbled upon this thread to find out it wasn’t me 😅 (ok it was, because I tried to do sth that is not supported but you catch the drift 😅 )

@RyanCavanaugh hi, do you have any updates about this topic?

Maybe something has changed in the two years since you wrote!

but would require a large rework of how we treat type parameters, with concordant negative perf impacts, without a corresponding large positive impact on the actual user-facing behavior side.

It would be good if you could just give us a short information. Thank you very much.

I’m running into this issue a lot, and would love to see it fixed. In particular, I’d love for the type system to be able to track a value of type T both for its relationship with T and its possible (narrowed) type.

My dream would be if both of these errors could go away:

declare function takeA(val: 'A'): void;

export function bounceAndTakeIfA<T extends 'A' | 'B'>(value: T): T {
    if (value === 'A') {
        takeA(value); // error 1
        return value;
    }
    else {
        return 'B'; // error 2
    }
}

const x = bounceAndTakeIfA('A')

A workaround worth noting is using a single overload with the generic type signature, before writing the function without the type parameter:

declare function takeA(val: 'A'): void;

export function bounceAndTakeIfA<T extends 'A' | 'B'>(value: T): T
export function bounceAndTakeIfA(value: 'A' | 'B'): 'A' | 'B' {
    if (value === 'A') {
        takeA(value); // error 1
        return value;
    }
    else {
        return 'B'; // error 2
    }
}

const x = bounceAndTakeIfA('A')

The flaw with this approach, of course, is that the body of the function is not checked to ensure the relationship specified by the type parameter T. It’s also verbose. However, if you are doing casting at each site, you are also losing these guarantees, and being verbose.

Edited to add: It really is a trade-off, because for some functions, a lot of type-checking is lost if you don’t have T in the implementation, and yet more casts may be needed.

How should I write such code then?

type NumberType = (() => number) | number;

function double<T extends NumberType>(
    num: T
) : T {
    if (typeof num === "number") return num * 2;
    return () => num() * 2;
}```

Error `Type 'number' is not assignable to type 'T'.`

Hello!

I think I fell on this same issue. Here’s my example:

function test1<T, K1 extends keyof T | ((a: T) => R), R>(t: T, a: K1) {
  // Compiling Error. It Can't understand a is callable in the first, and a key of T in the second
  return typeof a === 'function' ? a(t) : t[a];
}
function test2<T, R>(t: T, a: keyof T | ((a: T) => R)) {
  // Works as intended
  return typeof a === 'function' ? a(t) : t[a];
}

Playground Link

Still related to this issue, I have an extended example of the above one where I also had a compiler error:

function test1<T, K1 extends keyof T | ((a: T) => R), R>(t: T, a: K1) {
    return typeof a === 'function' ? a(t) : t[a];
}
function test2<T, R>(t: T, a: keyof T | ((a: T) => R)) {
  return typeof a === 'function' ? a(t) : t[a];
}

function max<T, R>(mapper: (a: T) => R) {
  return (a: T[]) => {
    let last: R | undefined;
    a.forEach((x) => {
      const mapped = mapper(x);
      if (last === undefined || last < mapped) {
        last = mapped;
      }
    });
    return last;
  };
}

test1([1, 2, 3], max((x) => x * 2)); // Compiling Error. x is resolved to unknown
test2([1, 2, 3], max((x) => x * 2)); //Work as intended

Playground Link

None of this is a production problem, though. I’m actually studying and playing around with typing and trying to provide the most intelligent inferences I can. I tried to add a feature to this package where some arguments could be a keyof something or a function mapper. This is where I fell in this limitation.

Just a minimal example that raises the error. I really think that it should be solve. On my case, it has a huge impact and force me to introduce a lot of assertions and type casting within my code, leading to a huge part of code not being checked correctly by the typing system…

type A = {
    testable: true
    doTest: () => void
}
type B = {
    testable: false
};

type Union = A | B

function notWorking<T extends Union,>(object: T) {
    if (!object.testable) return
    object.doTest() // Property 'doTest' does not exist on type 'T'. Did you mean 'testable'?
}

Please, note that the following function would work:

function working(object: Union) {
    if (!object.testable) return
    object.doTest()
}

A Playground would make things easier to work with.