Composing io-ts Codecs

·

2 min read

This post assumes that you know what io-ts is. If you want to learn about it I recommend reading this page and the docs.

It is pretty common to want to do something like take a value with an unknown type, check that it is a string, check that the string is valid JSON, parse it if it is, and then check that that parsed value has the type that we want. For example we might want to define a function that has the type

import * as t from "io-ts";

type unknownToJsonToPerson = (
  value: unknown
) => t.Validation<{ name: string; age: number }>;

We could use io-ts and io-ts-types to fill in the function body like this

import * as t from "io-ts";
import { flow } from "fp-ts/function";
import { JsonFromString } from "io-ts-types";
import { chain } from "fp-ts/lib/Either";

const unknownToJsonToPerson: (
  value: unknown
) => t.Validation<{ name: string; age: number }> = flow(
  t.string.decode,
  chain(JsonFromString.decode),
  chain(t.strict({ name: t.string, age: t.number }).decode)
);

Which is... OK, but we can do better. Because, in my experience, something one needs to do a lot when using io-ts it would be nice if we could have a compose function that we could use to compose codecs. We could then use that to do something like

import * as t from "io-ts";
import { pipe } from "fp-ts/function";
import { JsonFromString, Json } from "io-ts-types";

const unknownToJsonToPerson = pipe(
  t.string,
  compose(JsonFromString),
  compose<unknown, Json, { name: string; age: number }>(
    t.strict({ name: t.string, age: t.number })
  )
);

Handily, I've written that compose function and it looks like this!

export const compose =
  <I2, A extends I2, B>(Codec2: t.Type<B, A, I2>) =>
  <O1, I1, Name extends string = string>(
    Codec1: t.Type<A, O1, I1>,
    name?: Name
  ): t.Type<B, O1, I1> => {
    return new t.Type<B, O1, I1>(
      name || `${Codec1.name} -> ${Codec2.name}`,
      (u): u is B => Codec2.is(u),
      (a, c) =>
        pipe(
          a,
          (a) => Codec1.validate(a, c),
          chain((b) => Codec2.validate(b, c))
        ),
      (b) =>
        pipe(
          b,
          (b) => Codec2.encode(b),
          (a) => Codec1.encode(a)
        )
    );
  };

Un-handily, and as you might have noticed from my usage example, after piping to compose once you do have to explicitly define the type of your second invocation. I don't understand why this is the case, but if you can work out a way around this leave a comment explaining how, and ideally explaining why it was a problem in the first place, and I will be very pleased with you.