How I use io-ts to guarantee runtime type safety in my TypeScript

How I use io-ts to guarantee runtime type safety in my TypeScript

Photo by Time Swaan on Unsplash

I discovered the io-ts library while listening to a podcast featuring Gary Bernhardt. You may know Gary from videos like Wat, and The Birth & Death of JavaScript. Go watch those videos; Gary is one of the best orators in our industry today.

The podcast featured a section diving deep into the tech stack for Gary's latest project—Execute Program. In it, Gary was lamenting the TypeScript team's decision to make TypeScript an erasable type system. That is to say that TypeScript does not have a runtime, after it is compiled to Javascript its types no longer exist. A consequence of this erasable type system is that you can't really trust the shape of input—especially input you receive from external sources. Your TypeScript might compile file, but your Javascript may be filled with undefined values.

Say you started with some Javascript like the following. Your program received some input and you assumed that the input object would have a metadata object which would contain an id string.

const input = `
	{
	  "id": "1234",
	  "name": "Kieran Hunt"
	}
`

console.log(JSON.parse(input).metadata.id);

// console.log(JSON.parse(input).metadata.id);
//                                        ^
// TypeError: Cannot read property 'id' of undefined
//     at Object.<anonymous> (/Volumes/Unix/io-ts-presentation/1.js:6:28)

Your first lesson was to not trust your inputs. You being a rambunctious young upstart computer programmer you see a TypeError thrown there. You've heard that TypeScript can help bring types to your Javascript and so you decide to implement it.

type Input = {
  id: string,
  name: string
}

const input = `
	{
	  "id": "1234",
	  "name": "Kieran Hunt",
	}
`

console.log((JSON.parse(input) as Input).name);
// Kieran Hunt

You've also taken the chance to adjust your types to the input you received the last time you ran your program—you're always thinking about the customer. All seems to go well.

But, at the same time, whoever was running your program adjusted their input to match what your Javascript was expecting.

type Input = {
  id: string;
  name: string;
};

const input = `
	{
	  "id": "1234",
	  "metadata": {
	    "name": "Kieran Hunt"
	  }
	}
`

console.log((JSON.parse(input) as Input).name.length);

// console.log((JSON.parse(input) as Input).name.length);
//                                               ^
// TypeError: Cannot read property 'length' of undefined
💡

Remember! Typescript is erased at runtime. So the compiler just trusts us when we use the as type assertion.

Again we get a TypeError! Your Typescript compiled just file, but you got a runtime TypeError. And people say that Javascript doesn't have types...

Now there are a bunch of different ways for validating that input matches a predefined schema in Javascript. I think the most famous of those is JSON Schema. With JSON Schema, you write a schema definition in their JSON DSL and then use validator libraries to validate whichever input you want. The missing piece with JSON Schema though is that you still need to write your TypeScript types. You're effectively writing types twice. Good luck keeping those in sync.

Enter io-ts! With io-ts, you write your types using a runtime Javascript API. Those type definitions produce compile time TypeScript types and runtime type validators. My oh my!

So we reimplement our code like this.

import * as t from "io-ts";
import { PathReporter } from "io-ts/PathReporter";
import { isLeft } from "fp-ts/lib/These";

const InputCodec = t.type({
  id: t.string,
  name: t.string,
});

type Input = t.TypeOf<typeof InputCodec>;
// A _real_ TypeScript type.

const input = `
	{
	  "id": "1234",
	  "name": "Kieran"
	}
`;

const result = InputCodec.decode(JSON.parse(input));

if (isLeft(result)) {
  console.log(PathReporter.report(result));
} else {
  console.log(result.right.name);
}

// => Kieran Hunt

const anotherInput = `
  {
    "id": "1234",
    "metadata": {
      "name": "Kieran Hunt"
    }
  }
`;

const anotherResult = InputCodec.decode(JSON.parse(anotherInput));

if (isLeft(anotherResult)) {
  console.log(PathReporter.report(anotherResult));
} else {
  console.log(anotherResult.right.name);
}

The astute among you might have noticed the InputCodec there. A codec in io-ts speak is an object that can encode and decode a specific type. You build these codecs using a DSL vended by the library which looks a lot like writing types in TypeScript. The codec yields an actual TypeScript type which completely eliminates the pain of having to maintain two separate type definitions.

Calling .decode on the codec produces something like this.

Either<t.Errors, { id: string; name: string; }>

This is where io-ts starts to look rather functional. That's an either monad. It contains either an errors object or the successfully parsed input object. By some convention, the errors are available using .left and the parsed object using .right. Using the sibling fp-ts library, maintained by io-ts's maintainer, we can use the isLeft function to pick figure out whether we have an error or a correctly formatted input.

💡

Hint. Alias the isLeft function to isError to avoid questions from your colleagues come code review time.

If we have an error, we can use io-ts's built-in error reporter, PathReporter, to get a nicely formatted error message. Great for returning back to your caller.

If we don't have an error, calling .right on the decoded object will return an object of Input type.

And that's just the start of the power of io-ts. In later posts I hope to cover more places that I've found it useful.