interface Square {
kind: 'square';
}
interface Circle {
kind: 'circle';
}
type Shape = Square | Circle
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.
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.