Conditional types don't play nicely with optional properties

322 views Asked by At

It doesn't appear that optional properties distribute through conditional types.

Consider:

type A<X> = (X extends string ? { stringValue: X } : { otherValue: X });
type B<X> = A<X> & { optionalValue?: number }

function func<X>(arg: B<X>) {}

function test1<X>(arg: A<X>) { func(arg); }                           // fails
function test2<X>(arg: A<X>) { func({ ...arg }); }                    // fails
function test3<X>(arg: A<X>) { func({ ...arg, optionalValue: 42 }); } // okay
function test4<X>(arg: A<X> & {}) { func({ ...arg }); }               // okay

TS Playground

The error for test1 and test2 is: Argument of type 'A<X>' is not assignable to parameter of type 'A<X> & { optionalValue?: number | undefined; }'.

test3 succeeds when the optional optionalValue is supplied, and test4 succeeds when lower priority inference is forced with & {}.

I would have expected that B<X> would be equivalent to

X extends string
  ? { stringValue: X } & { optionalValue?: number }
  : { otherValue: X } & { optionalValue?: number }

How would I achieve that?

1

There are 1 answers

1
jcalz On BEST ANSWER

When TypeScript encounters a conditional type that depends on one or more as-yet-unspecified generic type parameters (like A<X> inside the bodies of the test() functions), it generally doesn't evaluate it. Instead it defers evaluation until such time as the generic type parameters are specified with generic type arguments. And, since they are not evaluated, deferred types are essentially opaque to the compiler; it doesn't really understand what might and might not be assignable to such a type, and it will often reject the assignment of anything to that type unless that thing is immediately seen as identical to it. It's a design limitation of TypeScript.

This limitation isn't really documented directly in The TypeScript Handbook, but it is often the stated reason why GitHub issues are closed. For example, see microsoft/TypeScript#50371, microsoft/TypeScript#51288, and microsoft/TypeScript#52144.


Anyway, inside the following function:

function test<X>(arg: A<X>) {
  func(arg); // error
  const a = { ...arg }; // A<X>
  func(a); // error
  const b = { ...arg, optionalValue: 42 } // A<X> & { optionalValue: number; }
  func(b); // okay
  const c: A<X> & {} = { ...arg }; // A<X> & { }
  func(c); // okay
}                        

the type A<X> is opaque; the compiler doesn't know what it is. Since the compiler can't peer into A<X> to understand it, all it can really do is direct pattern-matching between instances of A<X>. And func() requires an argument of type A<X> & { optionalValue?: number }.

We can see that arg and a are both seen as type A<X>, which is not assignable by definition to A<X> & { optionalValue?: number }. In general an intersection T & U extends T but not vice versa, so it fails.

On the other hand, b is of type A<X> & { optionalValue: number } and x is of type A<X> & {}. The compiler can now pattern-match those against A<X> & { optionalValue?: number }... these are both intersections of A<X> with something. So now the compiler just needs to compare { optionalValue: number} and {} with { optionalValue?: number }... both of which are assignable, so it succeeds. (Note that the assignability of {} to { optionalValue?: number} is technically unsound, as described in Understanding TS' type inferring/narrowing with combination of extends & implements, but that's out of scope here.)

So that's what's going on here. It is definitely weird that A<X> & {} is assignable to A<X> & { optionalValue?: number }, but A<X> alone is not, since A<X> & {} and A<X> are essentially identical, assuming A<X> is neither null nor undefined... But the compiler doesn't have the ability to go through even that level of analysis on A<X>; it's just some unevaluated thunk.


There is some ability for the compiler to compare two generic conditional types, such as implemented in microsoft/TypeScript#46429, but these situations are "fragile" in that a refactoring to a semantically identical type will cause the comparison to no longer work.

Unfortunately it's hard to tell which evaluations will get deferred and which evaluations will happen eagerly. Sometimes the compiler will decide to take a shortcut and substitute a generic type parameter with its constraint, and then it will resolve the conditional type to something specific, which may or may not be accurate. TypeScript's type system is neither sound nor complete, so there are places it will allow things that could be unsafe, and places it will prohibit things that must be safe.

In practice that means there's more of an art to writing TypeScript types than a science. It may be possible to refactor your types to make things work, depending on which types end up getting evaluated, when, and how. Pragmatically speaking, as long as you're satisfied that you're doing the right thing, it's fine to use type assertions to suppress errors inside of generic functions dealing with conditional types.