Skip to content
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

Closed
aoberoi opened this issue Feb 21, 2019 · 14 comments
Closed

Cannot escape distribution of conditional type properly #30020

aoberoi opened this issue Feb 21, 2019 · 14 comments

Comments

@aoberoi
Copy link

aoberoi commented Feb 21, 2019

TypeScript Version: 3.4.0-dev.201xxxxx

Search Terms: distributive conditional types, 1-tuple

Code

// Here are a couple types that will populate a discriminated union
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 */
}

// The goal is to have a type whose generic parameter which can identify a type by the  discriminant from
// Union, and if one isn't found, resolves to MyDefault

// First let's define a helper that will either resolve to T when its not the empty union, or
// Default when it is the empty union. Notice that the 1-tuple wrapper is used to avoid distribution.
type FallbackWhenBottom<T, Default> = [T] extends [never] ? Default : T;

// Now, this completes the goal.
type UnionByTypeWithDefault<T extends string> = FallbackWhenBottom<Extract<Union, { type: T }>, MyDefault>

// Let's test it out
type Test = UnionByTypeWithDefault<'a'>; // success: Test === UnionA
type Test2 = UnionByTypeWithDefault<'c'>; // fail: Test2 === never, expected Test2 === MyDefault

// DARN! Let's try to pick apart Test2 the way we think it should be evaluated

// This should be evaluated first (rem, FallbackWhenBottom was defined not to be distributive)
type Test3<T> = Extract<Union, { type: T }>;
type Test4 = Test3<'c'> // success: Test4 === never

// Then that should be substituted into FallbackWhenBottom
type Test5 = FallbackWhenBottom<never, MyDefault> // success: Test5 === MyDefault

// It seems like FallbackWhenBottom *is* behaving as distributive, as that's the only explanation
// for how Test2 could be never - T as never is being treated as the empty union. The 1-tuple should be preventing this ([never]), but it's not.

Expected behavior:

Test2 should evaluate to MyDefault

Actual behavior:

Test2 is evaluated to never

Playground Link: Link

Related Issues: #29368, #29627,

@aoberoi
Copy link
Author

aoberoi commented Feb 21, 2019

cc @jack-williams (you seem to be an expert in this topic)

@jack-williams
Copy link
Collaborator

jack-williams commented Feb 21, 2019

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 never from Test2 is not coming from a distribution short-circuit; the never comes from the false branch in UnionByTypeWithDefault which evaluates to never when T is 'c'.

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 UnionByTypeWithDefault you will see that the type is not a conditional type; the type is:

Extract<UnionA, { type: T }> | Extract<UnionB, { type: T }> 

so TypeScript is basically eagerly resolving the conditional type to the false branch, before actually instantiating T.

The reason why TypeScript does this is because it determines that instantiating T with any in Extract<Union, { type: T}> will never be assignable to never. Basically, under the most permissive instantiation of T (the wildcard instantiation), the conditional will always be false, so the conditional type is preemptively simplified.

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 any. (cc @ahejlsberg).

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 }>;

@aoberoi
Copy link
Author

aoberoi commented Feb 21, 2019

The reason why TypeScript does this is because it determines that instantiating T with any in Extract<Union, { type: T}> will never be assignable to never.

Even if I thought this was a good behavior (which I'm uncertain where I land), knowing that T is constrained with T extends string should mean that the most permissive instantiation of T would be string, not any.

Basically, under the most permissive instantiation of T (the wildcard instantiation), the conditional will always be false, so the conditional type is preemptively simplified.

I don't follow how the evaluation would end up here. Let's take T = string as an example:

[Extract<Union, { type: string }>]

// expand type Extract<T, U> = T extends U ? T : never;

[Union extends { type: string } ? Union : never]

// distributive conditional type expansion inside the tuple (Union was a naked generic type parameter that is a union)

[UnionA extends { type: string } ? UnionA : never | UnionB extends { type: string } ? UnionB : never]

// simplify UnionA extends { type: string } ? UnionA : never = UnionA
// simplify UnionB extends { type: string } ? UnionB : never = UnionB

[UnionA | UnionB]

Can you help me find where I went wrong?

@aoberoi
Copy link
Author

aoberoi commented Feb 21, 2019

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 }>;

Just tried, and Test2 is still never. I think its because Exclude<Union, { type: T }> will always be a subset of the parts of Union, so unless T allows it to match all the parts, Union extends Exclude<Union, { type: T }> will be false. It sounds like you were hoping for distributive expansion there, but since Union is not a generic parameter, that's not going to work.

@aoberoi
Copy link
Author

aoberoi commented Feb 21, 2019

Can you help me find where I went wrong?

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 T may not be considered naked because its inside the tuple (T is defined as a parameter in the expression outside the tuple so it must be evaluated there). If that's the case, I can see why the evaluation results in never.

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 UnionByTypeWithDefault would not hold that requirement.

How I expect this to be evaluated for UnionByTypeWithDefault<'c'>:

UnionByTypeWithDefault<'c'>

// expand UnionByTypeWithDefault<T extends string> = FallbackWhenBottom<Extract<Union, { type: T }>, MyDefault>

FallbackWhenBottom<Extract<Union, { type: 'c' }>, MyDefault>

// evaluating Extract<T, U> = T extends U ? T : never
// since it requires distributive expansion, it cannot be deferred with substitution

FallbackWhenBottom<(Union extends { type: 'c' } ? Union : never), MyDefault>

// distributive expansion (this is happening in the previous evaluation so Union
// is considered naked

FallbackWhenBottom<(UnionA extends { type: 'c' } ? UnionA : never | UnionB extends { type: 'c' } ? UnionB : never), MyDefault>

// simplify

FallbackWhenBottom<(never | never), MyDefault>

// simplify again

FallbackWhenBottom<never, MyDefault>

// expand type FallbackWhenBottom<T, Default> = T extends never ? Default : T;

never extends never ? MyDefault : never

// simplify

MyDefault

Is there something you can spot that is wrong here? I tried the same trick by putting 'foobar' in the false branch of UnionByTypeWithDefault and Test2 is still never.

@aoberoi
Copy link
Author

aoberoi commented Feb 21, 2019

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:

never extends never ? MyDefault : never

// shortcut to never because its treated as the empty union

never

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?

@jack-williams
Copy link
Collaborator

jack-williams commented Feb 21, 2019

Even if I thought this was a good behaviour (which I'm uncertain where I land), knowing that T is constrained with T extends string should mean that the most permissive instantiation of T would be string, not any.

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 string or any as the most 'permissive' instantiation here doesn't actually matter in this case. What TypeScript currently does is something like this:

Given conditional type A extends B ? T : U, TypeScript will simplify the conditional type to U if, under the most 'permissive' instantiation of type variables in A and B, the condition is still false. This is before you ever pass it the type argument 'c'.

In this example we can think of A as Extract<Union, { type: T }> and B as never. So TypeScript asks: if I replace T with the most permissive type is it still not assignable to never? Picking T as any as TypeScript does, or string as you suggest, makes no difference. In either case Extract<Union, { type: T }> will reduce to some non-never type. So even under this 'most permissive' instantiation, the condition is still false --- TypeScript eagerly picks the false branch.

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 never this is the converse. The condition is more likely to be true when the check type gets 'smaller'. Specifically: if the check type is Extract<Union, { type: T }>, then making T more permissive actually makes the check type larger, and therefore less likely to be true. In your example the instantiation of T that makes the condition most likely to be true is never which is completely the converse of what TypeScript does.

Just tried, and Test2 is still never.

Do you still get never for Test2 in this link? playground

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?

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 }>;

@jack-williams
Copy link
Collaborator

I'll also try and rope @weswigham into this who can hopefully give you clarification.

@weswigham
Copy link
Member

I think @jack-williams correctly identified the source of the issue - the "wildcard instantiation" we use emulates any, which ignores positional variance during comparisons, which is why the original type doesn't work.

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.

@aoberoi
Copy link
Author

aoberoi commented Feb 22, 2019

Does this work?

type UnionByTypeWithDefault<T extends string> = Extract<Union, { type: T }> extends never ? MyDefault : Extract<Union, { type: T }>;

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).

I think @jack-williams correctly identified the source of the issue - the "wildcard instantiation" we use emulates any, which ignores positional variance during comparisons, which is why the original type doesn't work.

Is there any way to run tsc to trace these types of decisions that the compiler is making? I've tried --diagnostics and --extendedDiagnostics but they don't seem to have this level of information.

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

whoa, this is so, SO much better. thank you!

@jack-williams
Copy link
Collaborator

jack-williams commented Feb 22, 2019

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).

Odd! Works fine for me in the playground. It's a moot point though, there are better solutions already.

@aoberoi
Copy link
Author

aoberoi commented Feb 22, 2019

@jack-williams you are awesome. thank you for spending time looking at this problem and explaining it to me, too.

giphy

@Veetaha
Copy link

Veetaha commented Apr 2, 2019

@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 false if it may not always be so? Apparently, you should change your optimization logic and add some warnings when you apply this logic so that the programmer can see that there is no need in conditional type that always evaluates to false.

I've spent a decent amount of time trying to debug such a counter-intuitive issue as #30708
May I ask you to put Bug label on this or that issue?

@jcalz
Copy link
Contributor

jcalz commented Oct 7, 2020

This definitely feels like a bug or at least a design limitation. Maybe this issue should be labeled as such?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants