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
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?
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 thetest()
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:
the type
A<X>
is opaque; the compiler doesn't know what it is. Since the compiler can't peer intoA<X>
to understand it, all it can really do is direct pattern-matching between instances ofA<X>
. Andfunc()
requires an argument of typeA<X> & { optionalValue?: number }
.We can see that
arg
anda
are both seen as typeA<X>
, which is not assignable by definition toA<X> & { optionalValue?: number }
. In general an intersectionT & U
extendsT
but not vice versa, so it fails.On the other hand,
b
is of typeA<X> & { optionalValue: number }
andx
is of typeA<X> & {}
. The compiler can now pattern-match those againstA<X> & { optionalValue?: number }
... these are both intersections ofA<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 toA<X> & { optionalValue?: number }
, butA<X>
alone is not, sinceA<X> & {}
andA<X>
are essentially identical, assumingA<X>
is neithernull
norundefined
... But the compiler doesn't have the ability to go through even that level of analysis onA<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.