TypeScript conditional types have an interesting property that they can “distribute” over union types. As explained better by the official docs:
Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation. For example, an instantiation of
T extends U ? X : Y
with the type argumentA | B | C
forT
is resolved as(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
.
While I understand the rationale behind this behavior, this can sometimes lead to somewhat non-intuitive outcomes in real-world scenarios:
For example, let us consider following usage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
interface DeferredKeySelection<TBase, TKeys extends string> { _base: TBase; _keys: TKeys; } declare namespace DeferredKeySelection { type SetBase<TSelection, TBase> = TSelection extends DeferredKeySelection< any, infer TKeys > ? DeferredKeySelection<TBase, TKeys> : DeferredKeySelection<TBase, never>; type AddKey< TSelection, TKey extends string > = TSelection extends DeferredKeySelection<infer TBase, infer TKeys> ? DeferredKeySelection<TBase, TKeys | TKey> : never; type Augment<T, TBase, TKey extends string> = AddKey<SetBase<T, TBase>, TKey>; type Resolve<TSelection> = TSelection extends DeferredKeySelection< infer Base, infer Keys > ? Keys extends keyof Base? Pick<Base, keyof Base> : any : TSelection; } |
It was written to model a fluent API (of Knex query builder) where the base type of a partial could be available later, for example:
1 2 3 4 |
await knex<User>("users").select("id", "name"); // Resolves to Pick<User, "id" | "name"> await knex.select("id", "name").from<User>("users"); // Resolves to Pick<User, "id" | "name"> |
Note that we can’t just use Pick
directly in select’s return value because it is possible that the base type (User above) hasn’t been provided yet.
So, instead we use a container type for bookkeeping and use DeferredKeySelection.Resolve
to resolve it – basically deferring the Pick operation till the termination of the fluent chain. If this pattern of using an identically named interface and namespace pair seems strange, checkout my previous post on the topic.
However, if we try to use it, we will notice something strange:
1 2 3 4 |
type T = DeferredKeySelection.Resolve<DeferredKeySelection<User, "id" | "age">> // Pick<User, "id"> | Pick<User, "age"> |
We might have expected the resolved type (T
) to be Pick<User, "id" | "age">
here but of course that is not the case because of the distributive behavior of conditional types mentioned above.
Fortunately the solution is very simple, we can box the first type parameter of conditional so it is no longer a simple union type:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// A container type to wrap the value in an object interface Boxed<T> { _value: T; } // Later: type ResolveSingle<TSelection> = TSelection extends DeferredKeySelection< infer Base, infer Keys > ? Base extends {} ? Boxed<Keys> extends Boxed<keyof Base> ? Pick<Base, Keys & keyof Base> : any : any : TSelection; |
Now our Resolve function behaves as we would expect.
(PS. The container types here have no runtime overhead because we never any values/instances of these types. They are just used for type-level book-keeping).