Created
January 14, 2025 14:50
-
-
Save briancavalier/b4ba91e33a9454a30fa49fb6f269b773 to your computer and use it in GitHub Desktop.
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
import { Fail, Ok, Parsed, Schema, _decode, ok } from './schema' | |
export const parse = <S extends Schema>(s: S, x: string): Ok<Parsed<S>> | Fail => | |
_decode(s, JSON.parse(x)) | |
export const stringify = <S>(s: S, x: Parsed<S>): Ok<string> | Fail => { | |
const r = _decode(s as any, x) | |
return r.ok ? ok(JSON.stringify(r.value)) : r | |
} |
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
export const schema = Symbol.for('@braindump/codec/schema') | |
export interface AnyNumber { readonly [schema]: 'number' } | |
export const number: AnyNumber = { [schema]: 'number' } | |
export interface AnyString { readonly [schema]: 'string' } | |
export const string: AnyString = { [schema]: 'string' } | |
export interface AnyBoolean { readonly [schema]: 'boolean' } | |
export const boolean: AnyBoolean = { [schema]: 'boolean' } | |
export interface Union<Schemas extends readonly unknown[]> { | |
readonly [schema]: 'union', | |
readonly schemas: Schemas | |
} | |
export const union = <const S extends readonly Schema[]>(...schemas: S): Union<S> => ({ [schema]: 'union', schemas }) | |
export interface ArrayOf<Schema> { | |
readonly [schema]: 'array', | |
readonly items: Schema | |
} | |
export const array = <S extends Schema>(s: S): ArrayOf<S> => ({ [schema]: 'array', items: s }) | |
export interface Optional<S> { | |
readonly [schema]: 'optional', | |
readonly schema: S | |
} | |
export const optional = <S extends Schema>(s: S): Optional<S> => ({ [schema]: 'optional', schema: s }) | |
export const isOptional = (s: unknown): s is Optional<unknown> => !!s && (s as Record<PropertyKey, unknown>)[schema] === 'optional' | |
export type Schema = | |
| string | |
| number | |
| boolean | |
| null | |
| AnyString | |
| AnyNumber | |
| AnyBoolean | |
| ((x: any) => x is unknown) | |
| Union<readonly Schema[]> | |
| ArrayOf<Schema> | |
| { readonly [s: string]: Schema | Optional<Schema> } | |
export type Parsed<S> = | |
S extends string | number | boolean | null ? S | |
: S extends AnyString ? string | |
: S extends AnyNumber ? number | |
: S extends AnyBoolean ? boolean | |
: S extends (x: any) => x is infer A ? A | |
: S extends Union<infer Schemas> ? Parsed<Schemas[number]> | |
: S extends ArrayOf<infer Schema> ? readonly Parsed<Schema>[] | |
: S extends { readonly [k: string]: Schema | Optional<Schema> } | |
? { readonly [K in RequiredKeys<S>]: Parsed<S[K]> } & | |
{ readonly [K in OptionalKeys<S>]?: S[K] extends Optional<infer SS> ? Parsed<SS> : never } | |
: unknown | |
type OptionalKeys<S> = { | |
readonly [K in keyof S]: S[K] extends Optional<unknown> ? K : never | |
}[keyof S] | |
type RequiredKeys<S> = Exclude<keyof S, OptionalKeys<S>> | |
export interface Ok<A> { readonly ok: true, readonly value: A } | |
export const ok = <A>(a: A) => ({ ok: true, value: a }) as const | |
export const unexpected = <S, I>(schema: S, input: I) => ({ ok: false, type: 'unexpected', schema, input }) as const | |
export const missing = <K, S>(key: K, schema: S) => ({ ok: false, type: 'missing', key, schema }) as const | |
export const at = <K, E>(key: K, error: E) => ({ ok: false, type: 'at', key, error }) as const | |
export const stopped = <I, E>(input: I, error: E) => ({ ok: false, type: 'stopped', input, error }) as const | |
export const none = <S, I, E extends readonly unknown[]>(schema: S, input: I, errors: E) => ({ ok: false, type: 'none', schema, input, errors }) as const | |
export const all = <I, E extends readonly unknown[]>(input: I, errors: E) => ({ ok: false, type: 'all', input, errors }) as const | |
export type Fail = | |
| ReturnType<typeof unexpected> | |
| ReturnType<typeof missing> | |
| ReturnType<typeof at> | |
| ReturnType<typeof stopped> | |
| ReturnType<typeof none> | |
| ReturnType<typeof all> | |
export const assertOk = <A>(r: Ok<A> | Fail): A => { | |
if (r.ok) return r.value | |
throw new AssertResultError(r) | |
} | |
class AssertResultError extends Error { | |
constructor(public readonly fail: Fail) { | |
super() | |
} | |
get message(): string { | |
return JSON.stringify(this.fail, null, 2) | |
} | |
} | |
export const assert = <S extends Schema>(s: S, x: unknown): Parsed<S> => | |
assertOk(_decode(s, x)) | |
export const decode = <S extends Schema>(s: S, x: unknown): Ok<Parsed<S>> | Fail => | |
_decode(s, x) | |
export const _decode = <S extends Schema>(s: S, x: unknown): Ok<any> | Fail => { | |
if (s === null || typeof s === 'number' || typeof s === 'string' || typeof s === 'boolean' || typeof s === 'bigint') | |
return s === x ? ok(x) : unexpected(s, x) | |
if (typeof s === 'function') | |
return s(x) ? ok(x) : unexpected(s, x) | |
if (schema in s) | |
switch (s[schema]) { | |
case 'number': | |
case 'string': | |
case 'boolean': | |
return s[schema] === typeof x ? ok(x) : unexpected(s, x) | |
case 'union': | |
return decodeUnion(s, x) | |
case 'array': | |
return Array.isArray(x) ? decodeArray(s, x) : unexpected(s, x) | |
} | |
if (typeof x !== 'object') | |
return x && typeof x === 'object' | |
? decodeProperties(s, x as Record<string, unknown>) | |
: unexpected(s, x) | |
return unexpected(s, x) | |
} | |
const decodeArray = (s: ArrayOf<unknown>, x: readonly unknown[]) => { | |
const r = decodeArrayItems(s.items as Schema, x) | |
return r.ok ? r : stopped(x, r) | |
} | |
const decodeArrayItems = (items: Schema, x: readonly unknown[]) => { | |
const a = [] | |
for (let i = 0; i < x.length; i++) { | |
const r = _decode(items, x[i]) | |
if (!r.ok) return at(i, r) | |
else a[i] = r.value | |
} | |
return ok(a) | |
} | |
const decodeProperties = (s: Record<string, Schema | Optional<Schema>>, x: Record<string, unknown>) => { | |
const a = {} as Record<string, unknown> | |
const e = [] | |
for (const k of Object.keys(s)) { | |
const sk = s[k] | |
if (k in x) { | |
const r = _decode(isOptional(sk) ? sk.schema as Schema : sk, x[k]) | |
if (!r.ok) e.push(at(k, r)) | |
else a[k] = r.value | |
} else { | |
if (!isOptional(s[k])) e.push(missing(k, sk)) | |
} | |
} | |
return e.length === 0 ? ok(a) : all(x, e) | |
} | |
const decodeUnion = (s: Union<readonly unknown[]>, x: unknown) => { | |
const e = [] | |
for (let i = 0; i < s.schemas.length; i++) { | |
const r = _decode(s.schemas[i] as Schema, x) | |
if (r.ok) return r | |
e.push(r) | |
} | |
return none(s, x, e) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment