io-ts; or Money for nothing and validation for free
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