io-ts-extra
Version:
Adds pattern matching, optional properties, and several other helpers and types, to io-ts.
178 lines (166 loc) • 6.54 kB
text/typescript
import {Type, union, Props, nullType, undefined as undefinedType, intersection, partial, type, mixed} from 'io-ts'
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports
import * as t from 'io-ts'
import {pipe} from 'fp-ts/lib/function'
import * as E from 'fp-ts/lib/Either'
export interface Optional {
optional: true
}
/**
* unions the passed-in type with `null` and `undefined`.
* @see sparseType
*/
export const optional = <RT extends t.Mixed>(rt: RT, name?: string) => {
const unionType = union([rt, nullType, undefinedType], name || rt.name + '?')
return Object.assign(unionType, {optional: true} as Optional)
}
type OptionalPropsTypes<P extends Props> = {[K in keyof P]?: P[K]['_A']}
type OptionalPropsOutputs<P extends Props> = {[K in keyof P]?: P[K]['_O']}
type RequiredPropsKeys<P extends Props> = {[K in keyof P]: P[K] extends Optional ? never : K}[keyof P]
type RequiredPropsTypes<P extends Props> = {[K in RequiredPropsKeys<P>]: P[K]['_A']}
type RequiredPropsOutputs<P extends Props> = {[K in RequiredPropsKeys<P>]: P[K]['_O']}
/**
* Can be used much like `t.type` from io-ts, but any property types wrapped with `optional` from
* this package need not be supplied. Roughly equivalent to using `t.intersection` with `t.type` and `t.partial`.
* @example
* const Person = sparseType({
* name: t.string,
* age: optional(t.number),
* })
*
* // no error - `age` is optional
* const bob: typeof Person._A = { name: 'bob' }
* @param props equivalent to the `props` passed into `t.type`
* @returns a type with `props` field, so the result can be introspected similarly to a type built with
* `t.type` or `t.partial` - which isn't the case if you manually use `t.intersection([t.type({...}), t.partial({...})])`
*/
export const sparseType = <P extends Props>(
props: P,
name?: string
): Type<OptionalPropsTypes<P> & RequiredPropsTypes<P>, OptionalPropsOutputs<P> & RequiredPropsOutputs<P>, mixed> & {
props: P
} => {
let someOptional = false
let someRequired = false
const optionalProps: Props = {}
const requiredProps: Props = {}
for (const key of Object.keys(props)) {
const val: any = props[key]
if (val.optional) {
someOptional = true
optionalProps[key] = val
} else {
someRequired = true
requiredProps[key] = val
}
}
const computedName = name || getInterfaceTypeName(props)
if (someOptional && someRequired) {
return Object.assign(intersection([type(requiredProps), partial(optionalProps)], computedName) as any, {props})
} else if (someOptional) {
return partial(props, computedName) as any
}
return type(props, computedName) as any
}
const getNameFromProps = (props: Props): string =>
Object.keys(props)
.map(k => `${k}: ${props[k].name}`)
.join(', ')
const getInterfaceTypeName = (props: Props): string => {
return `{ ${getNameFromProps(props)} }`
}
/**
* Validates that a value is an instance of a class using the `instanceof` operator
* @example
* const DateType = instanceOf(Date)
* DateType.is(new Date()) // right(Date(...))
* DateType.is('abc') // left(...)
*/
export const instanceOf = <T>(cns: new (...args: any[]) => T) =>
new t.Type<T>(
`InstanceOf<${cns.name || 'anonymous'}>`,
(v): v is T => v instanceof cns,
(s, c) => (s instanceof cns ? t.success(s) : t.failure(s, c)),
t.identity
)
/**
* A type which validates its input as a string, then decodes with `String.prototype.match`,
* succeeding with the RegExpMatchArray result if a match is found, and failing if no match is found.
*
* @example
* const AllCaps = regexp(/\b([A-Z]+)\b/)
* AllCaps.decode('HELLO') // right([ 'HELLO', index: 0, input: 'HELLO' ])
* AllCaps.decode('hello') // left(...)
* AllCaps.decode(123) // left(...)
*/
export const regexp = (() => {
const RegExpMatchArrayStructure = t.intersection([
t.array(t.string),
t.type({
index: t.number,
input: t.string,
}),
])
return (v: RegExp) => {
const RegExpMatchArrayDecoder = new t.Type<typeof RegExpMatchArrayStructure._A, string, string>(
`RegExp<${v.source}>`,
RegExpMatchArrayStructure.is,
(s, c) => {
// note: this implementation used to be much simpler:
// return RegExpMatchArrayStructure.validate(s.match(v), c)
// but a change to io-ts means that `t.type` won't validate an array, even if
// the array does have the properties required by the t.type.
const [array, structure] = RegExpMatchArrayStructure.types
const match = s.match(v)
return pipe(
match,
E.fromNullable(t.failure(s, c, `No match found for regexp ${v}`)),
E.mapLeft(e => (e as typeof e & {_tag: 'Left'}).left),
E.chain(match => array.validate(match, c)),
E.map((match: RegExpMatchArray) => ({index: match.index, input: match.input})),
E.chain(struct => structure.validate(struct, c)),
E.chain(() => t.success(match as t.TypeOf<typeof RegExpMatchArrayStructure>))
)
},
val => val.input
)
return t.string.pipe(RegExpMatchArrayDecoder)
}
})()
export type RegExpCodec = ReturnType<typeof regexp>
/**
* Like `t.type`, but fails when any properties not specified in `props` are defined.
*
* @example
* const Person = strict({name: t.string, age: t.number})
*
* expectRight(Person.decode({name: 'Alice', age: 30}))
* expectLeft(Person.decode({name: 'Bob', age: 30, unexpectedProp: 'abc'}))
* expectRight(Person.decode({name: 'Bob', age: 30, unexpectedProp: undefined}))
*
* @param props dictionary of properties, same as the input to `t.type`
* @param name optional type name
*
* @description
* note:
* - additional properties explicitly set to `undefined` _are_ permitted.
* - internally, `sparseType` is used, so optional properties are supported.
*/
export const strict = <P extends Props>(props: P, name?: string) => {
const codec = sparseType(props)
return new t.Type<typeof codec._A, typeof codec._O>(
name || `Strict<${codec.name}`,
(val): val is typeof codec._A => codec.is(val) && Object.keys(val).every(k => k in props),
(val, ctx) => {
if (typeof val !== 'object' || !val) {
return codec.validate(val, ctx)
}
const stricterProps = Object.keys(val).reduce<Props>(
(acc, next) => ({...acc, [next]: props[next] || t.undefined}),
props
)
return sparseType(stricterProps as typeof props).validate(val, ctx)
},
codec.encode
)
}