Run-Time Type Checking in TypeScript with io-ts

Run-Time Type Checking in TypeScript with io-ts

At Azavea we use TypeScript on many of our projects and products to bring the benefits of static typing to JavaScript. Typescript makes it easy to write type-safe functions by using type annotations. We can annotate the inputs and outputs and be confident that the function is going to be operating on what we expect:

const addSomeNumbers = (numbers: number[]): number =>
  numbers.reduce((a, b) => a + b, 0);

When we pass something that is not a number to this function, the Typescript compiler will emit an error:

Type 'string' is not assignable to type 'number'.

If we similarly annotate the entirety of an application the TypeScript compiler can ensure that we’ve constructed a type-safe application.

Until we start taking input.

In this post, we’ll be using some advanced TypeScript libraries to help us stay type-safe even in the face of unknown inputs. In order to get the most from this post, I recommend having a basic understanding of TypeScript.

Type safety erosion at the boundaries

The points where our program receives input form the boundaries between our cozy type-safe box and the rest of the universe. On the web, these input boundaries generally fall into two categories: user input and HTTP (and other networks) operations.

Validating user input has been a best-practice for as long as HTML has existed. HTML itself provides some APIs to do basic validation on input elements:

<input type="number" min="0" max="10" />

With these attributes in place, the user will not be able to enter a value less than zero or more than ten, nor can they input something that is not a number.

There is no such built-in utility for network operations.

Let’s fetch some numbers from a fictional API to feed to our addSomeNumbers function.

// index.ts

import axios, { AxiosResponse } from "axios";

const addSomeNumbers = (numbers: number[]): number =>
  numbers.reduce((a, b) => a + b, 0);

const getNumbers = (): Promise<AxiosResponse<number[]>> => axios.get('/random-numbers');

// Note that axios responses put the response body in .data
getNumbers().then(({ data }) => addSomeNumbers(data));

Nice. We’ve annotated our getNumbers function to indicate what the API endpoint returns: an array of numbers. We then passed the result to our addSomeNumbers function. The compiler is quite pleased with this and so we feel good. Type-safety!

But there’s a problem.

What happens if the endpoint’s response isn’t what we expect? We have no idea.

Want to work on projects with a social and civic impact? Learn what it’s like to work at Azavea.

Visit our career site

We encoded a dangerous assumption (that we knew what shape the response would take) in the return-type annotation of getNumbers: Promise<AxiosResponse<number[]>>. The correct annotation is really Promise<AxiosResponse<unknown>>.

Had we properly annotated getNumbers, the compiler would have stopped us in our tracks when we attempted to use the response body: Argument of type 'unknown' is not assignable to parameter of type 'number[]'.

Parsing the unknown

In our previous example, we failed to accurately model the run-time conditions but the correct types leave us at what seems like a dead-end. We have an unknown but we need number[]. How can we do that safely? The answer is: parsing.

io-ts is a runtime type system that is built on top of fp-ts. It provides utilities for constructing codecs that can decode and encode data. To solve our current problem, we’ll be focusing on the decoders.

A decoder is a mapping between real-world information and data that a program can understand. It attempts to parse data into the structure we need and fails if that conversion isn’t possible.

In our case, we’ll use io-ts decoders to go from unknown data we receive from a server through axios to data structured in the way we expect. If that conversion isn’t possible io-ts will let us know why. io-ts uses an Either to represent either the failure message or the data we expected. Let’s see how that works.

// index.ts

import axios, { AxiosResponse } from "axios";
import * as T from "io-ts";
import * as E from "fp-ts/Either";
import { failure } from "io-ts/PathReporter";
import { pipe } from "fp-ts/pipeable";

// 1
const numbersCodec = T.array(T.number);

const addSomeNumbers = (numbers: number[]): number =>
  numbers.reduce((a, b) => a + b, 0);
// 2
const getNumbers = (): Promise<AxiosResponse<unknown>> =>
  axios.get('/random-numbers');

getNumbers().then(({ data }) => {
  // 3
  const decodingResult: E.Either<T.Errors, number[]> = numbersCodec.decode(data);
  addSomeNumbers(pipe(
    decodingResult,
    // 4
    E.getOrElseW((errors) => {
      throw new Error(failure(errors).join("\n"));
    })
  ));
});

Let’s walk through the changes:

  1. We declare a new codec instance, numbersCodec using io-ts combinators.
  2. We ensure our type annotation for the getNumbers function reflects our defensive approach. Our inner-most type is now an unknown instead of number[].
  3. We fetch what we think is an array of numbers and pass the response body (data) to the decode method of our numbersCodec. The type annotation (E.Either<T.Errors, number[]>) is unnecessary here but I’ve included it for clarity. An Either is a structure that can contain one of two types of data. In this context, the Either contains either the errors from a failed decoding attempt (T.Errors) or the successfully decoded data (number[]).
  4. We use a method from the fp-ts/Either module called getOrElseW. It allows us to extract the value if our decoding succeeds, otherwise, it allows us to do something with the errors. In this case, we merge error messages and explicitly throw an error. If our API endpoint suddenly returned an array that looked like [1, 2, "hello"], our thrown error would read Error: Invalid value "hello" supplied to: Array<number>/2:number, meaning that a string was encountered at index 2.

This looks a lot different (and more complicated) than our previous examples but we have achieved two very important goals:

  1. No silent “errors” that allow the generation of incorrect data
  2. No run-time errors that cause unexpected terminations

Our program doesn’t do anything with the data until it gets safely parsed. If the data can’t be parsed, an error is thrown at the absolute beginning of the pipeline.

Streamlining the decoding process

Our latest example does what we wanted, but it’s verbose and difficult to read. Let’s simplify the process by creating a generic function we’ll call decodeWith:

// decodeWith.ts

import * as T from "io-ts";
import * as E from "fp-ts/Either";
import { failure } from "io-ts/PathReporter";
import { pipe } from "fp-ts/pipeable";

const decodeWith = <
  ApplicationType = any,
  EncodeTo = ApplicationType,
  DecodeFrom = unknown
>(
  codec: T.Type<ApplicationType, EncodeTo, DecodeFrom>
) => (input: DecodeFrom): ApplicationType =>
  pipe(
    codec.decode(input),
    E.getOrElseW((errors) => {
      throw new Error(failure(errors).join("\n"));
    })
  );

export default decodeWith;

decodeWith takes an io-ts codec and returns a function that handles decoding and error-handling. Now we can a big step towards cleaning up our HTTP code:

// index.ts

import axios, { AxiosResponse } from "axios";
import * as T from "io-ts";
import decodeWith from "./decodeWith";

const numbersCodec = T.array(T.number);

const addSomeNumbers = (numbers: number[]): number =>
  numbers.reduce((a, b) => a + b, 0);

const getNumbers = (): Promise<AxiosResponse<unknown>> =>
  axios.get('/random-numbers');

getNumbers().then({data} => addSomeNumbers(decodeWith(numbersCodec)(data)));

This is a big improvement, but the decoding is happening separately from our request. The final piece to the puzzle will be an additional function, decodeResponseWith that accommodates the AxiosResponse structure

// decodeResponseWith.ts

import * as T from "io-ts";
import { AxiosResponse } from "axios";
import decodeWith from "./decodeWith";

const decodeResponseWith = <
  ApplicationType = any,
  EncodeTo = ApplicationType,
  DecodeFrom = unknown
>(
  c: T.Type<ApplicationType, EncodeTo, DecodeFrom>
) => (response: AxiosResponse<any>): AxiosResponse<T.TypeOf<typeof c>> => ({
  ...response,
  data: decodeWith(c)(response.data)
});

export default decodeResponseWith;

Like the decodeWith method, this new method takes an io-ts codec. If the call to decodeWith results in a successful decoding, we return a new AxiosResponse that contains the safely decoded values under the data property.

With this method in hand we can make the decoding the HTTP calls seamless:

// index.ts

import axios, { AxiosResponse } from "axios";
import * as T from "io-ts";
import decodeResponseWith from "./decodeResponseWith";

const numbersCodec = T.array(T.number);

const addSomeNumbers = (...numbers: number[]): number =>
  numbers.reduce((a, b) => a + b, 0);

const getNumbers = (): Promise<AxiosResponse<numbers[]>> =>
  axios.get('/random-numbers').then(decodeResponseWith(numbersCodec));

getNumbers().then({ data } => addSomeNumbers(data));

The final result here looks very similar to the very first example but we are now type-safe even at run-time. The elegance of this approach is that all calls to this endpoint are automatically validated and decoded. With a few extra lines of code, we can now use data from responses knowing that we’re going to get the data we expect.

The costs

There are always trade-offs when adopting new libraries or techniques and this approach is no exception:

  • io-ts codecs will become the source of truth for types of anything that come over the wire. Read more about this in the io-ts docs. It’s not possible to create codecs from existing TypeScript types or interfaces.
  • Using io-ts requires an understanding of basic fp-ts constructs. The fp-ts learning curve is significant and it should be factored into any decision about adoption — we’ve found that even the functional programming enthusiasts at Azavea can struggle with determining what the idiomatic fp-ts way of solving a problem might be.
  • The documentation can be sparse and there aren’t many examples of io-ts and fp-ts code in the wild to pull from when you get stuck.
  • The error messages emitted by default when decoding fails can be difficult to read.

The benefits

Despite these various trade-offs, run-time type checking with io-ts has been an absolute game-changer for our applications:

  • The stable HTTP layers that it helps to build allow us to deliver features faster and with a higher level of confidence than ever before.
  • The io-ts paradigm naturally eliminated instances of shotgun parsing by encouraging fully defined codecs.
  • It’s easy (as we’ve shown) to do the requesting and parsing in a tight sequence to avoid partial evaluation of bad data.
  • The ability to compose codecs encourages collocation of related codecs which helps keep the code-base well-organized and easy to understand.

The bottom line

Don’t wait to start checking run-time types if you’re already using TypeScript.

If you don’t want to opt-in to the functional programming paradigms that io-ts and fp-ts bring along, check out zod, which is heavily inspired by io-ts but doesn’t depend on fp-ts.