Editor’s note: This article was last updated on 18 April 2023 to include information about some popular TypeScript pattern matching libraries, including TS-Pattern, Unionize, and Pratica.
Most programmers understand type safety as a programming language feature that eliminates type errors. As a statically typed superset of JavaScript, TypeScript addresses this issue, especially in strict mode, which is more rigorous and performs additional checks.
With that said, I’m more interested in understanding type safety as the extent of program correctness rather than just making sure that what I expect to be a string is a string and not a number.
In this article, we’ll present several techniques that you can apply when working on day-to-day tasks to increase your confidence that your code is correct. Let’s get started!
Jump ahead:
Either
left
or right
Either
left
or right
Often in our programs, the majority of bugs are caused by either handling a particular case incorrectly or not handling it at all. This is a very broad definition of what causes bugs, but the remedy is also generic and has many applications. To address issues that originate from incorrectly handling decisions in our code, we use algebraic data types, a popular concept in functional programming.
An algebraic data type is a kind of composite type, a type that is a result of combining a few types together. Sound familiar? There is a similar construct in TypeScript called a union type:
type Either = "left" | "right"; const result1: Either = "left"; const result2: Either = "right"; const result3: Either = "forward";
In the code above, TypeScript will throw an error for result3
because it’s not assignable to the type Either
. The result
type accepts only left
or right
values, and forward
isn’t assignable to the type Either
. Is this an algebraic data type we’re looking for? Not yet, first, we need an interface:
type Left = { tag: "left", error: Error }; type Right<T> = { tag: "right", result: T }; type Either<T> = Left | Right<T>;
Either
is now a tagged union, or a discriminated union. The TypeScript type system is structural, and a tagged union is as close as we can get to an algebraic data type in TypeScript. This notation is actually very close to how we can export algebraic data types to JSON in purely functional languages like Haskell.
What’s the benefit of such an approach? While it might look like unnecessary boilerplate, it pays off. We can now simulate pattern matching with a switch
statement:
The function allows us to match on a tag of a value of type Either<string>
. Right away, we get a hint on all values from which we can choose:
Now, TypeScript knows that the only available members of the left
value are tag
and error
. The result is only available on the right
type, which we know doesn’t fall under the tag that equals left
:
I forgot to handle the right
case! By specifying the match
return type explicitly, TypeScript can warn me about any cases I forgot to handle:
function match (value: Either <string>): string { switch(value.tag) { case "left": return value.error.message; case "right": return value.result; default: //Type guard const _exhaustiveCheck: never = value; return _exhaustiveCheck; } }
We’ve also added a type guard to ensure that all possible cases are handled. Now, match
returns a string for every case of value it accepts. Now, you should have an understanding of which algebraic data types can be useful.
Pattern matching with a switch
statement is widely used because it is both type-safe and easy to understand. The TypeScript compiler can detect if you miss any case in the switch
statement, and you can use the type system to enforce the correct properties for each variant.
We can implement the reusable implementation of match
using callbacks:
type Left<T> = { tag: "left", value: T }; type Right<T> = { tag: "right", value: T }; type Either<L, R> = Left<L> | Right<R>; function match<T, L, R>( input: Either<L, R>, left: (left: L) => T, right: (right: R) => T, ) { switch (input.tag) { case "left": return left(input.value); case "right": return right(input.value); } } function validate(): Either<Error, { name: string }> { } const value: string | null const value = match( validate(), _left => null, // _left: Error right => right.name, // right: { name: string } );
What’s really neat about this particular match
implementation is that as long as you get the type of the input right, the rest of the types for a match
call are inferred, and no additional typing is required.
Before we look into a more complex example, have you heard about the Either
type before? There’s a good chance you have!
We often use the Either
type when we have to handle either of two cases. By convention, left
is used to hold an error, and right
is used to hold the correct value. If you have a problem remembering the order, think about how the correct value belongs on the “right” side. If that doesn’t stick, consider the arguments and callbacks we’re used to in Node.js:
import fs from "fs"; fs.readFile("input.txt", (err, data) => { if (err) return console.error(err); console.log(data.toString()); });
The first argument on the left side of the arguments list is an error, and the second on the right side of the arguments list is a result. If you’re interested in algebraic data types, take a look at fp-ts
, a library that defines many different algebraic data types and has a rich ecosystem.
There are several pattern matching libraries available for TypeScript that provide more expressive and concise ways to perform pattern matching. These libraries offer different APIs and features; depending on your use case and personal preference, you can choose the one that best fits your needs.
TS-Pattern is a lightweight library that allows you to use pattern matching with TypeScript in a functional programming style. It provides an intuitive and type-safe API to match patterns and destructure data. Some of its features include:
match
expressionThe code below shows an example usage:
import { match, __ } from "ts-pattern"; type Shape = Circle | Rectangle | Triangle; interface Circle { kind: "circle"; radius: number; } interface Rectangle { kind: "rectangle"; width: number; height: number; } interface Triangle { kind: "triangle"; base: number; height: number; } const getArea = (shape: Shape): number => { match(shape) .with({ kind: "circle" }, ({ radius }) => Math.PI * radius * radius) .with({ kind: "rectangle" }, ({ width, height }) => width * height) .with({ kind: "triangle" }, ({ base, height }) => 0.5 * base * height) .exhaustive(); } const circle: Circle = { kind: "circle", radius: 5 }; const rectangle: Rectangle = { kind: "rectangle", width: 4, height: 6 }; const triangle: Triangle = { kind: "triangle", base: 3, height: 4 }; console.log(getArea(circle)); // 78.53981633974483 console.log(getArea(rectangle)); // 24 console.log(getArea(triangle)); // 6
In the code snippet above, we use the TS-Pattern library to create a union type, which is also called a sum type, and a function to calculate the area of different shapes using pattern matching.
match
is a function from the TS-Pattern library that allows you to perform pattern matching in a functional programming style. __
is a wildcard pattern that can be used to match any value.
Unionize is a TypeScript library that focuses on creating tagged unions and action creators with minimal boilerplate. It provides an API that allows you to concisely define and match against union types:
import { Unionize, ofType } from "unionize"; const Shapes = Unionize({ Circle: ofType<{ radius: number }>(), Rectangle: ofType<{ width: number; height: number }>(), Triangle: ofType<{ base: number; height: number }>(), }); type Shape = typeof Shapes._Union; function getArea(shape: Shape): number { switch (Shapes.match(shape)) { case "Circle": const { radius } = shape; return Math.PI * radius * radius; case "Rectangle": const { width, height } = shape; return width * height; case "Triangle": const { base, height } = shape; return 0.5 * base * height; } }
In the code snippet above, we use the Unionize
function from the Unionize library to create a tagged union, also known as a sum type. We also create a function to calculate the area of different shapes using pattern matching. ofType
is a helper function that creates a type definition for each variant of the union.
Pratica is a functional programming library for TypeScript that provides tools for creating and working with sum and product types, including pattern matching. It supports features like exhaustive pattern matching, partial application, and currying:
import { Union, of } from "pratica"; const Shape = Union({ Circle: of<{ radius: number }>(), Rectangle: of<{ width: number; height: number }>(), Triangle: of<{ base: number; height: number }>(), }); const getArea = Shape.match({ Circle: ({ radius }) => Math.PI * radius * radius, Rectangle: ({ width, height }) => width * height, Triangle: ({ base, height }) => 0.5 * base * height, });
In the code above, we use the Union
function from the Pratica library to define a sum type or a tagged union. We also create a function to calculate the area of different shapes using pattern matching. of
is a helper function that creates a constructor for each variant of the sum type.
We can apply the very same technique we used to come up with our Either
type, where using a switch
statement is already popular, in Redux’s reducer. Instead of having only a binary option of left
or right
, we can have as many options as we have action types to handle.
Thanks to accurate autocompletion, we strive to optimize for reducer correctness and ease of development:
enum ActionTypes { REQUEST_SUCCESS = "REQUEST_SUCCESS", REQUEST_FAILURE = "REQUEST_FAILURE", } type SFA<T, P> = { type: T, payload: P }; const createAction = <T extends ActionTypes, P>( type: T, payload: P ) : SFA<T, P> => ({ type, payload }); const success = (payload: { items: Todo[] }) => createAction(ActionTypes.REQUEST_SUCCESS, payload); const failure = (payload: { reason: string }) => createAction(ActionTypes.REQUEST_FAILURE, payload); const actions = { success, failure }; type Action = ReturnType<typeof actions[keyof typeof actions]>; type Todo = { id: string }; type State = { items: Todo[] , error: string }; function reducer(state: State, action: Action): State { switch (action.type) { case ActionTypes.REQUEST_SUCCESS: return { ...state, items: action.payload.items, error: "" }; case ActionTypes.REQUEST_FAILURE: return { ...state, items: [], error: action.payload.reason }; } return state; }
In the code above, I defined action types as a string enum. The SFA
type stands for a standard flux action and can be overloaded together with createAction
to accommodate more action shapes, but this is not the most important step at the moment.
The interesting part is how we built the Action
type. Using ReturnType
, we can obtain types of actions returned by action creators directly from the actions
object:
This significantly reduces the amount of typing we have to do in every reducer without compromising type safety.
Have you ever defined a type for the JSON payload? In general, HTTP clients allow you to do that, but you have no guarantee that what you actually fetch will match the interface you have specified. Here, runtime types come in handy.
The io-ts
library adopts this approach and blurs the boundary between what’s possible to type statically and what otherwise would require writing defensive code and custom type guards:
import axios from "axios"; 14.4K (gzipped: 5.1K) import * as t from "dio-ts"; 24.8K (gzipped: 6K) const Repository = t.type({ description: t.string, stargazers_count: t.number, }) type IRepository = t.TypeOf<typeof Repository>;
Defining Repository
, a runtime type, is as effortless as defining an interface in TypeScript. It’s also possible to extract the static type from the runtime type using TypeOf
:
(async () => { const response = await axios.get("https://api.github.com/repos/Microsoft/TypeScript"); const repo = Repository.decode(response.data); const starts: number const starts = repo.fold( _errors => 0, repo => repo.stargazers_count ) console.log(starts); })();
I can fetch the payload without worrying about specifying the type of the response. I decode the payload to what I expect to be the Microsoft/TypeScript GitHub repository.
I only have to define the fields I’m interested in. Calling fold
on the repo is similar to how we used the match
function. In fact, the repo is of type Either
, which has a slightly different implementation than our Either
, but the idea is the same. The left value is a list of errors that prevented the payload from parsing correctly, and the right value is the repository.
When working with complex data structures or discriminating unions, pattern matching is especially useful. Although TypeScript doesn’t have native pattern matching, its community has created several pattern matching libraries that offer more expressive and concise ways to perform pattern matching.
I intentionally tried to avoid throwing and handling errors in the provided examples. Error handling isn’t an afterthought; we model software treating errors as part of the domain. It’s not easy, but I found it to be a great learning experience.
I would also encourage you to avoid using any
in your interfaces and function signatures. It’s an escape hatch that quickly propagates across all of its consumers, either forcing you to lose the benefits of static typing or assert types, explicitly using as
syntax or guard function.
I hope the examples provided give you some guideline on how you can incorporate algebraic data types into your own project. Don’t forget to try out io-ts
yourself!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.