Codec

This module allows you to create a boundary on the outermost layer of your application, usually where you process back-end data or communicate with a third-party API. A codec consists of two parts - an encoder and a decoder, hence the name. Using a decoder you can validate your expectations regarding the structure and type of data you're receiving. An encoder, on the other hand, lets you make sure you're sending your application data in the correct format and can also act as a mapper from your custom domain objects to plain JSON values. In case your infrastructure supports JSON schema you can also generate one from your codecs so that you don't have to deal with the error handling.
How to import
import { Codec, GetType, string, number ... } from 'purify-ts/Codec'

Constructors

interface
<T extends Record<string, Codec<any>>>(properties: T): Codec<{[k in keyof T]: GetType<T[k]>}>
Creates a codec for any JSON object.
Codec.interface({
    username: string,
    age: number,
    email: optional(string),
    followers: array(number)
})
Codec<{
  username: string
  age: number
  email?: string
  followers: Array<number>
}>
custom
<T>(config: { decode: (value: unknown) => Either<string, T>, encode: (value: T) => any}): Codec<T>
Creates a codec for any type, you can add your own deserialization/validation logic in the decode argument.
//            ↓↓↓ It's important to specify the type argument
Codec.custom<string>({
    decode: input => (typeof input === 'string' ? Right(input) : Left('fail')),
    encode: input => input // strings have no serialization logic
})
Codec<string>

Type helpers

GetType
GetType<T extends Codec<any>> = T extends Codec<infer U> ? U : never
You can use this to get a free type from any codec.
const User = Codec.interface({
  username: string,
  age: number
})

type User = GetType<typeof User>
// type User will equal {username: string; age: number}
FromType
FromType<T> = {[P in keyof Required<T>]: Pick<T, P> extends Required<Pick<T, P>> ? T[P] : T[P] | undefined}
Special type used when you want to do the opposite of `GetType` - define a Codec for an existing type. The problem is that due to technical limitations optional properties are hard to generate in TypeScript so Codec generates properties of type "T | undefined" instead, which is not compatible.
type A = { a?: number }
const A: Codec<A> = Codec.interface({ a: optional(number) })
type A = { a?: number }
const A: Codec<FromType<A>> = Codec.interface({ a: optional(number) })
// Type 'Codec<{ a: number | undefined; }>' is not assignable to type 'Codec<A>'
Success!

Instance methods

decode
(input: unknown) => Either<string, T>
Takes a JSON value (i.e. a plain JS object) and runs the decode function the codec was constructed with. All of purify's built-in codecs return a descriptive error message in case the decode fails.
encode
(input: T) => unknown
Takes a runtime value and turns it into a JSON value using the encode function the codec was constructed with. Most of purify's built-in codecs have no custom encode method and they just return the same value, but you could add custom serialization logic for your custom codecs.
unsafeDecode
(input: unknown) => T
The same as the decode method, but throws an exception on failure. Please only use as an escape hatch.
schema
() => JSONSchema6
Codec.interface({
  username: string,
  age: number,
  email: optional(optional(oneOf([oneOf[string]]))),
  followers: array(number)
}).schema()
{
  type: 'object',
  properties: {
    username: { type: 'string' },
    age: { type: 'number' },
    email: { type: 'string' }, // Schema is optimized
    followers: { type: 'array', items: [ { type: 'number' } ] }
  },
  required: [ 'username', 'age', 'followers' ]
}

Primitive codecs

string
Codec<string>
A codec for any string value. Most of the time you will use it to implement an interface codec (see the Codec.interface example above). Encoding a string acts like the identity function.
string.decode('purify-ts')
string.decode(3.14)
Right('purify-ts')
Left('Expected a string, but received a number with value 3.14')
number
Codec<number>
A codec for any number value. This includes anything that has a typeof number - NaN, Infinity etc. Encoding a number acts like the identity function.
number.decode(4.20)
number.decode(null)
Right(4.20)
Left('Expected a number, but received null')
boolean
Codec<boolean>
A codec for a boolean value.
boolean.decode(true)
boolean.decode(0)
Right(true)
Left('Expected a boolean, but received a number with value 0')
nullType
Codec<null>
A codec for null only.
nullType.decode(null)
Right(null)
unknown
Codec<unknown>
A codec that can never fail, but of course you get no type information. Encoding an unknown acts like the identity function.
unknown.decode(0)
unknown.decode({someObject: true})
unknown.decode(false)
Right(0)
Right({someObject: true})
Right(false)
date
Codec<Date>
A codec for a parsable date string, on successful decoding it resolves to a Date object. The validity of the date string during decoding is decided by the browser implementation of Date.parse. Encode runs toISOString on the passed in date object.
date.decode('2019-12-15T20:34:25.052Z')
date.encode(new Date(2019, 2, 13))
Right(new Date('2019-12-15T20:34:25.052Z'))
'2019-03-12T22:00:00.000Z'

Complex codecs

oneOf
<T extends Array<Codec<any>>>(codecs: T): Codec<GetType<T extends Array<infer U> ? U : never>>
A codec combinator that receives a list of codecs and runs them one after another during decode and resolves to whichever returns Right or to Left if all fail. Keep in mind that encoding probably won't work correctly if you use a custom codec and it's not lawful (as in, decode(encode(X)) is not equal to X)
const nullable = <T>(codec: Codec<T>): Codec<T | null> =>
  oneOf([codec, nullType])
oneOf([string, nullType]).decode('Well, hi!')
oneOf([string, nullType]).decode(null)
Codec<T | null>
Right('Well, hi!')
Right(null)
optional
<T>(codec: Codec<T>): Codec<T | undefined>
A codec for a value T or undefined. Mostly used for optional properties inside an object, hence the name.
optional(number).decode(undefined)
Codec.interface({ a: optional(number) }).decode({})
Right(undefined)
Right({})
nullable
<T>(codec: Codec<T>): Codec<T | null>
A codec for a value T or null. Keep in mind if you use `nullable` inside `Codec.interface` the property will still be required.
nullable(number).decode(null)
Right(null)
array
<T>(codec: Codec<T>): Codec<Array<T>>
A codec for an array.
array(number).decode([3.14, 2, 3])
array(oneOf([string, number])).decode(['x', 0, 'y', 1])
Right([3.14, 2, 3])
Right(['x', 0, 'y', 1])
record
<K extends keyof any, V>(keyCodec: Codec<K>, valueCodec: Codec<V>): Codec<Record<K, V>>
A codec for an object without specific properties, its restrictions are equivalent to the Record<K, V> type so you can only check for number and string keys.
record(string, boolean).decode({valid: true})
record(number, string).decode({0: 'user1', 1: 'user2'})
record(number, string).decode({valid: 'no'})
Right({valid: true}) // but the type is Either<string, Record<string, boolean>>
Right({0: 'user1', 1: 'user2'})
Left('Problem with key type of property "valid": Expected a number, but received a string with value "valid"')
tuple
<TS extends [Codec<any>, ...Codec<any>[]]>(codecs: TS): Codec<{[i in keyof TS]: TS[i] extends Codec<infer U> ? U : never}>
The same as the array decoder, but accepts a fixed amount of array elements and you can specify each element type, much like the tuple type.
tuple([number]).decode([0, 1])
tuple([number]).decode([''])
Left('Expected an array of length 1, but received an array with length of 2')
Left('Problem with value at index 0: Expected a number, but received a string with value ""')
map
<K, V>(keyCodec: Codec<K>, valueCodec: Codec<V>): Codec<Map<K, V>>
A codec for the built-in Map type.
map(string, number).decode([['a', 0], ['b', 1]])
map(string, number).encode(new Map(Object.entries({a: 0, b: 1}))
Right(Map(2) {"a" => 0, "b" => 1})
[['a', 0], ['b', 1]]
exactly
<T extends (string | number | boolean)[]>(...expectedValues: T): Codec<T[number]>
A codec that only succeeds decoding when the value is exactly what you've constructed the codec with.
exactly('').decode('non-empty string')
exactly('None', 'Read', 'Write')
Left('Expected "", but received a string with value "non-empty string"')
Codec<"None" | "Read" | "Write">
enumeration
<T extends Record<string, string | number>>(e: T): Codec<T[keyof T]>
A codec for a TypeScript enum.
enum Mode { Read, Write, ReadWrite }
                  
enumeration(Mode).decode(0)
Right(Mode.Read)
lazy
<T>(getCodec: () => Codec<T>): Codec<T>
A special codec used when dealing with recursive data structures, it allows a codec to be recursively defined by itself.
interface Comment {
  content: string,
  responses: Comment[]
}

const Comment: Codec<Comment> = Codec.interface({
  content: string,
  responses: lazy(() => array(Comment))
})
Codec<Comment>
intersect
<T, U>(t: Codec<T>, u: Codec<U>): Codec<T & U>
Creates an intersection between two codecs. If the provided codecs are not for an object, the second decode result will be returned.
intersect(
  Codec.interface({a: number}),
  Codec.interface({b: string})
).decode({a: 5, b: ''})
Right({a: 5, b: ''})

Purify-specific codecs

maybe
<T>(codec: Codec<T>): Codec<Maybe<T>>
A codec for purify's Maybe type. Encode runs Maybe#toJSON, which effectively returns the value inside if it's a Just or undefined if it's Nothing.
maybe(number).decode(undefined)
maybe(number).decode(null)
maybe(number).decode(123)
maybe(number).encode(Just(0))
Right(Nothing) // Also works with missing properties inside an object
Right(Nothing)
Right(Just(123))
0
nonEmptyList
<T>(codec: Codec<T>): Codec<NonEmptyList<T>>
A codec for purify's NEL type.
nonEmptyList(number).decode([])
nonEmptyList(number).decode([0])
Left('Expected an array with one or more elements, but received an empty array')
Right(NonEmptyList([0]))

Utils

parseError
(error: string): DecodeError
Turns a string error message produced by a built-in purify codec into a meta object.
parseError(`
  Problem with key type of property "a":
  Expected a number, but received a string with value "a"
`)
{
  type: 'property',
  property: 'a',
  error: {
    type: 'failure',
    expectedType: 'number',
    receivedType: 'string',
    receivedValue: 'a'
  }
}
DecodeError
DecodeError = { type: 'property'; property: string; error: DecodeError } | { type: 'index'; index: number; error: DecodeError } | { type: 'oneOf'; errors: DecodeError[] } | { type: 'failure' expectedType?: ExpectedType receivedType: ReceivedType receivedValue?: unknown } | { type: 'custom'; message: string }
An ADT representing all possible decode errors