import * as t from 'io-ts'; import { Refinement } from 'fp-ts/lib/function'; import { Compute, objectKeys, hasAtLeastTwo, ValueOf, hasProperties } from './type-helpers'; /** * Why does this wrapper exist? * * `io-ts` is awesome and super powerful, but the API leaves some things to be * desired. It was designed to be used with `fp-ts` which is a library for * functional programming patterns in TypeScipt. The resulting type on the * "decode" method is an Either which is great to work with when using `fp-ts`, * but is otherwise cumbersome. * * By introducing an `assert` method, we can guarantee the function will always * return the decoded type by throwing an error if it fails to decode. With * promises we can easily chain an assertion and the promise will reject if * the data fails to decode. * * The other main thing this wrapper seeks to address is the clunkiness of * defining objects. * * If you want to declare an object in io-ts, it looks like this: * * const person = t.type({ firstName: t.string, lastName: t.string }) * * 'type' here is not obvious that it is representing an object, but that's * not the only issue. If you want to make one of the keys optional, you must * manually union the type with t.undefined. * * const person = t.type({ * firstName: t.string, * lastName: t.union([t.string, t.undefined]) * }) * * This isn't so bad that it warrants a wrapper, but it could be improved * upon. The real pain is that when deriving types from this, the 'optional' * key isn't actually optional. * * // { firstName: string, lastName: string | undefined } * type Person = t.TypeOf * * Note that lastName does *NOT* have a question mark after it marking it as * optional. If you wanted to now declare a variable with this type, you MUST * provide the lastName key, even if the value is undefined. * * const p: Person = { firstName: "Kevin", lastName: undefined } * * This is where things get really clunky. The solution according to the docs * is to separate your optional keys (with t.partial) from your required keys * and then intersect the two types together. * * const person = t.intersection([ * t.type({ * firstName: t.string, * }), * t.partial({ * lastName: t.string * }) * ]) * * // Now this is correct * // { firstName: string, lastName?: string | undefined } * type Person = t.TypeOf * * This wrapper provides a much nicer API for this *very* common scenario * addressing the aforementioned quirks of io-ts. * * const person = eg.object({ * firstName: eg.string * lastName: eg.string.optional * }) * * // { firstName: string, lastName?: string | undefined } * type Person = TypeFromCodec * * The `optional` property unions the codec with the `undefined` codec, and the * type information that is derived handles this automatically. */ type KeysWithValueType = { [Key in keyof O]: Key }[Exclude< keyof O, KeysWithoutValueType >]; type KeysWithoutValueType = { [Key in keyof O]: T extends O[Key] ? never : Key; }[keyof O]; type RepackKeys = { [Key in keyof T]: T[Key] } & {}; type EnableOptionalKeys = RepackKeys< { [MandatoryKey in KeysWithoutValueType]: T[MandatoryKey] } & { [OptionalKey in KeysWithValueType]?: T[OptionalKey]; } >; export class EnGardeAssertionError extends Error { constructor(public errors: t.Errors) { super(); Object.setPrototypeOf(this, EnGardeAssertionError.prototype); } } type IoTsCodec = t.Any; export class Codec extends t.Type< t.TypeOf, t.OutputOf, t.InputOf > { constructor(ioTsCodec: C) { super(ioTsCodec.name, ioTsCodec.is, ioTsCodec.validate, ioTsCodec.encode); } public assertDecode = (value: unknown) => { const result = this.decode(value); if (result._tag === 'Left') throw new EnGardeAssertionError(result.left); return result.right; }; public get optional() { return new Codec(t.union([this, t.undefined])); } } export class ObjectCodec

extends t.InterfaceType< P, EnableOptionalKeys<{ [K in keyof P]: t.TypeOf }>, EnableOptionalKeys<{ [K in keyof P]: t.OutputOf }>, unknown > { constructor(ioTsCodec: t.TypeC

) { super( ioTsCodec.name, // these MUST be cast as any because the types don't line up and that's // what we want since we're enabling optional keys (value: unknown): value is any => typeof value === 'object' && value !== null && Object.keys(ioTsCodec.props).every(key => ioTsCodec.props[key].is( hasProperties(value, key) ? value[key] : undefined ) ), ioTsCodec.validate as any, ioTsCodec.encode as any, ioTsCodec.props ); } public assertDecode = (value: unknown) => { const result = this.decode(value); if (result._tag === 'Left') throw new EnGardeAssertionError(result.left); return result.right; }; public create = (value: TypeFromCodec) => this.assertDecode(value); public get optional() { return new Codec(t.union([this, t.undefined])); } } const primitiveCodecs = { string: new Codec(t.string), number: new Codec(t.number), boolean: new Codec(t.boolean), null: new Codec(t.null), undefined: new Codec(t.undefined), any: new Codec(t.any), unknown: new Codec(t.unknown) }; const wrapHigherOrderCodec = ( higherOrderCodec: (...hocParams: Params) => Codec ) => (...hocParams: Params) => new Codec(higherOrderCodec(...hocParams)); const higherOrderCodecs = { // Using a special type for object which enables optional keys object:

(p: P, name?: string) => new ObjectCodec(t.type(p, name)), // Unfortunately tuple type inference does not work with our HOC wrapper. // so we need to explicitly define this here. tuple: ( codecs: Codecs, name?: string ): Codec> => new Codec(t.tuple(codecs as any, name) as any), intersection: ( codecs: Codecs, name?: string ): Codec> => new Codec(t.intersection(codecs as any, name) as any), recursion: ( name: string, definition: (self: t.Type) => t.Type ): Codec, A, O, I>> => { return new Codec(t.recursion(name, definition)); }, brand: >( name: Name, base: BaseCodec, predicate: (value: TypeFromCodec) => boolean ) => new Codec( t.brand( base, predicate as Refinement< BaseCodec['_A'], t.Branded >, name ) ), enum: ( e: Enum ): Codec>> => { const keyLiterals = objectKeys(e).map(k => t.literal(e[k] as any)); if (hasAtLeastTwo(keyLiterals)) { return new Codec(t.union(keyLiterals)) as any; } if (keyLiterals.length === 1) { return new Codec(keyLiterals[0]); } return new Codec(t.undefined as any) as any; }, instanceof: any>( c: Constructor ) => { return new Codec( new t.Type, InstanceType, unknown>( `instanceof ${c.name}`, (value: unknown): value is InstanceType => value instanceof c, (value: unknown, context: t.Context) => value instanceof c ? t.success(value) : t.failure(value, context), a => a ) ); }, // decorates an InterfaceType like eg.exact, // but fails validation if keySet of target is not equivalent to codec's exactStrict:

>( wrappedInterfaceType: IT ) => { const wrappedKeySet = new Set(Object.keys(wrappedInterfaceType.props)); // ensure every key in u exists in the codec const guard = (u: unknown): u is TypeFromCodec => wrappedInterfaceType.is(u) && Object.keys(u).every(key => wrappedKeySet.has(key)); const extraKeys = (obj: t.TypeOf) => Object.keys(obj).filter(key => !wrappedKeySet.has(key)); const extraKeyContextEntries = ( keys: string[], obj: t.TypeOf ): t.ContextEntry[] => keys.map(key => ({ key, actual: obj[key], type: t.undefined })); return new ObjectCodec( new t.InterfaceType< IT['props'], t.TypeOf, t.OutputOf, t.InputOf >( `exactStrict(${wrappedInterfaceType.name})`, guard, (unknownValue, context) => { const decoded = wrappedInterfaceType.validate(unknownValue, context); if (decoded._tag === 'Right') { const ks = extraKeys(decoded.right); return ks.length === 0 ? t.success(decoded.right) : t.failure( unknownValue, [...context, ...extraKeyContextEntries(ks, decoded.right)], 'found extra keys not present in codec' ); } else { // wrapped codec's validation failed // include wrapped codec failures, as well as any extra keys return t.failure(unknownValue, [ ...context, ...decoded.left.reduce( // poor man's flat map (acc, err) => [...acc, ...err.context], [] ), ...(eg.record(eg.string, eg.any).is(unknownValue) ? extraKeyContextEntries(extraKeys(unknownValue), unknownValue) : []) ]); } }, wrappedInterfaceType.encode, wrappedInterfaceType.props ) ); }, // The types for these higher order codecs work with simple hoc wrapper array: wrapHigherOrderCodec(t.array), union: wrapHigherOrderCodec(t.union), record: wrapHigherOrderCodec(t.record), partial: wrapHigherOrderCodec(t.partial), literal: wrapHigherOrderCodec(t.literal), exact: wrapHigherOrderCodec(t.exact) }; const isEmptyArray = (value: unknown): value is [] => Array.isArray(value) && value.length === 0; const helpers = { /** * Picks a subset of ObjectCodec and returns new ObjectCodec. The subset is determined by keys array. * * @param codec Codec to pick from * @param keys Keys to pick * @returns Subset codec that only has 'keys'. */ pick: , Keys extends keyof C['props']>( codec: C, keys: Keys[] ) => { const props = codec.props; return eg.object( keys.reduce((acc, key) => { acc[key] = props[key]; return acc; }, {} as { [key in Keys]: C['props'][key] }) ); }, /** * Omits a subset of ObjectCodec and returns new ObjectCodec. The subset is determined by keys array. * * @param codec Codec to omit from * @param keys Keys to omit * @returns Subset codec without 'keys'. */ omit: , Keys extends keyof C['props']>( codec: C, keys: Keys[] ) => { const props = { ...codec.props }; return eg.object( keys.reduce((acc, key) => { delete acc[key]; return acc; }, props) as Compute> ); }, /** * Creates union-of-literals LiteralType codec from ObjectCodec. * * @param Object codec * @returns LiteralType of keys, where keys are keyof codec. */ keyOf: >( codec: C ): Codec>> => { const props: C['props'] = codec.props; const keyLiterals = objectKeys(props).map(k => t.literal(k as any)); if (hasAtLeastTwo(keyLiterals)) { return new Codec(t.union(keyLiterals)) as any; } if (keyLiterals.length === 1) { return new Codec(keyLiterals[0]); } return new Codec(t.undefined as any) as any; }, // io-ts doesn't support creating an empty tuple or an array of type never // so we're creating a custom codec to represent this. emptyArray: new Codec( new t.Type<[], []>( '[]', isEmptyArray, (value, context) => isEmptyArray(value) ? t.success(value) : t.failure(value, context), t.identity ) ) }; export type TypeFromCodec = t.TypeOf; export const eg = { ...primitiveCodecs, ...higherOrderCodecs, ...helpers }; // re-export io-ts lib export { t }; // re-export fp-ts export { isLeft, isRight } from 'fp-ts/lib/Either';