io-ts; or Money for nothing and validation for free

Posted on November 21, 2018

This post assumes familiarity with TypeScript.

Motivation

One of the caveats of TypeScript is the ability for the compile-time type of a value to not match the run-time value. This is especially troublesome for values coming in from application boundaries (user input, external services, etc). Validating inputs at application boundaries is a good idea, to the point of being a programming truism - that said there are better and worse ways to do so. io-ts is one of the better ways.

The Problem

Consider the following type:

interface myInterface {
   name: string,
   value: number
}

This is fairly easy to validate with a little bit of code. (Please note I’m taking liberties and assuming !!value returns true for values that are not null or undefined - I’m ignoring weird falsiness behavior out of expediency in explanation over correctness)

const validateMyInterface = (maybeMyInterface) =>
    !!maybeMyInterface
    && !!maybeMyInterface.name
    && maybeMyInterface.name instanceOf string
    && !!maybeMyInterface.value
    && maybeMyInterface.value instanceOf Number

That’s a fair bit of typing and some of these pieces look reusable, so maybe we break these down into smaller pieces and build up from there.

const validateExists = (maybeExists) =>
    maybeExists !== null
    && maybeExists !== undefined,
  validateString = (maybeString) =>
    validateExists(maybeString)
    && maybeString instanceOf string,
  validateNumber = (maybeNumber) =>
    validateExists(maybeNumber)
    && maybeNumber instanceOf Number,
  validateMyInterface = (maybeMyInterface) =>
    validateExists(maybeMyInterface)
    && validateString(maybeMyInterface.name)
    && validateNumber(maybeMyInterface.value);

That’s nice, but surely somebody else has already done a lot of this legwork - it’s generic enough to assume this is a solved problem. Perhaps we’re wasting effort here - but that’s not the most troubling problem. What concerns me most is keeping the validator in sync with the actual type. If myInterface is updated, I will need to remember to update validateMyInterface as well. This means this solution hasn’t completely solved the problem of the compile-time type and the run-time value being different.

The Solution

io-ts is the prior work to solve validating run-time values against compile time types. Even better, it can be used in a way that completely avoids the type and validator drifting apart.

Once again, let’s write a validator for myInterface.

import * as t from 'io-ts'

const myInterfaceValidator = t.interface({
  name: t.string,
  value: t.number
});

That looks strikingly similar to the interface declaration. Here it is again so we can see them side by side:

interface myInterface {
  name: string
  value: number
}

It’s still possible for the validator to drift from the type definition though. io-ts is savvy enough to give us a way to allow us to generate both the validator and the type from the same code.

interface myInterface extends t.TypeOf<typeof myInterfaceValidator> {}

Just like that, validator and type definition are created at once and can never drift apart.

Usage Example

import * as t from 'io-ts'
import {ThrowReporter} from 'io-ts/lib/ThrowReporter'

const tMyInterface = t.interface({
    name: t.string,
    value: t.number
});

export interface MyInterface extends t.TypeOf<typeof tMyInterface> {}

const maybeMyInterface = {},
    validationResult = tMyInterface.decode(maybeMyInterface);

//throws an exception if validation doesn't pass
ThrowReporter.report(validationResult);

console.log(validationResult.value);

validationResult is an Either value, and does not strictly need to be passed through a Reporter. It is possible to inspect the result, using isLeft() and isRight() functions and take whatever action is appropriate for the use case.

References

io-ts Lorefnon - TypeScript and validations at runtime boundaries fp-ts documentation - Either