Generics extending unions cannot be narrowed #13995
Comments
|
But Regarding:
Do you have a specific example of behavior where you'd do |
|
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 |
|
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. |
|
Again appeared in #25039 |
|
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'.` |
|
By using |
I have encountered this problem. It's much odd because it says "'a' does not exist" even if it's inside of // 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
}
} |
|
same issue, different example:
|
|
This would be great for specializing Props to React components. Contrived example:
|
|
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!
}
}; |
|
I think this is the same issue, this came from a real use case but I've reduced it to a toy example to better demonstrate: interface Foo {
stringVal: string;
stringArrayVal: string[];
numberArrayVal: number[];
}
type KeysWithType<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T];
type ArrayPropertyOf<T> = KeysWithType<T, Array<any>>;
type ArrayTypeOfPropertyOf<T, K extends keyof T> = T[K] extends Array<infer U> ? U : never;
/// type checks
const obj: Foo = {
stringVal: 'hello',
stringArrayVal: ['world'],
numberArrayVal: [1,2,3]
}
let a: ArrayTypeOfPropertyOf<Foo, 'numberArrayVal'>; // as expected, type narrowed to number.
function printArrayKeyWorking(key: ArrayPropertyOf<Foo>) {
switch (key) {
case 'stringArrayVal':
case 'numberArrayVal':
case 'stringVal': // as expected: Type '"stringVal"' is not comparable to type '"numberArrayVal" | "stringArrayVal"'.
console.log(key);
}
}
function printArrayKeyNotWorking<K extends ArrayPropertyOf<Foo>>(key: K) {
switch (key) {
case 'stringArrayVal':
case 'numberArrayVal':
case 'stringVal': // no error
console.log(key);
}
}
function transformChildArrayValue<K extends ArrayPropertyOf<Foo>>(key: K, value: ArrayTypeOfPropertyOf<Foo, K>) {
switch (key) {
case 'stringArrayVal':
return value.repeat(2); // not expected: Type '"stringVal"' is not comparable to type '"numberArrayVal" | "stringArrayVal"'.
case 'numberArrayVal':
return value ** 2; // not expected: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
case 'stringVal':
return key;
}
}Is there actually a workaround existing for this? The real case I'm having is the same kind of problem as the last function where I am unable to correctly type the key name and value as the two parameters as the type cannot be correctly narrowed. Note this is part of a library that I want to enforce good types for the downstream developer so I'm looking for generic solution not one specific to this particular problem Any tips for a workaround greatly appreciated! |
|
Not sure if that's the exact same problem, but I probably found a real simple case where this doesn't work as expected: function ifNullThenUndefined<T>(value: T): T extends null ? undefined : T {
return value === null ? undefined : value;
}This complains at the This, however, works: type MapNullToUndefined<T> = T extends null ? undefined : T;
function ifNullThenUndefined<T>(value: T): MapNullToUndefined<T> {
return (value === null ? undefined : value) as MapNullToUndefined<T>;
}
const a1 = ifNullThenUndefined(null); // a1 is inferred as undefined
const a2 = ifNullThenUndefined(undefined); // a2 is inferred as undefined
const a3 = ifNullThenUndefined(2); // a3 is inferred as a number |
|
@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. 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, The “solution,” such as it is, is to use casting. Whether you do that by defining a particular type (e.g. 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 |
|
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! |
|
I want to implement a function which gets an ID (which can be a string or a number) or an array of IDs and coerces it into a number or an array of numbers. Basically it needs to implement the following overloads: export function ensureIsNumber(id: string | number): number;
export function ensureIsNumber(ids: string[] | number[]): number[];I thought of somethig like that: function ensureIsNumber<T extends string | number, K extends T | Array<T>>(
idOrIds: K
): K extends Array<T> ? number[] : number {
if (idOrIds instanceof Array) {
return idOrIds.map(id => Number(id));
} else {
return Number(idOrIds);
}
}UnfortunatelyTypescript cannot narrow it down :( |
|
Add an explicit cast. Any other way you try will still be unsound, whether TS complains or not. |
Assuming Basically what it often comes down to is mutually-exclusive unions can (and, per this suggestion, should) be narrowed, but because of the problems when unions are not mutually exclusive, TS doesn’t. I strongly hope that someday TS will be smarter about recognizing and leveraging mutually-exclusive unions. |
|
It's "unsound" as in TS not actually preventing you from making mistakes. The other way to implement it would be with overloads but TS doesn't protect you from making mistakes there, either, export function ensureIsNumber(id: string | number): number;
export function ensureIsNumber(ids: string[] | number[]): number[];
export function ensureIsNumber(idOrIds: string|number|(string|number)[]): number|number[] {
if (idOrIds instanceof Array) {
return 999; //not an array
} else {
return [111]; //not a number
}
}A "sound" approach would be some variant of the above that would protect you from making that mistake. As of now, no such approach exists. So, any approach is fine because they're all not great and allow you to make mistakes in your implementation. (The best would be to not use overloads in the first place) |
|
@AnyhowStep Just a couple of notes: Also: function ensureIsNum(
idOrIds: string | number | string[] | number[]
): number | number[] {
if (idOrIds instanceof Array) {
return idOrIds.map(id => Number(id));
} else {
return Number(idOrIds);
}
}In the previous example you won't be able to access the |
Yes, but no one will see And I used You're interested in, |
|
can I throw my example (from my #36521 (comment)) How can I test in order to split my code into two possibilities (like my
|
|
A Playground would make things easier to work with. |
|
Even though it does not use generics, the following example seems to be in the same family of problems since it resembles #31743 which was closed as duplicate of this issue: class Test {
a: string;
b: string;
c: number;
constructor() {
this.a = "a";
this.b = "b";
this.c = 1;
}
test(key: keyof this): number {
switch (key) {
case "a":
case "b":
return this[key].length; // Property 'length' does not exist on type 'this[keyof this]'.(2339)
case "c":
return this[key]; // Type 'this[keyof this]' is not assignable to type 'number'.(2322)
}
return 0;
}
} |
|
Also this issue comes up when you want to write mapper for some AST-like type: enum TYPE {
BOOLEAN = 'BOOLEAN',
STRING = 'STRING',
FLOAT = 'FLOAT',
COMPLEX1 = 'COMPLEX1',
COMPLEX2 = 'COMPLEX2',
}
type BooleanValue = {
type: TYPE.BOOLEAN;
value: boolean;
};
type StringValue = {
type: TYPE.STRING;
value: string;
};
type FloatValue = {
type: TYPE.FLOAT;
value: number;
};
type PrimitiveValue = BooleanValue | StringValue | FloatValue;
type ComplexValue1 = {
type: TYPE.COMPLEX1;
value: PrimitiveValue;
};
type ComplexValue2 = {
type: TYPE.COMPLEX2;
value: ComplexValue1;
};
type Value = PrimitiveValue | ComplexValue1 | ComplexValue2;
function mapValueGeneric<T extends Value>(value: T, f: (_: PrimitiveValue) => PrimitiveValue): T {
switch (value.type) {
case TYPE.BOOLEAN:
case TYPE.STRING:
case TYPE.FLOAT:
return f(value);//error 2322: Type 'PrimitiveValue' is not assignable to type 'T'.
//error 2345: Argument of type 'T' is not assignable to parameter of type 'PrimitiveValue'.
case TYPE.COMPLEX1:
return {
type: TYPE.COMPLEX1,
value: f(value.value),//error 2322: Type '{ type: TYPE.COMPLEX1; value: PrimitiveValue; }' is not assignable to type 'T'.
};
case TYPE.COMPLEX2:
return {
type: TYPE.COMPLEX2,
value: mapValueGeneric(value.value, f),//error 2322: Type '{ type: TYPE.COMPLEX2; value: Value; }' is not assignable to type 'T'.
};
}
}
function mapValueSimple(value: Value, f: (_: PrimitiveValue) => PrimitiveValue): Value {
switch (value.type) {
case TYPE.BOOLEAN:
case TYPE.STRING:
case TYPE.FLOAT:
return f(value);
case TYPE.COMPLEX1:
return {
type: TYPE.COMPLEX1,
value: f(value.value),
};
case TYPE.COMPLEX2:
return {
type: TYPE.COMPLEX2,
value: mapValueSimple(value.value, f),//error 2322: Type 'Value' is not assignable to type 'string | number | boolean | BooleanValue | StringValue | FloatValue | ComplexValue1'.
};
}
} |
|
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()
} |
|
@Sharcoux, why you need generic type in this case, why not just accept erased type? For me it is not the best illustration of issue. This issue comes up when you want to preserve input type, so need to use it as return type (like in my example above), or if you want to have two or more function parameters of exactly same type. |
|
What do you call erased type? |
|
@Sharcoux, when you use interface as type, not as generic's restriction (so lose information about what original type was) |
|
@RyanCavanaugh Maybe something has changed in the two years since you wrote!
It would be good if you could just give us a short information. Thank you very much. |
@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 It's really time to fix this one. At least TS has incremental compilation now, so hopefully speed isn't an issue now either. |
|
@RyanCavanaugh is there any plan to fix that? I encounter it a lot and it would be great if you could address that. |
|
@RyanCavanaugh please consider fixing this again |
|
Could we get better compiler feedback for this issue? What I got did not lead me to think that it was a limitation. |
TypeScript Version: 2.2.0-dev.20170126
Code
Expected behavior:
Compiles without error.
Actual behavior:
It works correctly if I just use
value: 'A' | 'B'as the argument tobounceAndTakeIfA, 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) sinceAB extends 'A' | 'B', narrowing doesn't happen. In reality, I am just usingextendsto meanAB ⊂ 'A' | 'B', butextendscan mean more than that, which (I suspect) is why TS is refusing to narrow on it.Some alternative to
extendsthat more specifically means ⊂ (subsetsmaybe?) would probably be the best solution here?The text was updated successfully, but these errors were encountered: