Distributing Pick<T, K>/Omit<T,K> over union types in TypeScript
Disclaimer: this article assumes intermediate knowledge of both TypeScript and React. Concepts you should be familiar with include Higher Order Components and utility TypeScript types such as Pick<T, K> and Omit<T, K>.
A few weeks ago at work, we were trying to use a Higher Order React component (HOC) called withRouter
that is typed roughly like this:
type WithRouterProps = { ... };
type ExcludeWithRouterProps<P> = Omit<P, keyof WithRouterProps>;
function withRouter<P extends WithRouterProps>(component: React.ComponentType<P>):
React.ComponentType<ExcludeWithRouterProps<P>>
Note: this is just the standard way of typing React HOCs in TypeScript — the return type of the HOC function is a React Component with the same props as the original component with the exception of all the properties which are injected by the HOC itself.
We were trying to use the withRouter
HOC with a component which has the following union type Props
:
type BaseProps<T> = T & {
baseProp: string;
} & WithRouterProps;
type A = ...;
type B = ...;
type Props =
| BaseProps<A>
| BaseProps<B>;
class MyComponent extends React.Component<Props> {
...
}
const DecoratedComponent = withRouter(MyComponent);
We expected the type of DecoratedComponent
to look like this:
typeof DecoratedComponent = React.ComponentType<
{ baseProp: string } & (A | B)
>;
Basically, the props that we expect to have to pass to the DecoratedComponent
are the same props that it receives without everything that's in WithRouterProps
. However, the type we were actually getting for DecoratedComponent
is:
typeof DecoratedComponent = React.ComponentType<{
baseProps: string;
}>;
This is unexpected since we now can't use our WrappedComponent
as we wanted to. All of the props that we wanted to pass to it are gone.
After some research, we figured out that this is a known TypeScript issue documented in various TypeScript issues:
- https://github.com/microsoft/TypeScript/issues/28339 (main issue)
- https://github.com/microsoft/TypeScript/issues/28791
- https://github.com/microsoft/TypeScript/issues/28483
- https://github.com/microsoft/TypeScript/issues/33656
- ...
However, even after reading through many of these issues, it still took us a while to really understand what's going on here. So, I thought it'd be a good idea to write a blog post that explains what's happening here and how to get around it.
Let's start by simplifying our example above into something easier to reason about:
type Base<T> = T & {
base: string;
}
type A = {
a: string;
}
type B = {
b: string;
}
type P = (Base<A> | Base<B>) & { toRemove: string };
type Q = Omit<P, "toRemove">;
What do you expect type Q
to be? I recommend you try to think it over before reading the answer below.
I think it should be Base<A> | Base<B>
(but feel free to disagree here). However, if you run the code above you'll see that Q
is actually just { base: string }
.
The reason for this, as explained in the GitHub links above, is that "Pick<T, K>/Omit<T, K> are not distributive over union types". What this means is that the Pick<T, K>/Omit<T, K> operations are not applied (distributively) to each of the union subtypes of T
, but instead to a type that represents the "common properties" of the union. The common properties of Base<A> & Base<B>
correspond to { base: string }
and so Pick
will be applied to that:
Omit<{ base: string, toRemove: string }, "toRemove">
=> { base: string }
The big question is why TypeScript behaves this way. You might think that this is "wrong" but TypeScript is working as intended here and it seems like the TS team has no plans to change this behavior. After going through most of the GitHub issues linked above, it seems to me that the TS team made this decision for a couple of reasons:
- Simplicity — generating all the possible types that come out as a result of distributing Pick/Omit on a union type can result in an extremely long type definition. This is harder to grasp/understand than the simpler alternative that the TS team went with.
- Performance — while I couldn't find any conclusive evidence for this, I expect that deriving a Pick/Omit type from an union type that considers all variations of the union takes considerably longer than for the intersection of all those types.
Writing a distributive version of Pick/Omit
So now let's say you actually need to Pick/Omit properties from an object type and apply that distributively over a union type. The way to do this is actually very simple:
type DistributiveOmit<T, K extends keyof T> = T extends unknown
? Omit<T, K>
: never;
type DistributivePick<T, K extends keyof T> = T extends unknown
? Pick<T, K>
: never;
The first thing we need to learn in order to understand how all this works is distributive conditional types. This is a TypeScript feature that allows you to write conditional types that distribute over an union type. Regular conditional types of type T extends U ? X : Y
resolve to either X
or Y
depending on whether T extends U
or not. Here's an example:
// Very simple conditional type for Redux actions
type ReduxAction<P extends Object, M extends any = void> =
M extends undefined ?
{ payload: P }
: { payload: P, meta: M };
type GetUserAction = ReduxAction<User>;
// Shape of GetUserAction type is `{ meta: User }`
type LoadUserAction = ReduxAction<{ userId: string }, Date>;
// Shape of LoaderUserAction type is:
// `{ payload: { userId: string },
// meta: Date }`
In this very simple conditional type, we allow the client of ReduxAction
to generate differently shaped object types depending on whether the M
type argument is undefined
or not.
Now that we understand basic conditional types, we can try to understand distributive conditional types. They're not that different and in fact the syntax is exactly the same.
Let's say we write:
type UnionType = U1 | U2 | U3;
type X<T> = ...;
type A<T> = T extends UnionType ? X<T> : never;
The type of A<UnionType>
will be X<U1> | X<U2> | X<U3>
(whatever type X
is). This is because the conditional type syntax unions together all the types on the left-hand side of the operator ( X<T>
).
Now let's go back to writing a distributive version of the Omit<T, K>
type. All we have to do is leverage distributive conditional types:
type DistributiveOmit<T, K extends keyof T> = T extends unknown
? Omit<T, K>
: never;
What we're doing here is distributing over all the subtypes of T
and resolving to the default implementation of Omit<T, K>
for each of those.
After overriding our third-party dependency's type definitions to use our new DistributiveOmit
type, we were able to use their withRouter
HOC with our React Component with union type props.
Overall, this was a very interesting issue to run up against. The frustrating part to me is that it seems like Pick<T, K>
and Omit<T, K>
should be distributive by default in TypeScript. I'd like to learn more about why Pick/Omit work this way, so I left a comment in the main TypeScript GitHub issue about this subject.