Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Bridging Static and Runtime Types with io-ts

What’s io-ts? In theory, io-ts is a runtime type system for IO decoding/encoding. In practice, io-ts is an elegant solution to a very nasty problem.

Statically-typed applications that interact with the external world are facing a challenge to keep this interaction surface type safe. One of the most prominent is data input, especially working with requested data via REST or GraphQL endpoints but also structured text files, uploaded CSV and JSON files.

If you get stuck and would like to try a more gradual introduction or would like to read more about algebraic data structures, read my previous article Pattern matching and type safety in TypeScript.

Prone API to changes

Although HTTP clients like axios allow us to specify a type of the payload we expect to receive in response to each request we make, it’s only as good as the API documentation or our empirical experience with particular API:

  • Oh, this number is string decimal
  • Oh, this number is an actual number
  • Oh, this boolean is actually 0 or 1, not true and false

Can you relate? I certainly can, my frustration reached its peak in mid-2016. Despite not having OCD, I felt I had to do something about it and created mappet.

const schema = {
  firstName: "first_name",
  cardNumber: "card.number",
};
const mapper = mappet(schema);
const source = {
  first_name: "Michal",
  last_name: "Zalecki",
  card: {
    number: "5555-5555-5555-4444",
  },
};
const result = mapper(source);
// {
//   firstName: "Michal",
//   cardNumber: "5555-5555-5555-4444",
// }

You probably aren’t familiar with mappet. This rather unpopular library enjoys fine retention. It maps responses making them what you want them to be instead of what you get. For me, the idea of pouring raw data from the external service into my application was always crazy as you’re just left hoping it somehow won’t break.

io-ts basics

io-ts, the primary topic of this article, addresses the same issue, but through empowering the type system with a runtime component. These runtime components are called codecs.

A value of type Type<A, O, I> (codec) is the runtime representation of the static type A. It:

  • decodes inputs of type I (through decode)
  • encodes outputs of type O (through encode)
  • can be used as a custom type guard (through is)

For those of you who are familiar with Elm, you might find io-ts similar to Json.Decode and Json.Encode modules.

Let’s start with creating a simple string codec. The string codec makes sure that string is a string also at the runtime.

const isString = (u: unknown): u is string => typeof u === "string";

// Type<A, O, I>
const string = new t.Type<string, string, unknown>(
  "string",
  isString,
  (input, context) =>
    isString(input) ? t.success(input) : t.failure(input, context),
  t.identity,
);

Through codec decode method we can parse the input which in this case isn’t specified (unknown type).

string.decode("100").map(str => str.toUpperCase()).getOrElse("Invalid");
// "100"
string.decode(100).map(str => str.toUpperCase()).getOrElse("Invalid");
// "Invalid"

decode method returns an Either<t.Errors, A>. Either is a monad that actually comes from a different, sister library fp-ts. fp-ts is a set of many common data types and type classes, so if you want to implement something that works on such data types, you use fp-ts and make your library interoperable with the rest of the fp-ts ecosystem. Monads may sound intimidating, but in reality, they’re just a bunch of discriminated unions with some basic filter/map/reduce method variations.

Returned Either<t.Errors, A> has multiple methods that allow you to make transformations and work on A type without considering whether this particular decode call resulted in an error or not. Once you want to consume the value, you choose one of a few approaches. In the aforementioned example, we just went with simple getOrElse, which is self-explanatory.

Codecs helper

If you see little or no value in the string codec, I understand that and bear with me, because this is not what you typically do when working with io-ts. The majority of codecs you create with t.type helper. And t.type helper is awesome!

import * as t from "io-ts";

const HNPost = t.type({
  title: t.string,
  url: t.string,
  points: t.number,
});

type HNPost = t.TypeOf<typeof HNPost>;

HNPost is a codec for part of the Hacker News post available through the Aloglia API. What really makes it viable to use in a typical project is that it doesn’t require additional effort to define the interface and codec separately through leveraging t.TypeOf<tyoeof t.Type>.

Codecs composition

Codecs can be composed to create deeply nested types to represent not only a single entity but the response payload.

const HNPosts = t.type({
  hits: t.array(HNPost),
  page: t.number,
});

This is how we could process the response payload to request for the Hacker News posts.

const res = await axios.get("http://hn.algolia.com/api/v1/search?query=typescript");
const posts = HNPosts.decode(res.data);

Notice that we haven’t passed any type to axios.get and res.data has now the any type by default. This is fine because at that point that’s the only information we can truly trust. Let’s say we want to sum up all points of the top TypeScript articles.

import { PathReporter } from "io-ts/lib/PathReporter";
const pointsSum = posts.map(post => post.hits.reduce((s, p) => s + p.points, 0));

if (pointsSum.isRight()) {
  console.log(`Sum of points is ${pointsSum.value}`);
} else {
  console.log(PathReporter.report(pointsSum));
}

Either’s isRight or corresponding isLeft methods are TypeScript type guards so you can use them to assert on types and implement an alternative way to consume underlying value.

Reporters are simple instances that are a pluggable mechanism to handle errors. The only requirement for the reporter is to implement the Reporter interface.

interface Reporter<T> {
  report: (validation: Validation<any>) => T
}

Where Validation<A> is

import { Either } from "fp-ts/lib/Either";

type Validation<A> = Either<t.Errors, A>;

Wrap-up

You want to consider how safe it is to pass data from the API server (or any other source) straight into your application state. To make sure payloads are what you expect them to be, use io-ts that’s an excellent choice but its narrow focus on encoding and decoding may feel constraining.

Consider using mappet as an extension of your data transformation logic to modify and filter entries. Apart from only type-safety, you get increased flexibility and more control over data you work with.

This article has been originaly posted on Tooploox's blog: Bridging Static and Runtime Types with io-ts

Photo by Marcus Benedix on Unsplash.