Runtime type system for IO decoding/encoding
The idea
A value of type Type<A, O, I>
(called “codec”) is the runtime representation of the static type A
.
A codec can:
- decode inputs of type
I
(throughdecode
) - encode outputs of type
O
(throughencode
) - be used as a custom type guard (through
is
)
class Type<A, O, I> {
constructor(
/** a unique name for this codec */
readonly name: string,
/** a custom type guard */
readonly is: (u: unknown) => u is A,
/** succeeds if a value of type I can be decoded to a value of type A */
readonly validate: (input: I, context: Context) => Either<Errors, A>,
/** converts a value of type A to a value of type O */
readonly encode: (a: A) => O
) {}
/** a version of `validate` with a default context */
decode(i: I): Either<Errors, A>
}
The Either
type returned by decode
is defined in fp-ts, a library containing implementations of common algebraic types in TypeScript.
The Either
type represents a value of one of two possible types (a disjoint union). An instance of Either
is either an instance of Left
or Right
:
type Either<E, A> =
| {
readonly _tag: 'Left'
readonly left: E
}
| {
readonly _tag: 'Right'
readonly right: A
}
Convention dictates that Left
is used for failure and Right
is used for success.
Example
A codec representing string
can be defined as:
import * as t from 'io-ts'
const string = new t.Type<string, string, unknown>(
'string',
(input: unknown): input is string => typeof input === 'string',
// `t.success` and `t.failure` are helpers used to build `Either` instances
(input, context) => (typeof input === 'string' ? t.success(input) : t.failure(input, context)),
// `A` and `O` are the same, so `encode` is just the identity function
t.identity
)
and we can use it as follows:
import { isRight } from 'fp-ts/lib/Either'
isRight(string.decode('a string')) // true
isRight(string.decode(null)) // false
More generally the result of calling decode
can be handled using fold
along with pipe
(which is similar to the pipeline operator)
import * as t from 'io-ts'
import { pipe } from 'fp-ts/lib/pipeable'
import { fold } from 'fp-ts/lib/Either'
// failure handler
const onLeft = (errors: t.Errors): string => `${errors.length} error(s) found`
// success handler
const onRight = (s: string) => `No errors: ${s}`
pipe(t.string.decode('a string'), fold(onLeft, onRight))
// => "No errors: a string"
pipe(t.string.decode(null), fold(onLeft, onRight))
// => "1 error(s) found"
We can combine these codecs through combinators to build composite types which represent entities like domain models, request payloads etc. in our applications:
import * as t from 'io-ts'
const User = t.type({
userId: t.number,
name: t.string
})
So this is equivalent to defining something like:
type User = {
userId: number
name: string
}
The advantage of using io-ts
to define the runtime type is that we can validate the type at runtime, and we can also extract the corresponding static type, so we don’t have to define it twice.
Static types can be extracted from codecs using the TypeOf
operator:
type User = t.TypeOf<typeof User>
// same as
type User = {
userId: number
name: string
}
Error reporters
A reporter implements the following interface
interface Reporter<A> {
report: (validation: Validation<any>) => A
}
This package exports a default PathReporter
reporter
Example
import { PathReporter } from 'io-ts/lib/PathReporter'
const result = User.decode({ name: 'Giulio' })
console.log(PathReporter.report(result))
// => [ 'Invalid value undefined supplied to : { userId: number, name: string }/userId: number' ]