Last active
December 24, 2020 07:58
-
-
Save sonhanguyen/d82a52811ef1d2d67be025462e347d4a to your computer and use it in GitHub Desktop.
Composable validation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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