-
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
Cannot escape distribution of conditional type properly #30020
Comments
cc @jack-williams (you seem to be an expert in this topic) |
I'll have a go: I'll inline the definition to make things abit clearer (for me at least). type UnionByTypeWithDefault<T extends string> = [Extract<Union, { type: T }>] extends [never] ? MyDefault : Extract<Union, { type: T }>;
type Test = UnionByTypeWithDefault<'a'>; // success: Test === UnionA
type Test2 = UnionByTypeWithDefault<'c'>; // fail: Test2 === never, expected Test2 === MyDefault The first thing to note is that the type Test3 = Extract<Union, { type: 'c' }>; // never You can identify this by trying: type UnionByTypeWithFoobar<T extends string> = [Extract<Union, { type: T }>] extends [never] ? MyDefault : "foobar";
type Test4 = UnionByTypeWithFoobar<'a'>; // "foobar"
type Test5 = UnionByTypeWithFoobar<'c'>; // "foobar" Then if you hover over Extract<UnionA, { type: T }> | Extract<UnionB, { type: T }> so TypeScript is basically eagerly resolving the conditional type to the false branch, before actually instantiating The reason why TypeScript does this is because it determines that instantiating Obviously this is not what you want! I think the notion of 'most permissive' depends on variance and context. The instantiation that makes the condition most likely to be true doesn't seem to always correlate with As a workaround: I think this might be ok (but I haven't really tested it): type UnionByTypeWithDefault<T extends string> = Union extends Exclude<Union, { type: T }> ? MyDefault : Extract<Union, { type: T }>; |
Even if I thought this was a good behavior (which I'm uncertain where I land), knowing that
I don't follow how the evaluation would end up here. Let's take
Can you help me find where I went wrong? |
Just tried, and |
I think I found what I did wrong. The step in which I do distributive conditional type expansion inside the tuple might be incorrect, because the type parameter However, if this is the case, simply removing the tuple should fix this: type FallbackWhenBottom<T, Default> = T extends never ? Default : T;
type UnionByTypeWithDefault<T extends string> = FallbackWhenBottom<Extract<Union, { type: T }>, MyDefault>; I've made that change in the playground and it does not fix this. I'm preserving the separation (although it might make it a little less clear) because it seems that keeping the generic type parameter naked is significant, and embedding it inside How I expect this to be evaluated for
Is there something you can spot that is wrong here? I tried the same trick by putting |
I'm really sorry for this stream-of-consciousness. I think the intermediate steps here are important for me, and I hope they aren't too taxing on anyone else. I think the problem with my last expansion is in the final step:
So, now I'm very confused. It seems I've tried both keeping the condition distributive and forcing it not to be distributive, and I still end up with the wrong result. Is there some expressive power missing? Is there some other mechanism I'm just not using that I should be? |
I just want to emphasise: I'm not trying to justify the TypeScript behaviour here, or say you went wrong. I think your expectations were valid, and what you wrote should have probably worked. I was just trying trying to explain what TypeScript is currently doing. This wasn't clear in my post, which is my fault. Taking either Given conditional type In this example we can think of The reasoning TypeScript is doing here is not correct, so trying to make sense of it will leave you confused. The assumption is that making the type variables in the condition more 'permissive' will make the condition more likely to be true. In the case where the extends type is
Do you still get never for Test2 in this link? playground
Yes I think there is an unfortunate interaction going on here. When you use: type FallbackWhenBottom<T, Default> = T extends never ? Default : T; you get the distributive behaviour that you specifically don't want. When you use: type FallbackWhenBottom<T, Default> = [T] extends [never] ? Default : T; TypeScript does the false branch simplification (it will not do this if the check or extends types are naked parameters). Does this work? type UnionByTypeWithDefault<T extends string> = Extract<Union, { type: T }> extends never ? MyDefault : Extract<Union, { type: T }>; |
I'll also try and rope @weswigham into this who can hopefully give you clarification. |
I think @jack-williams correctly identified the source of the issue - the "wildcard instantiation" we use emulates However, there's a simpler way to write this that doesn't have such an issue: interface UnionA {
type: 'a';
foo: boolean;
}
interface UnionB {
type: 'b';
bar: number;
}
// This is the discriminated union
type Union = UnionA | UnionB;
// Here is a type that is like those in Union, but a little more general
interface MyDefault {
type: string;
/* ... more default properties */
}
type UnionByTypeWithDefault<T extends string> = T extends Union["type"] ? Extract<Union, { type: T }> : MyDefault;
// much shorter, one less conditional, easier to reason about, behaves correctly when `T` is a union
type Test = UnionByTypeWithDefault<'a'>; // success: Test === UnionA
type Test2 = UnionByTypeWithDefault<'c'>; // success: Test2 === MyDefault
type Test3 = UnionByTypeWithDefault<'a' | 'b'>; // success: Test3 === UnionA | UnionB generally, I'd recommend that if you can write a conditional without relying on a non-distributive conditional, you should - the reason non-distributive conditions are not the default is because they often have surprising behavior around unions when composed. |
Nope. I think this be roughly equivalent to what I tried when I removed the tuple wrapping (I guess separating the problem into two conditional types doesn't change anything, even though I thought it would change the order and distributiveness of evaluation).
Is there any way to run
whoa, this is so, SO much better. thank you! |
Odd! Works fine for me in the playground. It's a moot point though, there are better solutions already. |
@jack-williams you are awesome. thank you for spending time looking at this problem and explaining it to me, too. |
@weswigham I don't think we should consider this behavior valid. It is very counter-intuitive. Why does the type change when I manually expand it (not mentioning union distributions)? Why do you consider a condition to always evaluate to I've spent a decent amount of time trying to debug such a counter-intuitive issue as #30708 |
This definitely feels like a bug or at least a design limitation. Maybe this issue should be labeled as such? |
TypeScript Version: 3.4.0-dev.201xxxxx
Search Terms: distributive conditional types, 1-tuple
Code
Expected behavior:
Test2
should evaluate toMyDefault
Actual behavior:
Test2
is evaluated tonever
Playground Link: Link
Related Issues: #29368, #29627,
The text was updated successfully, but these errors were encountered: