Skip to content

Instantly share code, notes, and snippets.

@sonhanguyen
Last active December 24, 2020 07:58
Show Gist options
  • Save sonhanguyen/d82a52811ef1d2d67be025462e347d4a to your computer and use it in GitHub Desktop.
Save sonhanguyen/d82a52811ef1d2d67be025462e347d4a to your computer and use it in GitHub Desktop.
Composable validation
// I could not find any validation library (zod, io-ts etc) that allows custom error types
// so quickly wipped this up, note that the errors here are string literals but they
// might as well be, say, custom classes for pattern matching later
// think about error messages that you need to translate to many languages in the front-end
const formValidator = <S extends Record<string, Validator<any, any>>>(schema: S): Validator<
{ [K in keyof S]: Infer<S[K]>['Input'] },
{ [K in keyof S]:
{ error: Infer<S[K]>['Error']
value: Infer<S[K]>['Input']
key: K
}
}[keyof S][]
> => obj => {
const results = Object
.entries(schema)
.map(([key, validator]) => [ validator(obj[key]), key ])
const hasErrors = results
.filter(([it]) => ![undefined, true].includes(it))
.length
if (hasErrors) return results.reduce(
(errors, [ error, key ]) => [ ...errors, { error, key, value: obj[key] } ],
[]
)
}
type Validator<T, E extends {} = never> = (_: T) => E | undefined | true
type Infer<T extends Validator<any, any>> = {
Error: Exclude<ReturnType<E>, undefined | boolean>
Input: Parameters<T>[0]
Type: Extract<ReturnType<T>, DowncastError<any>> extends DowncastError<infer CastTo>
? UnionToIntersection<CastTo>
: Parameters<T>[0]
}
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type A<T> = T | null
type B = A<true> extends A<boolean> ? 'YES' : 'NO'
// validator to guard
const guard = <T, E>(validate: Validator<T, E>) =>
(it: T): it is Infer<typeof validate>['Type'] => ![undefined, true].includes(validate(it))
class DowncastError<T> {
private readonly tag
constructor(public guard?: (it: any) => it is T) {}
}
// guard to validator
const validator = <A, T = any>(guard: (it: T) => it is A): Validator<T, DowncastError<A>> => val => {
const test = guard(val)
if (test === true) return
// if the guard returns falsy, the guard function itself
// will be used as the error object, for lack of one that can provide context
return guard
}
type ChainableValidator<T, E> = Validator<T, E> & {
then<Err>(
next: Validator<Infer<Validator<T, E>>['Type'], Err>
): ChainableValidator<T, E | Err>
}
const failFast = <T, E>(validate: Validator<T, E>): ChainableValidator<T, E> => {
const validator: T = input => validate(input)
return Object.assign(validator, {
then<V extends Infer<T>['Type'], E>>(next: Validator<V, E>) => failFast(input =>
const result = validate(input)
if ([undefined, true].includes(result)) return next(input)
return result
)
})
}
const hasEmail = (it): it is { email } => it && 'email' in it
const validateHasEmail = validator(hasEmail)
const validateEmail = (it: { email }) => {
if (typeof it.email != 'string') return new DowncastError<{ email: string }>()
if (!it.email) return 'Email is empty' as const
}
const validateAge = (it: { age }) => {
if (typeof it.email != 'number') return new DowncastError<{ age: number }>()
}
const emailValidator = failFast(validateEmail)
.then(validateHasEmail)
.then(validateAge)
const validateForm = formValidator({
email(string) {
if (!string) return 'No email' as const
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment