UNPKG

@ucanto/core

Version:
1,603 lines (1,477 loc) 37.9 kB
import * as Schema from './type.js' import { ok, Failure } from '../result.js' export * from './type.js' export { ok } /** * @abstract * @template [T=unknown] * @template [I=unknown] * @template [Settings=void] * @extends {Schema.Base<T, I, Settings>} * @implements {Schema.Schema<T, I>} */ export class API { /** * @param {Settings} settings */ constructor(settings) { this.settings = settings } toString() { return `new ${this.constructor.name}()` } /** * @abstract * @param {I} input * @param {Settings} settings * @returns {Schema.ReadResult<T>} */ /* c8 ignore next 3 */ readWith(input, settings) { throw new Error(`Abstract method readWith must be implemented by subclass`) } /** * @param {I} input * @returns {Schema.ReadResult<T>} */ read(input) { return this.readWith(input, this.settings) } /** * @param {unknown} value * @returns {value is T} */ is(value) { return !this.read(/** @type {I} */ (value))?.error } /** * @param {unknown} value * @return {T} */ from(value) { const result = this.read(/** @type {I} */ (value)) if (result.error) { throw result.error } else { return result.ok } } /** * @returns {Schema.Schema<T|undefined, I>} */ optional() { return optional(this) } /** * @returns {Schema.Schema<T|null, I>} */ nullable() { return nullable(this) } /** * @returns {Schema.Schema<T[], I>} */ array() { return array(this) } /** * @template U * @param {Schema.Reader<U, I>} schema * @returns {Schema.Schema<T | U, I>} */ or(schema) { return or(this, schema) } /** * @template U * @param {Schema.Reader<U, I>} schema * @returns {Schema.Schema<T & U, I>} */ and(schema) { return and(this, schema) } /** * @template {T} U * @param {Schema.Reader<U, T>} schema * @returns {Schema.Schema<U, I>} */ refine(schema) { return refine(this, schema) } /** * @template {string} Kind * @param {Kind} [kind] * @returns {Schema.Schema<Schema.Branded<T, Kind>, I>} */ brand(kind) { return /** @type {Schema.Schema<Schema.Branded<T, Kind>, I>} */ (this) } /** * @param {Schema.NotUndefined<T>} value * @returns {Schema.DefaultSchema<Schema.NotUndefined<T>, I>} */ default(value) { // ⚠️ this.from will throw if wrong default is provided const fallback = this.from(value) // we also check that fallback is not undefined because that is the point // of having a fallback if (fallback === undefined) { throw new Error(`Value of type undefined is not a valid default`) } const schema = new Default({ reader: /** @type {Schema.Reader<T, I>} */ (this), value: /** @type {Schema.NotUndefined<T>} */ (fallback), }) return /** @type {Schema.DefaultSchema<Schema.NotUndefined<T>, I>} */ ( schema ) } } /** * @template [I=unknown] * @extends {API<never, I>} * @implements {Schema.Schema<never, I>} */ class Never extends API { toString() { return 'never()' } /** * @param {I} input * @returns {Schema.ReadResult<never>} */ read(input) { return typeError({ expect: 'never', actual: input }) } } /** * @template [I=unknown] * @returns {Schema.Schema<never, I>} */ export const never = () => new Never() /** * @template [I=unknown] * @extends API<unknown, I, void> * @implements {Schema.Schema<unknown, I>} */ class Unknown extends API { /** * @param {I} input */ read(input) { return /** @type {Schema.ReadResult<unknown>}*/ ({ ok: input }) } toString() { return 'unknown()' } } /** * @template [I=unknown] * @returns {Schema.Schema<unknown, I>} */ export const unknown = () => new Unknown() /** * @template O * @template [I=unknown] * @extends {API<null|O, I, Schema.Reader<O, I>>} * @implements {Schema.Schema<null|O, I>} */ class Nullable extends API { /** * @param {I} input * @param {Schema.Reader<O, I>} reader */ readWith(input, reader) { const result = reader.read(input) if (result.error) { return input === null ? { ok: null } : { error: new UnionError({ causes: [ result.error, typeError({ expect: 'null', actual: input }).error, ], }), } } else { return result } } toString() { return `${this.settings}.nullable()` } } /** * @template O * @template [I=unknown] * @param {Schema.Reader<O, I>} schema * @returns {Schema.Schema<O|null, I>} */ export const nullable = schema => new Nullable(schema) /** * @template O * @template [I=unknown] * @extends {API<O|undefined, I, Schema.Reader<O, I>>} * @implements {Schema.Schema<O|undefined, I>} */ class Optional extends API { optional() { return this } /** * @param {I} input * @param {Schema.Reader<O, I>} reader * @returns {Schema.ReadResult<O|undefined>} */ readWith(input, reader) { const result = reader.read(input) return result.error && input === undefined ? { ok: undefined } : result } toString() { return `${this.settings}.optional()` } } /** * @template {unknown} O * @template [I=unknown] * @extends {API<O, I, {reader:Schema.Reader<O, I>, value:O & Schema.NotUndefined<O>}>} * @implements {Schema.DefaultSchema<O, I>} */ class Default extends API { /** * @returns {Schema.DefaultSchema<O & Schema.NotUndefined<O>, I>} */ optional() { // Short circuit here as we there is no point in wrapping this in optional. return /** @type {Schema.DefaultSchema<O & Schema.NotUndefined<O>, I>} */ ( this ) } /** * @param {I} input * @param {object} options * @param {Schema.Reader<O|undefined, I>} options.reader * @param {O} options.value * @returns {Schema.ReadResult<O>} */ readWith(input, { reader, value }) { if (input === undefined) { return /** @type {Schema.ReadResult<O>} */ ({ ok: value }) } else { const result = reader.read(input) return result.error ? result : result.ok !== undefined ? // We just checked that result.ok is not undefined but still needs // reassurance /** @type {Schema.ReadResult<O>} */ (result) : { ok: value } } } toString() { return `${this.settings.reader}.default(${JSON.stringify( this.settings.value )})` } get value() { return this.settings.value } } /** * @template O * @template [I=unknown] * @param {Schema.Reader<O, I>} schema * @returns {Schema.Schema<O|undefined, I>} */ export const optional = schema => new Optional(schema) /** * @template O * @template [I=unknown] * @extends {API<O[], I, Schema.Reader<O, I>>} * @implements {Schema.ArraySchema<O, I>} */ class ArrayOf extends API { /** * @param {I} input * @param {Schema.Reader<O, I>} schema */ readWith(input, schema) { if (!Array.isArray(input)) { return typeError({ expect: 'array', actual: input }) } /** @type {O[]} */ const results = [] for (const [index, value] of input.entries()) { const result = schema.read(value) if (result.error) { return memberError({ at: index, cause: result.error }) } else { results.push(result.ok) } } return { ok: results } } get element() { return this.settings } toString() { return `array(${this.element})` } } /** * @template O * @template [I=unknown] * @param {Schema.Reader<O, I>} schema * @returns {Schema.ArraySchema<O, I>} */ export const array = schema => new ArrayOf(schema) /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @extends {API<Schema.InferTuple<U>, I, U>} * @implements {Schema.Schema<Schema.InferTuple<U>, I>} */ class Tuple extends API { /** * @param {I} input * @param {U} shape * @returns {Schema.ReadResult<Schema.InferTuple<U>>} */ readWith(input, shape) { if (!Array.isArray(input)) { return typeError({ expect: 'array', actual: input }) } if (input.length !== this.shape.length) { return error(`Array must contain exactly ${this.shape.length} elements`) } const results = [] for (const [index, reader] of shape.entries()) { const result = reader.read(input[index]) if (result.error) { return memberError({ at: index, cause: result.error }) } else { results[index] = result.ok } } return { ok: /** @type {Schema.InferTuple<U>} */ (results) } } /** @type {U} */ get shape() { return this.settings } toString() { return `tuple([${this.shape.map(reader => reader.toString()).join(', ')}])` } } /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} shape * @returns {Schema.Schema<Schema.InferTuple<U>, I>} */ export const tuple = shape => new Tuple(shape) /** * @template V * @template {string} K * @template [I=unknown] * @extends {API<Schema.Dictionary<K, V>, I, { key: Schema.Reader<K, string>, value: Schema.Reader<V, I> }>} * @implements {Schema.DictionarySchema<V, K, I>} */ class Dictionary extends API { /** * @param {I} input * @param {object} schema * @param {Schema.Reader<K, string>} schema.key * @param {Schema.Reader<V, I>} schema.value */ readWith(input, { key, value }) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'dictionary', actual: input, }) } const dict = /** @type {Schema.Dictionary<K, V>} */ ({}) for (const [k, v] of Object.entries(input)) { const keyResult = key.read(k) if (keyResult.error) { return memberError({ at: k, cause: keyResult.error }) } const valueResult = value.read(v) if (valueResult.error) { return memberError({ at: k, cause: valueResult.error }) } // skip undefined because they mess up CBOR and are generally useless. if (valueResult.ok !== undefined) { dict[keyResult.ok] = valueResult.ok } } return { ok: dict } } get key() { return this.settings.key } get value() { return this.settings.value } partial() { const { key, value } = this.settings return new Dictionary({ key, value: optional(value), }) } toString() { return `dictionary(${this.settings})` } } /** * @template {string} K * @template {unknown} V * @template [I=unknown] * @param {object} shape * @param {Schema.Reader<V, I>} shape.value * @param {Schema.Reader<K, string>} [shape.key] * @returns {Schema.DictionarySchema<V, K, I>} */ export const dictionary = ({ value, key }) => new Dictionary({ value, key: key || /** @type {Schema.Reader<K, string>} */ (string()), }) /** * @template {[unknown, ...unknown[]]} T * @template [I=unknown] * @extends {API<T[number], I, {type: string, variants:Set<T[number]>}>} * @implements {Schema.Schema<T[number], I>} */ class Enum extends API { /** * @param {I} input * @param {{type:string, variants:Set<T[number]>}} settings * @returns {Schema.ReadResult<T[number]>} */ readWith(input, { variants, type }) { if (variants.has(input)) { return /** @type {Schema.ReadResult<T[number]>} */ ({ ok: input }) } else { return typeError({ expect: type, actual: input }) } } toString() { return this.settings.type } } /** * @template {string} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants * @returns {Schema.Schema<U[number], I>} */ const createEnum = variants => new Enum({ type: variants.join('|'), variants: new Set(variants), }) export { createEnum as enum } /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @extends {API<Schema.InferUnion<U>, I, U>} * @implements {Schema.Schema<Schema.InferUnion<U>, I>} */ class Union extends API { /** * @param {I} input * @param {U} variants */ readWith(input, variants) { const causes = [] for (const reader of variants) { const result = reader.read(input) if (result.error) { causes.push(result.error) } else { return /** @type {Schema.ReadResult<Schema.InferUnion<U>>} */ (result) } } return { error: new UnionError({ causes }) } } get variants() { return this.settings } toString() { return `union([${this.variants.map(type => type.toString()).join(', ')}])` } } /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants * @returns {Schema.Schema<Schema.InferUnion<U>, I>} */ export const union = variants => new Union(variants) /** * @template T, U * @template [I=unknown] * @param {Schema.Reader<T, I>} left * @param {Schema.Reader<U, I>} right * @returns {Schema.Schema<T|U, I>} */ export const or = (left, right) => union([left, right]) /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @extends {API<Schema.InferIntersection<U>, I, U>} * @implements {Schema.Schema<Schema.InferIntersection<U>, I>} */ class Intersection extends API { /** * @param {I} input * @param {U} schemas * @returns {Schema.ReadResult<Schema.InferIntersection<U>>} */ readWith(input, schemas) { const causes = [] for (const schema of schemas) { const result = schema.read(input) if (result.error) { causes.push(result.error) } } return causes.length > 0 ? { error: new IntersectionError({ causes }) } : /** @type {Schema.ReadResult<Schema.InferIntersection<U>>} */ ({ ok: input, }) } toString() { return `intersection([${this.settings .map(type => type.toString()) .join(',')}])` } } /** * @template {Schema.Reader<unknown, I>} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants * @returns {Schema.Schema<Schema.InferIntersection<U>, I>} */ export const intersection = variants => new Intersection(variants) /** * @template T, U * @template [I=unknown] * @param {Schema.Reader<T, I>} left * @param {Schema.Reader<U, I>} right * @returns {Schema.Schema<T & U, I>} */ export const and = (left, right) => intersection([left, right]) /** * @template [I=unknown] * @extends {API<boolean, I>} */ class Boolean extends API { /** * @param {I} input */ readWith(input) { switch (input) { case true: case false: return { ok: /** @type {boolean} */ (input) } default: return typeError({ expect: 'boolean', actual: input, }) } } toString() { return `boolean()` } } /** @type {Schema.Schema<boolean, unknown>} */ const anyBoolean = new Boolean() export const boolean = () => anyBoolean /** * @template {number} [O=number] * @template [I=unknown] * @template [Settings=void] * @extends {API<O, I, Settings>} * @implements {Schema.NumberSchema<O, I>} */ class UnknownNumber extends API { /** * @param {number} n */ greaterThan(n) { return this.refine(greaterThan(n)) } /** * @param {number} n */ lessThan(n) { return this.refine(lessThan(n)) } /** * @template {O} U * @param {Schema.Reader<U, O>} schema * @returns {Schema.NumberSchema<U, I>} */ refine(schema) { return new RefinedNumber({ base: this, schema }) } } /** * @template [I=unknown] * @extends {UnknownNumber<number, I>} * @implements {Schema.NumberSchema<number, I>} */ class AnyNumber extends UnknownNumber { /** * @param {I} input * @returns {Schema.ReadResult<number>} */ readWith(input) { return typeof input === 'number' ? { ok: input } : typeError({ expect: 'number', actual: input }) } toString() { return `number()` } } /** @type {Schema.NumberSchema<number, unknown>} */ const anyNumber = new AnyNumber() export const number = () => anyNumber /** * @template {number} [T=number] * @template {T} [O=T] * @template [I=unknown] * @extends {UnknownNumber<O, I, {base:Schema.Reader<T, I>, schema:Schema.Reader<O, T>}>} * @implements {Schema.NumberSchema<O, I>} */ class RefinedNumber extends UnknownNumber { /** * @param {I} input * @param {{base:Schema.Reader<T, I>, schema:Schema.Reader<O, T>}} settings * @returns {Schema.ReadResult<O>} */ readWith(input, { base, schema }) { const result = base.read(input) return result.error ? result : schema.read(result.ok) } toString() { return `${this.settings.base}.refine(${this.settings.schema})` } } /** * @template {number} T * @extends {API<T, T, number>} */ class LessThan extends API { /** * @param {T} input * @param {number} number * @returns {Schema.ReadResult<T>} */ readWith(input, number) { if (input < number) { return { ok: input } } else { return error(`Expected ${input} < ${number}`) } } toString() { return `lessThan(${this.settings})` } } /** * @template {number} T * @param {number} n * @returns {Schema.Schema<T, T>} */ export const lessThan = n => new LessThan(n) /** * @template {number} T * @extends {API<T, T, number>} */ class GreaterThan extends API { /** * @param {T} input * @param {number} number * @returns {Schema.ReadResult<T>} */ readWith(input, number) { if (input > number) { return { ok: input } } else { return error(`Expected ${input} > ${number}`) } } toString() { return `greaterThan(${this.settings})` } } /** * @template {number} T * @param {number} n * @returns {Schema.Schema<T, T>} */ export const greaterThan = n => new GreaterThan(n) const Integer = { /** * @param {number} input * @returns {Schema.ReadResult<Schema.Integer>} */ read(input) { return Number.isInteger(input) ? { ok: /** @type {Schema.Integer} */ (input) } : typeError({ expect: 'integer', actual: input, }) }, toString() { return `Integer` }, } const anyInteger = anyNumber.refine(Integer) export const integer = () => anyInteger const MAX_UINT64 = 2n ** 64n - 1n /** * @template {bigint} [O=Schema.Uint64] * @template [I=unknown] * @extends {API<O, I, void>} * @implements {Schema.Schema<O, I>} */ class Uint64Schema extends API { /** * @param {I} input * @returns {Schema.ReadResult<O>} */ read(input) { switch (typeof input) { case 'bigint': return input > MAX_UINT64 ? error(`Integer is too big for uint64, ${input} > ${MAX_UINT64}`) : input < 0 ? error( `Negative integer can not be represented as uint64, ${input} < ${0}` ) : { ok: /** @type {I & O} */ (input) } case 'number': return !Number.isInteger(input) ? typeError({ expect: 'uint64', actual: input, }) : input < 0 ? error( `Negative integer can not be represented as uint64, ${input} < ${0}` ) : { ok: /** @type {O} */ (BigInt(input)) } default: return typeError({ expect: 'uint64', actual: input, }) } } toString() { return `uint64` } } /** @type {Schema.Schema<Schema.Uint64, unknown>} */ const Uint64 = new Uint64Schema() /** * Creates a schema for {@link Schema.Uint64} values represented as a`bigint`. * * ⚠️ Please note that while IPLD in principal considers the range of integers * to be infinite n practice, many libraries / codecs may choose to implement * things in such a way that numbers may have limited sizes. * * So please use this with caution and always ensure that used codecs do support * uint64. */ export const uint64 = () => Uint64 const Float = { /** * @param {number} number * @returns {Schema.ReadResult<Schema.Float>} */ read(number) { return Number.isFinite(number) ? { ok: /** @type {Schema.Float} */ (number) } : typeError({ expect: 'Float', actual: number, }) }, toString() { return 'Float' }, } const anyFloat = anyNumber.refine(Float) export const float = () => anyFloat /** * @template {string} [O=string] * @template [I=unknown] * @template [Settings=void] * @extends {API<O, I, Settings>} */ class UnknownString extends API { /** * @template {O|unknown} U * @param {Schema.Reader<U, O>} schema * @returns {Schema.StringSchema<O & U, I>} */ refine(schema) { const other = /** @type {Schema.Reader<U, O>} */ (schema) const rest = new RefinedString({ base: this, schema: other, }) return /** @type {Schema.StringSchema<O & U, I>} */ (rest) } /** * @template {string} Prefix * @param {Prefix} prefix */ startsWith(prefix) { return this.refine(startsWith(prefix)) } /** * @template {string} Suffix * @param {Suffix} suffix */ endsWith(suffix) { return this.refine(endsWith(suffix)) } toString() { return `string()` } } /** * @template O * @template {string} [T=string] * @template [I=unknown] * @extends {UnknownString<T & O, I, {base:Schema.Reader<T, I>, schema:Schema.Reader<O, T>}>} * @implements {Schema.StringSchema<O & T, I>} */ class RefinedString extends UnknownString { /** * @param {I} input * @param {{base:Schema.Reader<T, I>, schema:Schema.Reader<O, T>}} settings * @returns {Schema.ReadResult<T & O>} */ readWith(input, { base, schema }) { const result = base.read(input) return result.error ? result : /** @type {Schema.ReadResult<T & O>} */ (schema.read(result.ok)) } toString() { return `${this.settings.base}.refine(${this.settings.schema})` } } /** * @template [I=unknown] * @extends {UnknownString<string, I>} * @implements {Schema.StringSchema<string, I>} */ class AnyString extends UnknownString { /** * @param {I} input * @returns {Schema.ReadResult<string>} */ readWith(input) { return typeof input === 'string' ? { ok: input } : typeError({ expect: 'string', actual: input }) } } /** @type {Schema.StringSchema<string, unknown>} */ const anyString = new AnyString() export const string = () => anyString /** * @template [I=unknown] * @extends {API<Uint8Array, I, void>} */ class BytesSchema extends API { /** * @param {I} input * @returns {Schema.ReadResult<Uint8Array>} */ readWith(input) { if (input instanceof Uint8Array) { return { ok: input } } else { return typeError({ expect: 'Uint8Array', actual: input }) } } } /** @type {Schema.Schema<Uint8Array, unknown>} */ export const Bytes = new BytesSchema() export const bytes = () => Bytes /** * @template {string} Prefix * @template {string} Body * @extends {API<Body & `${Prefix}${Body}`, Body, Prefix>} * @implements {Schema.Schema<Body & `${Prefix}${Body}`, Body>} */ class StartsWith extends API { /** * @param {Body} input * @param {Prefix} prefix */ readWith(input, prefix) { const result = input.startsWith(prefix) ? /** @type {Schema.ReadResult<Body & `${Prefix}${Body}`>} */ ({ ok: input, }) : error(`Expect string to start with "${prefix}" instead got "${input}"`) return result } get prefix() { return this.settings } toString() { return `startsWith("${this.prefix}")` } } /** * @template {string} Prefix * @template {string} Body * @param {Prefix} prefix * @returns {Schema.Schema<`${Prefix}${string}`, string>} */ export const startsWith = prefix => new StartsWith(prefix) /** * @template {string} Suffix * @template {string} Body * @extends {API<Body & `${Body}${Suffix}`, Body, Suffix>} */ class EndsWith extends API { /** * @param {Body} input * @param {Suffix} suffix */ readWith(input, suffix) { return input.endsWith(suffix) ? /** @type {Schema.ReadResult<Body & `${Body}${Suffix}`>} */ ({ ok: input, }) : error(`Expect string to end with "${suffix}" instead got "${input}"`) } get suffix() { return this.settings } toString() { return `endsWith("${this.suffix}")` } } /** * @template {string} Suffix * @param {Suffix} suffix * @returns {Schema.Schema<`${string}${Suffix}`, string>} */ export const endsWith = suffix => new EndsWith(suffix) /** * @template T * @template {T} U * @template [I=unknown] * @extends {API<U, I, { base: Schema.Reader<T, I>, schema: Schema.Reader<U, T> }>} * @implements {Schema.Schema<U, I>} */ class Refine extends API { /** * @param {I} input * @param {{ base: Schema.Reader<T, I>, schema: Schema.Reader<U, T> }} settings */ readWith(input, { base, schema }) { const result = base.read(input) return result.error ? result : schema.read(result.ok) } toString() { return `${this.settings.base}.refine(${this.settings.schema})` } } /** * @template T * @template {T} U * @template [I=unknown] * @param {Schema.Reader<T, I>} base * @param {Schema.Reader<U, T>} schema * @returns {Schema.Schema<U, I>} */ export const refine = (base, schema) => new Refine({ base, schema }) /** * @template {null|boolean|string|number} T * @template [I=unknown] * @extends {API<T, I, T>} * @implements {Schema.LiteralSchema<T, I>} */ class Literal extends API { /** * @param {I} input * @param {T} expect * @returns {Schema.ReadResult<T>} */ readWith(input, expect) { return input !== /** @type {unknown} */ (expect) ? { error: new LiteralError({ expect, actual: input }) } : { ok: expect } } get value() { return /** @type {Exclude<T, undefined>} */ (this.settings) } /** * @template {Schema.NotUndefined<T>} U * @param {U} value */ default(value = /** @type {U} */ (this.value)) { return super.default(value) } toString() { return `literal(${toString(this.value)})` } } /** * @template {null|boolean|string|number} T * @template [I=unknown] * @param {T} value * @returns {Schema.LiteralSchema<T, I>} */ export const literal = value => new Literal(value) /** * @template {{[key:string]: Schema.Reader}} U * @template [I=unknown] * @extends {API<Schema.InferStruct<U>, I, U>} */ class Struct extends API { /** * @param {I} input * @param {U} shape * @returns {Schema.ReadResult<Schema.InferStruct<U>>} */ readWith(input, shape) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'object', actual: input, }) } const source = /** @type {{[K in keyof U]: unknown}} */ (input) const struct = /** @type {{[K in keyof U]: Schema.Infer<U[K]>}} */ ({}) const entries = /** @type {{[K in keyof U]: [K & string, U[K]]}[keyof U][]} */ ( Object.entries(shape) ) for (const [at, reader] of entries) { const result = reader.read(source[at]) if (result.error) { return memberError({ at, cause: result.error }) } // skip undefined because they mess up CBOR and are generally useless. else if (result.ok !== undefined) { struct[at] = /** @type {Schema.Infer<U[typeof at]>} */ (result.ok) } } return { ok: struct } } /** * @returns {Schema.MapRepresentation<Partial<Schema.InferStruct<U>>> & Schema.StructSchema} */ partial() { return new Struct( Object.fromEntries( Object.entries(this.shape).map(([key, value]) => [key, optional(value)]) ) ) } /** @type {U} */ get shape() { // @ts-ignore - We declared `settings` private but we access it here return this.settings } toString() { return [ `struct({ `, ...Object.entries(this.shape) .map(([key, schema]) => `${key}: ${schema}`) .join(', '), ` })`, ].join('') } /** * @param {Schema.InferStructSource<U>} data */ create(data) { return this.from(data || {}) } /** * @template {{[key:string]: Schema.Reader}} E * @param {E} extension * @returns {Schema.StructSchema<U & E, I>} */ extend(extension) { return new Struct({ ...this.shape, ...extension }) } } /** * @template {null|boolean|string|number} T * @template {{[key:string]: T|Schema.Reader}} U * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.LiteralSchema<U[K] & T>}} V * @template [I=unknown] * @param {U} fields * @returns {Schema.StructSchema<V, I>} */ export const struct = fields => { const shape = /** @type {{[K in keyof U]: Schema.Reader<unknown, unknown>}} */ ({}) /** @type {[keyof U & string, T|Schema.Reader][]} */ const entries = Object.entries(fields) for (const [key, field] of entries) { switch (typeof field) { case 'number': case 'string': case 'boolean': shape[key] = literal(field) break case 'object': shape[key] = field === null ? literal(null) : field break default: throw new Error( `Invalid struct field "${key}", expected schema or literal, instead got ${typeof field}` ) } } return new Struct(/** @type {V} */ (shape)) } /** * @template {Schema.VariantChoices} U * @template [I=unknown] * @extends {API<Schema.InferVariant<U>, I, U>} * @implements {Schema.VariantSchema<U, I>} */ class Variant extends API { /** * @param {I} input * @param {U} variants * @returns {Schema.ReadResult<Schema.InferVariant<U>>} */ readWith(input, variants) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'object', actual: input, }) } const keys = /** @type {Array<keyof input & keyof variants & string>} */ ( Object.keys(input) ) const [key] = keys.length === 1 ? keys : [] const reader = key ? variants[key] : undefined if (reader) { const result = reader.read(input[key]) return result.error ? memberError({ at: key, cause: result.error }) : { ok: /** @type {Schema.InferVariant<U>} */ ({ [key]: result.ok }) } } else if (variants._) { const result = variants._.read(input) return result.error ? result : { ok: /** @type {Schema.InferVariant<U>} */ ({ _: result.ok }) } } else if (key) { return error( `Expected an object with one of the these keys: ${Object.keys(variants) .sort() .join(', ')} instead got object with key ${key}` ) } else { return error( 'Expected an object with a single key instead got object with keys ' + keys.sort().join(', ') ) } } /** * @template [E=never] * @param {I} input * @param {E} [fallback] */ match(input, fallback) { const result = this.read(input) if (result.error) { if (fallback !== undefined) { return [null, fallback] } else { throw result.error } } else { const [key] = Object.keys(result.ok) const value = result.ok[key] return /** @type {any} */ ([key, value]) } } /** * @template {Schema.InferVariant<U>} O * @param {O} source * @returns {O} */ create(source) { return /** @type {O} */ (this.from(source)) } } /** * Defines a schema for the `Variant` type. It takes an object where * keys denote branches of the variant and values are schemas for the values of * those branches. The schema will only match objects with a single key and * value that matches the schema for that key. If the object has more than one * key or the key does not match any of the keys in the schema then the schema * will fail. * * The `_` branch is a special case. If such branch is present then it will be * used as a fallback for any object that does not match any of the variant * branches. The `_` branch will be used even if the object has more than one * key. Unlike other branches the `_` branch will receive the entire object as * input and not just the value of the key. Usually the `_` branch can be set * to `Schema.unknown` or `Schema.dictionary` to facilitate exhaustive matching. * * @example * ```ts * const Shape = Variant({ * circle: Schema.struct({ radius: Schema.integer() }), * rectangle: Schema.struct({ width: Schema.integer(), height: Schema.integer() }) * }) * * const demo = (input:unknown) => { * const [kind, value] = Schema.match(input) * switch (kind) { * case "circle": * return `Circle with radius ${shape.radius}` * case "rectangle": * return `Rectangle with width ${shape.width} and height ${shape.height}` * } * } * * const ExhaustiveShape = Variant({ * circle: Schema.struct({ radius: Schema.integer() }), * rectangle: Schema.struct({ width: Schema.integer(), height: Schema.integer() }), * _: Schema.dictionary({ value: Schema.unknown() }) * }) * * const exhastiveDemo = (input:unknown) => { * const [kind, value] = Schema.match(input) * switch (kind) { * case "circle": * return `Circle with radius ${shape.radius}` * case "rectangle": * return `Rectangle with width ${shape.width} and height ${shape.height}` * case: "_": * return `Unknown shape ${JSON.stringify(value)}` * } * } * ``` * * @template {Schema.VariantChoices} Choices * @template [In=unknown] * @param {Choices} variants * @returns {Schema.VariantSchema<Choices, In>} */ export const variant = variants => new Variant(variants) /** * @param {string} message * @returns {{error: Schema.Error, ok?: undefined}} */ export const error = message => ({ error: new SchemaError(message) }) class SchemaError extends Failure { get name() { return 'SchemaError' } /* c8 ignore next 3 */ describe() { return this.name } } class TypeError extends SchemaError { /** * @param {{expect:string, actual:unknown}} data */ constructor({ expect, actual }) { super() this.expect = expect this.actual = actual } get name() { return 'TypeError' } describe() { return `Expected value of type ${this.expect} instead got ${toString( this.actual )}` } } /** * @param {object} data * @param {string} data.expect * @param {unknown} data.actual * @returns {{ error: Schema.Error }} */ export const typeError = data => ({ error: new TypeError(data) }) /** * * @param {unknown} value */ export const toString = value => { const type = typeof value switch (type) { case 'boolean': case 'string': return JSON.stringify(value) // if these types we do not want JSON.stringify as it may mess things up // eg turn NaN and Infinity to null case 'bigint': return `${value}n` case 'number': case 'symbol': case 'undefined': return String(value) case 'object': return value === null ? 'null' : Array.isArray(value) ? 'array' : Symbol.toStringTag in /** @type {object} */ (value) ? value[Symbol.toStringTag] : 'object' default: return type } } class LiteralError extends SchemaError { /** * @param {{ * expect:string|number|boolean|null * actual:unknown * }} data */ constructor({ expect, actual }) { super() this.expect = expect this.actual = actual } get name() { return 'LiteralError' } describe() { return `Expected literal ${toString(this.expect)} instead got ${toString( this.actual )}` } } class ElementError extends SchemaError { /** * @param {{at:number, cause:Schema.Error}} data */ constructor({ at, cause }) { super() this.at = at this.cause = cause } get name() { return 'ElementError' } describe() { return [ `Array contains invalid element at ${this.at}:`, li(this.cause.message), ].join('\n') } } class FieldError extends SchemaError { /** * @param {{at:string, cause:Schema.Error}} data */ constructor({ at, cause }) { super() this.at = at this.cause = cause } get name() { return 'FieldError' } describe() { return [ `Object contains invalid field "${this.at}":`, li(this.cause.message), ].join('\n') } } /** * @param {object} options * @param {string|number} options.at * @param {Schema.Error} options.cause * @returns {{error: Schema.Error}} */ export const memberError = ({ at, cause }) => typeof at === 'string' ? { error: new FieldError({ at, cause }) } : { error: new ElementError({ at, cause }) } class UnionError extends SchemaError { /** * @param {{causes: Schema.Error[]}} data */ constructor({ causes }) { super() this.causes = causes } get name() { return 'UnionError' } describe() { const { causes } = this return [ `Value does not match any type of the union:`, ...causes.map(cause => li(cause.message)), ].join('\n') } } class IntersectionError extends SchemaError { /** * @param {{causes: Schema.Error[]}} data */ constructor({ causes }) { super() this.causes = causes } get name() { return 'IntersectionError' } describe() { const { causes } = this return [ `Value does not match following types of the intersection:`, ...causes.map(cause => li(cause.message)), ].join('\n') } } /** * @param {string} message */ const indent = (message, indent = ' ') => `${indent}${message.split('\n').join(`\n${indent}`)}` /** * @param {string} message */ const li = message => indent(`- ${message}`)