hrtyy.dev

TypeScript Utility Types with Discriminated Unions

How to effectively use TypeScript utility types like Pick and Omit with discriminated unions by leveraging conditional types.

Omit with Discriminated Union in TypeScript

When working with discriminated unions in TypeScript, simply using utility types like Omit or Pick might not be sufficient. This is because these utility types do not distribute over the union types by default. To handle this, we need to use conditional types to distribute the utility types over each member of the union.

Example

Consider the following discriminated union type Plane and a common type Common:

1type Plane =
2 | { kind: "circle"; radius: number; } // Circle with radius
3 | { kind: "square"; x: number }; // Square with x coordinate
4
5type Common = {
6 name: string; // Name of the shape
7 color: `#${string}`; // Color in hex format
8}
9
10type Shape = Plane & Common; // Shape combining Plane and Common properties

If we want to create a new type that omits the color property from Shape, using Omit<Shape, "color"> directly will not work as expected:

1type NoColorShape = Omit<Shape, "color">;
2
3// Valid Shape object with color
4let s1: Shape = {
5 kind: "circle",
6 radius: 1,
7 name: "c",
8 color: "#000"
9}
10
11// The following object is invalid because NoColorShape is same as
12// type NoColorShape = {
13// kind: "circle" | "square";
14// name: string;
15// }
16
17let s2: NoColorShape = {
18 kind: "circle",
19 name: "c",
20 // @ts-expect-error
21 radius: 1,
22 color: "#000"
23}

What we want is omitting only the color property from each member of the union, but other properties like radius, and x were also omitted.

To correctly omit the color property from each member of the union, we need to use a conditional type to distribute the Omit utility type over the union:

1// Conditional type to distribute Omit over each member of the union
2type OmitEach<T, K extends keyof T> = T extends any ? Omit<T, K> : never;
3
4type NoColorShape2 = OmitEach<Shape, "color">;
5
6// Invalid NoColorShape2 object, should not have color
7let s3: NoColorShape2 = {
8 kind: "circle",
9 radius: 1,
10 name: "c",
11 // @ts-expect-error
12 color: "#000"
13}
14
15// Valid NoColorShape2 object without color
16let s4: NoColorShape2 = {
17 kind: "circle",
18 radius: 1,
19 name: "c",
20}

T extends any looks like a no-op, but it is necessary to trigger the distributive behavior of conditional types.

By using T extends any ? Omit<T, K> : never, we ensure that the Omit utility type is applied to each member of the union individually, resulting in the desired type.

1function processShape(shape: Shape) {
2 if (shape.kind === "circle") {
3 console.log(`Circle with radius ${shape.radius}`);
4 } else if (shape.kind === "square") {
5 console.log(`Square with x coordinate ${shape.x}`);
6 }
7 console.log(`Shape name: ${shape.name}, color: ${shape.color}`);
8}
9
10function processNoColorShape(shape: NoColorShape2) {
11 if (shape.kind === "circle") {
12 console.log(`Circle with radius ${shape.radius}`);
13 } else if (shape.kind === "square") {
14 console.log(`Square with x coordinate ${shape.x}`);
15 }
16 console.log(`Shape name: ${shape.name}`);
17}
18
19// Example usage
20const shape1: Shape = { kind: "circle", radius: 5, name: "Circle1", color: "#ff0000" };
21const shape2: NoColorShape2 = { kind: "square", x: 10, name: "Square1" };
22
23processShape(shape1);
24processNoColorShape(shape2);

In these examples, the processShape function works with the Shape type, and the processNoColorShape function works with the NoColorShape2 type. Both functions use type narrowing to handle different kinds of shapes.

Conclusion

When working with discriminated unions in TypeScript, it is important to use conditional types to distribute utility types like Omit or Pick over each member of the union. This ensures that the utility types are applied correctly and produce the expected results.