-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
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! |
This issue is now fixed in #43183. |
Wow, this was one of the most annoying issues for me, thanks. |
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. |
@ahejlsberg you've made my week with this good news! 🎉 |
4 years later. I'm gonna cry I think 😃 |
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,
and I'm using it as the generic constraint in this function, and I have to assign the variable with type
If I try to skip using the type variable it doesn't work, even if I define an explicit type guard function:
|
Another simple example of a problem. With generic extending a union type, automatic type guards don't work. 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'
} |
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" ----> ?????
} |
Can confirm generic union type guards aren't working 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
}
} |
From the look of this thread, the issue is NOT really resolved at this point, and we cannot narrow dependent types? |
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. |
@RyanCavanaugh Gotcha, thanks for that explanation! I can see how that's unsafe. What about this example? const Actions = {
Action1: 'Action1',
Action2: 'Action2',
} as const;
type Action = ValueOf<typeof Actions>;
// Some dynamic parameters
type ParamConstraints = {
[Actions.Action1]: { name: string, age: number },
[Actions.Action2]: { company: string }
};
function doSomething<TAction extends Action>(action: TAction, param: ParamConstraints[TAction]) {
if(action === 'Action1') {
param.name // Property 'name' does not exist on type '{ name: string; age: number; } | { company: string; }'.
}
if(action === 'Action2') {
param.company // same error
}
switch(action) {
case 'Action1':
param.name // same error
break;
case 'Action2':
param.company // same error
break;
}
}
// Expected error about the wrong structure for the 2nd argument
doSomething('Action1', { company: 'test' }) This seems to enforce the contract for callers (ie. I must call Or is this something entirely different? |
No // Sound upcast
const p = 'Action1' as 'Action1' | 'Action2';
// Correct call causes unsoundness in function body
doSomething(p, { company: 'test' }); |
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:
|
This is the work-around I'm using. The key function in the following is the 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 I have some operations on boxes that I want to implement generically -- The solution here is the For example, if the argument of |
The fix seems very incomplete to me. A simple re-assignment of the narrowed variable will break it:
This of course causes all sorts of related problems. In light of this, shouldn't this issue be reopened for having a main source of reference on these kind of issues? |
This also doesn't work with narrowing conditional types (tested with both enums and unions in TS 5.5):
|
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 usingextends
to meanAB ⊂ 'A' | 'B'
, butextends
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?The text was updated successfully, but these errors were encountered: