Gaurab Paul

Polyglot software developer & consultant passionate about web development, distributed systems and open source technologies

Support my blog and open-source work

Tags

Using Boxing to prevent distribution of conditional types
Posted  5 years ago

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 argument A | B | C for T 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:

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:

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:

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:

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