Mapping Types in TypeScript

TypeScript provides developers with a robust typing system. At times, it can be an incredibly useful tool; however, on other occasions, it can lead to hours of battle trying to get things just right. In today’s tale, we conquer one such battle.

The Setup

Imagine we are working with a system that deals with various shapes. We have different interfaces to represent different kinds of shapes, each with a kind key to distinguish the type.

interface Square {
  kind: 'square';
}

interface Circle {
  kind: 'circle';
}

type Shape = Square | Circle

In this system, we have a list of shapes that we need to filter to locate only the circles.

const shapes: Shape[] = [
    {kind: 'square'},
    {kind: 'circle'}
];

const circles = shapes
  .filter(shape => shape.kind === 'circle');

Technically, it works, but there’s an issue: our circles array is currently typed as Shape[]. That’s not correct! If we filter for kind === "circle", we should have Circle[]. A naive attempt to fix this might use a type assertion to tell the compiler what we expect.

const circlesTypeAssertion = shapes
  .filter(shape => shape.kind === "circle") as Circle[];

While this method works, it lacks type safety. Suppose another developer or even our future selves happens to change the filter predicate to filter for "square", forgetting to update the type assertion. All the downstream types will be incorrect!

A better attempt would be to write a predicate using type guards.

function isCircle(shape: Shape): shape is Circle {
  return shape.kind === "circle"
}

const circlesTypeGuard = shapes.filter(isCircle);

This is certainly better. circlesTypeGuard is typed as Circle[] and done without risking type safety. We could stop there, but as all shape-based systems go, we’ll want to add more and more shapes. Soon we’ll find we have hundreds of shapes, each requiring us to write a predicate. The DRY voice in our heads won’t let us sleep at night with such a catastrophe.

Ideally, we need an isKind function that takes a shape and kind, returns a boolean if the shape matches the given kind, and maintains type safety. Ensuring type safety is the crux here.

Such a task requires tapping into our TypeScript sorcery. We are dealing with a situation known as a discriminated union type. We have a union type Shape we want to operate over, and we know every Shape shares a property, a discriminant, kind. The discriminant property allows TypeScript to determine which specific variant of the union type is being used at any given point. When we’re in a discriminated union, we can use the type guard technique discussed above, and the TypeScript utility type Extract to pattern match.

function isKindFn<Kind extends Shape['kind']>(kind: Kind) {
  return (shape: Shape): shape is Extract<Shape, { kind: Kind }> =>
    shape.kind === kind;
}

const circlesIsKind = shapes.filter(isKindFn("circle"));

If you stare at it long enough, it starts to make sense. We have to return a function because TypeScript doesn’t propagate predicates to parent functions. Our function operates with a generic type parameter Kind which extends the kind`s in our `Shape union type. The kind parameter will be one of those Kind. We return a function that takes a Shape and checks if the shape’s `kind matches the kind passed to the outer function. The function is annotated with a return type using a type guard and employing the Extract utility type. Our use of Extract states that from the types Shape represents, we only want to consider the ones that have the kind property Kind (the kind passed into the function).

And voila! Our filter is now correctly types as Circle[]. Battle won.


Full code used in this post is available here.

Written on 2023-05-18