diff --git a/README.md b/README.md index 23fd2e3..828ff09 100644 --- a/README.md +++ b/README.md @@ -308,3 +308,13 @@ Shortcuts: - add more combinators: partial, required, get, ... - separate [`Runtype`](src/runtype.ts#L106) and [`InternalRuntype`](src/runtype.ts#L171) and type runtype internals (see [this comment](https://github.com/hoeck/simple-runtypes/pull/73#discussion_r948841977)) + + +#### current tasks (metadata) notes + +- check that intersection & union tests do properly test the distribution stuff +- make getMetadata public +- maybe make metadata typed and include all options so that you can walk the tree to create testdata orjson-schemas from types +- maybe add a `serialize` function to each runtype too? to use instead of JSON.stringify and to provide a full-service library? +- maybe make `any` a forbidden type of a runtype +- maybe move the `isPure` checks more outward (out of the loops in array and object runtypes) diff --git a/src/any.ts b/src/any.ts index 77e1e4a..85d5c88 100644 --- a/src/any.ts +++ b/src/any.ts @@ -1,10 +1,13 @@ -import { Runtype, internalRuntype } from './runtype' +import { Runtype, setupInternalRuntype } from './runtype' /** * A value to check later. */ export function any(): Runtype { - return internalRuntype((v) => { - return v as any - }, true) + return setupInternalRuntype( + (v) => { + return v as any + }, + { isPure: true }, + ) } diff --git a/src/array.ts b/src/array.ts index 813989f..6896802 100644 --- a/src/array.ts +++ b/src/array.ts @@ -1,21 +1,21 @@ import { createFail, failSymbol, + getInternalRuntype, InternalRuntype, - internalRuntype, isFail, - isPureRuntype, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' -export const arrayRuntype = internalRuntype((v, failOrThrow) => { +export const arrayRuntype: InternalRuntype = (v, failOrThrow) => { if (Array.isArray(v)) { return v } return createFail(failOrThrow, `expected an Array`, v) -}, true) +} /** * An array of a given type. @@ -31,46 +31,53 @@ export function array( ): Runtype { const { maxLength, minLength } = options || {} - const isPure = isPureRuntype(a) + const internalA = getInternalRuntype(a) + const isPure = !!internalA.meta?.isPure - return internalRuntype((v, failOrThrow) => { - const arrayValue = (arrayRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype( + (v, failOrThrow) => { + const arrayValue = arrayRuntype(v, failOrThrow) - if (isFail(arrayValue)) { - return propagateFail(failOrThrow, arrayValue, v) - } + if (isFail(arrayValue)) { + return propagateFail(failOrThrow, arrayValue, v) + } - if (maxLength !== undefined && arrayValue.length > maxLength) { - return createFail( - failOrThrow, - `expected the array to contain at most ${maxLength} elements`, - v, - ) - } + if (maxLength !== undefined && arrayValue.length > maxLength) { + return createFail( + failOrThrow, + `expected the array to contain at most ${maxLength} elements`, + v, + ) + } - if (minLength !== undefined && arrayValue.length < minLength) { - return createFail( - failOrThrow, - `expected the array to contain at least ${minLength} elements`, - v, - ) - } + if (minLength !== undefined && arrayValue.length < minLength) { + return createFail( + failOrThrow, + `expected the array to contain at least ${minLength} elements`, + v, + ) + } - // copy the unknown array in case the item runtype is not pure (we do not mutate anything in place) - const res: A[] = isPure ? arrayValue : new Array(arrayValue.length) + // copy the unknown array in case the item runtype is not pure (we do not + // mutate anything in place) + const res: A[] = isPure ? arrayValue : new Array(arrayValue.length) - for (let i = 0; i < arrayValue.length; i++) { - const item = (a as InternalRuntype)(arrayValue[i], failSymbol) + for (let i = 0; i < arrayValue.length; i++) { + const item = internalA(arrayValue[i], failSymbol) - if (isFail(item)) { - return propagateFail(failOrThrow, item, v, i) - } + if (isFail(item)) { + return propagateFail(failOrThrow, item, v, i) + } - if (!isPure) { - res[i] = item + if (!isPure) { + res[i] = item + } } - } - return res - }, isPure) + return res + }, + { + isPure, + }, + ) } diff --git a/src/boolean.ts b/src/boolean.ts index ef38a8d..872f859 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -1,12 +1,15 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, Runtype, setupInternalRuntype } from './runtype' -const booleanRuntype = internalRuntype((v, failOrThrow) => { - if (v === true || v === false) { - return v - } +const booleanRuntype = setupInternalRuntype( + (v, failOrThrow) => { + if (v === true || v === false) { + return v + } - return createFail(failOrThrow, 'expected a boolean', v) -}, true) + return createFail(failOrThrow, 'expected a boolean', v) + }, + { isPure: true }, +) /** * A boolean. diff --git a/src/custom.ts b/src/custom.ts index 67d5167..a89c3c9 100644 --- a/src/custom.ts +++ b/src/custom.ts @@ -2,11 +2,11 @@ import { createFail, Fail, failSymbol, - internalRuntype, - InternalRuntype, + getInternalRuntype, isFail, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' /** @@ -20,15 +20,20 @@ export function createError(msg: string): Fail { * Construct a custom runtype from a validation function. */ export function runtype(fn: (v: unknown) => T | Fail): Runtype { - return internalRuntype((v, failOrThrow) => { - const res = fn(v) + return setupInternalRuntype( + (v, failOrThrow) => { + const res = fn(v) - if (isFail(res)) { - return propagateFail(failOrThrow, res, v) - } + if (isFail(res)) { + return propagateFail(failOrThrow, res, v) + } - return res - }) + return res + }, + { + isPure: false, + }, + ) } /** @@ -49,7 +54,7 @@ export type ValidationResult = * exceptions and try-catch for error handling is of concern. */ export function use(r: Runtype, v: unknown): ValidationResult { - const result = (r as InternalRuntype)(v, failSymbol) + const result = getInternalRuntype(r)(v, failSymbol) if (isFail(result)) { // we don't know who is using the result (passing error up the stack or diff --git a/src/dictionary.ts b/src/dictionary.ts index 4183e02..dbb377c 100644 --- a/src/dictionary.ts +++ b/src/dictionary.ts @@ -1,81 +1,84 @@ import { objectRuntype } from './object' import { createFail, - internalRuntype, + setupInternalRuntype, isFail, - isPureRuntype, propagateFail, + getInternalRuntype, + InternalRuntypeOf, } from './runtype' import { debugValue } from './runtypeError' import type { Runtype, InternalRuntype, Fail } from './runtype' +const internalObjectRuntype: InternalRuntypeOf = objectRuntype + function dictionaryRuntype( keyRuntype: Runtype, valueRuntype: Runtype, ) { - const isPure = isPureRuntype(keyRuntype) && isPureRuntype(valueRuntype) - - return internalRuntype>((v, failOrThrow) => { - const o: object | Fail = (objectRuntype as InternalRuntype)(v, failOrThrow) + const keyRt: InternalRuntypeOf = keyRuntype + const valueRt: InternalRuntypeOf = valueRuntype - if (isFail(o)) { - return propagateFail(failOrThrow, o, v) - } + const isPure = !!(keyRt.meta?.isPure && valueRt.meta?.isPure) - if (Object.getOwnPropertySymbols(o).length) { - return createFail( - failOrThrow, - `invalid key in dictionary: ${debugValue( - Object.getOwnPropertySymbols(o), - )}`, - v, - ) - } + return setupInternalRuntype>( + (v, failOrThrow) => { + const o = internalObjectRuntype(v, failOrThrow) - // optimize allocations: only create a copy if any of the key runtypes - // return a different object - otherwise return value as is - const res = (isPure ? o : {}) as { [key: string]: U } - - for (const key in o) { - if (!Object.prototype.hasOwnProperty.call(o, key)) { - continue + if (isFail(o)) { + return propagateFail(failOrThrow, o, v) } - if (key === '__proto__') { - // e.g. someone tried to sneak __proto__ into this object and that - // will cause havoc when assigning it to a new object (in case its impure) + if (Object.getOwnPropertySymbols(o).length) { return createFail( failOrThrow, - `invalid key in dictionary: ${debugValue(key)}`, + `invalid key in dictionary: ${debugValue( + Object.getOwnPropertySymbols(o), + )}`, v, ) } - const keyOrFail: T | Fail = (keyRuntype as InternalRuntype)( - key, - failOrThrow, - ) - if (isFail(keyOrFail)) { - return propagateFail(failOrThrow, keyOrFail, v) - } + // optimize allocations: only create a copy if any of the key runtypes + // return a different object - otherwise return value as is + const res = (isPure ? o : {}) as { [key: string]: U } - const value = o[key as keyof typeof o] - const valueOrFail: U | Fail = (valueRuntype as InternalRuntype)( - value, - failOrThrow, - ) + for (const key in o) { + if (!Object.prototype.hasOwnProperty.call(o, key)) { + continue + } - if (isFail(valueOrFail)) { - return propagateFail(failOrThrow, valueOrFail, v) - } + if (key === '__proto__') { + // e.g. someone tried to sneak __proto__ into this object and that + // will cause havoc when assigning it to a new object (in case its impure) + return createFail( + failOrThrow, + `invalid key in dictionary: ${debugValue(key)}`, + v, + ) + } + const keyOrFail = keyRt(key, failOrThrow) + + if (isFail(keyOrFail)) { + return propagateFail(failOrThrow, keyOrFail, v) + } + + const value = o[key as keyof typeof o] + const valueOrFail = valueRt(value, failOrThrow) + + if (isFail(valueOrFail)) { + return propagateFail(failOrThrow, valueOrFail, v) + } - if (!isPure) { - res[keyOrFail] = valueOrFail + if (!isPure) { + res[keyOrFail] = valueOrFail + } } - } - return res - }, isPure) + return res + }, + { isPure }, + ) } /** diff --git a/src/enum.ts b/src/enum.ts index 5f807ce..d0a2137 100644 --- a/src/enum.ts +++ b/src/enum.ts @@ -1,4 +1,4 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' import { debugValue } from './runtypeError' type EnumObject = { [key: string]: string | number } @@ -9,7 +9,7 @@ type EnumObject = { [key: string]: string | number } function enumRuntype( enumObject: T, ): Runtype { - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { // use the fast reverse lookup of number enums to check whether v is a // value of the enum if (typeof v === 'number' && (enumObject as any)[v as any] !== undefined) { @@ -25,7 +25,7 @@ function enumRuntype( `expected a value that belongs to the enum ${debugValue(enumObject)}`, v, ) - }, true) + }) } export { enumRuntype as enum } diff --git a/src/guardedBy.ts b/src/guardedBy.ts index 7c90c61..e75c19c 100644 --- a/src/guardedBy.ts +++ b/src/guardedBy.ts @@ -1,14 +1,14 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * A runtype based on a type guard */ export function guardedBy(typeGuard: (v: unknown) => v is F): Runtype { - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (!typeGuard(v)) { return createFail(failOrThrow, 'expected typeguard to return true', v) } return v - }, true) + }) } diff --git a/src/ignore.ts b/src/ignore.ts index a72f317..022a653 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -1,10 +1,10 @@ -import { internalRuntype, Runtype } from './runtype' +import { setupInternalRuntype, Runtype } from './runtype' /** * A value to ignore (typed as unknown and always set to undefined). */ export function ignore(): Runtype { - return internalRuntype(() => { + return setupInternalRuntype(() => { return undefined as unknown - }, true) + }) } diff --git a/src/integer.ts b/src/integer.ts index 1e2670e..dd5f94f 100644 --- a/src/integer.ts +++ b/src/integer.ts @@ -1,19 +1,19 @@ import { createFail, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, propagateFail, Runtype, } from './runtype' -export const integerRuntype = internalRuntype((v, failOrThrow) => { +export const integerRuntype = setupInternalRuntype((v, failOrThrow) => { if (typeof v === 'number' && Number.isSafeInteger(v)) { return v } return createFail(failOrThrow, 'expected a safe integer', v) -}, true) +}) /** * A Number that is a `isSafeInteger()` @@ -33,8 +33,8 @@ export function integer(options?: { const { min, max } = options - return internalRuntype((v, failOrThrow) => { - const n = (integerRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype((v, failOrThrow) => { + const n = (integerRuntype as InternalRuntype)(v, failOrThrow) if (isFail(n)) { return propagateFail(failOrThrow, n, v) @@ -49,5 +49,5 @@ export function integer(options?: { } return n - }, true) + }) } diff --git a/src/intersection.ts b/src/intersection.ts index 3bef824..17f79bc 100644 --- a/src/intersection.ts +++ b/src/intersection.ts @@ -2,12 +2,12 @@ import { union } from './union' import { record } from './record' import { InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, - isPureRuntype, propagateFail, Runtype, RuntypeUsageError, + getMetadata, } from './runtype' // An intersection of two record runtypes @@ -16,8 +16,8 @@ function recordIntersection2( recordB: Runtype, ): Runtype { const fields: { [key: string]: Runtype } = {} - const a = (recordA as any).fields - const b = (recordB as any).fields + const a = getMetadata(recordA)?.fields ?? {} + const b = getMetadata(recordB)?.fields ?? {} for (const k in { ...a, ...b }) { if (a[k] && b[k]) { @@ -40,7 +40,7 @@ function unionIntersection2( u: Runtype, b: Runtype, ): Runtype { - const unionRuntypes: Runtype[] = (u as any).unions + const unionRuntypes = getMetadata(u)?.unions if ( !unionRuntypes || @@ -79,22 +79,25 @@ function intersection2(a: Runtype, b: Runtype): Runtype { 'intersection2: cannot intersect a base type with a record', ) } else { - const isPure = isPureRuntype(a) && isPureRuntype(b) + return setupInternalRuntype( + (v, failOrThrow) => { + const valFromA = (a as InternalRuntype)(v, failOrThrow) + const valFromB = (b as InternalRuntype)(v, failOrThrow) - return internalRuntype((v, failOrThrow) => { - const valFromA = (a as InternalRuntype)(v, failOrThrow) - const valFromB = (b as InternalRuntype)(v, failOrThrow) + if (isFail(valFromB)) { + return propagateFail(failOrThrow, valFromB, v) + } - if (isFail(valFromB)) { - return propagateFail(failOrThrow, valFromB, v) - } + if (isFail(valFromA)) { + return propagateFail(failOrThrow, valFromA, v) + } - if (isFail(valFromA)) { - return propagateFail(failOrThrow, valFromA, v) - } - - return valFromB // second runtype arg is preferred - }, isPure) + return valFromB // second runtype arg is preferred + }, + { + isImpure: getMetadata(a)?.isImpure || getMetadata(b)?.isImpure, + }, + ) } } diff --git a/src/json.ts b/src/json.ts index 7155991..00c0d86 100644 --- a/src/json.ts +++ b/src/json.ts @@ -1,14 +1,14 @@ import { createFail, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, propagateFail, Runtype, } from './runtype' import { use } from './custom' -export const jsonRuntype = internalRuntype((v, failOrThrow) => { +export const jsonRuntype = setupInternalRuntype((v, failOrThrow) => { if (!(typeof v === 'string')) { return createFail(failOrThrow, 'expected a json string', v) } @@ -25,7 +25,7 @@ export const jsonRuntype = internalRuntype((v, failOrThrow) => { * A String that is valid json */ export function json(rt: Runtype): Runtype { - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { const n = (jsonRuntype as InternalRuntype)(v, failOrThrow) if (isFail(n)) { diff --git a/src/literal.ts b/src/literal.ts index a513a4d..35ac33d 100644 --- a/src/literal.ts +++ b/src/literal.ts @@ -1,5 +1,5 @@ import { debugValue } from './runtypeError' -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * A literal string, number, boolean or enum. @@ -8,7 +8,7 @@ export function literal(lit: T): Runtype export function literal(lit: T): Runtype export function literal(lit: T): Runtype export function literal(lit: string | number | boolean): Runtype { - const rt: any = internalRuntype((v, failOrThrow) => { + const rt: any = setupInternalRuntype((v, failOrThrow) => { if (v === lit) { return lit } diff --git a/src/null.ts b/src/null.ts index 482cd52..e9a47f7 100644 --- a/src/null.ts +++ b/src/null.ts @@ -1,11 +1,11 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * null */ // eslint-disable-next-line no-shadow-restricted-names function nullRuntype(): Runtype { - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (v !== null) { return createFail(failOrThrow, 'expected null', v) } diff --git a/src/nullOr.ts b/src/nullOr.ts index 2fabd0b..d257c15 100644 --- a/src/nullOr.ts +++ b/src/nullOr.ts @@ -1,6 +1,6 @@ import { InternalRuntype, - internalRuntype, + setupInternalRuntype, isPureRuntype, Runtype, } from './runtype' @@ -11,7 +11,7 @@ import { export function nullOr(t: Runtype): Runtype { const isPure = isPureRuntype(t) - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (v === null) { return null } diff --git a/src/number.ts b/src/number.ts index 36ee29b..4d68c71 100644 --- a/src/number.ts +++ b/src/number.ts @@ -1,4 +1,4 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * A number. By default reject NaN and Infinity values. @@ -18,7 +18,7 @@ export function number(options?: { }): Runtype { const { allowNaN, allowInfinity, min, max } = options || {} - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (typeof v !== 'number') { return createFail(failOrThrow, 'expected a number', v) } diff --git a/src/object.ts b/src/object.ts index c2b208e..5a6a547 100644 --- a/src/object.ts +++ b/src/object.ts @@ -1,13 +1,16 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' // cached object runtype -export const objectRuntype = internalRuntype((v, failOrThrow) => { - if (typeof v === 'object' && !Array.isArray(v) && v !== null) { - return v - } +export const objectRuntype = setupInternalRuntype( + (v, failOrThrow) => { + if (typeof v === 'object' && !Array.isArray(v) && v !== null) { + return v + } - return createFail(failOrThrow, 'expected an object', v) -}, true) + return createFail(failOrThrow, 'expected an object', v) + }, + { isPure: true }, +) /** * An object that is not an array. diff --git a/src/optional.ts b/src/optional.ts index b27e842..c80d37e 100644 --- a/src/optional.ts +++ b/src/optional.ts @@ -1,6 +1,6 @@ import { InternalRuntype, - internalRuntype, + setupInternalRuntype, isPureRuntype, OptionalRuntype, Runtype, @@ -18,7 +18,7 @@ import { export function optional(t: Runtype): OptionalRuntype { const isPure = isPureRuntype(t) - const rt = internalRuntype((v, failOrThrow) => { + const rt = setupInternalRuntype((v, failOrThrow) => { if (v === undefined) { return undefined } diff --git a/src/record.ts b/src/record.ts index 48caf7d..32864ef 100644 --- a/src/record.ts +++ b/src/record.ts @@ -2,7 +2,7 @@ import { createFail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, isPureRuntype, propagateFail, @@ -41,7 +41,7 @@ export function internalRecord( const typemapKeys = [...Object.keys(typemap)] const typemapValues = [...Object.values(typemap)] - const rt = internalRuntype((v, failOrThrow) => { + const rt = setupInternalRuntype((v, failOrThrow) => { // inlined object runtype for perf if (typeof v !== 'object' || Array.isArray(v) || v === null) { return createFail(failOrThrow, 'expected an object', v) diff --git a/src/runtype.ts b/src/runtype.ts index 55bdf19..6dbefa6 100644 --- a/src/runtype.ts +++ b/src/runtype.ts @@ -1,4 +1,44 @@ /** + * Runtype + * + * Just a function. The returned value may be a copy of v, depending on the + * runtypes implementation. + */ +export interface Runtype { + /** + * A function to check that v 'conforms' to type T + * + * By default, Raises a RuntypeError if the check fails. + * With `useRuntype(runtype, value)` it will return a `ValidationResult` instead. + */ + (v: unknown): T +} + +/** + * Helper for use in record definitions to mark optional keys. + */ +export interface OptionalRuntype { + isOptionalRuntype: true + (v: unknown): T +} + +/** + * Helper to build record types with optionals + */ +export type Unpack = T extends Runtype + ? U + : T extends OptionalRuntype + ? V + : never + +/** + * Helper to Force Typescript to boil down complex types to a plain interface + */ +export type Collapse = T extends infer U ? { [K in keyof U]: U[K] } : never + +/** + * Error with additional information + * * Thrown if the input does not match the runtype. * * Use `getFormattedErrorPath`, `getFormattedErrorValue` and @@ -22,7 +62,7 @@ export class RuntypeError extends Error { } /** - * Thrown if the api is misused. + * Thrown if the api is misused */ export class RuntypeUsageError extends Error {} @@ -44,7 +84,9 @@ export interface Fail { value?: any } -// create a fail or raise the error exception if the called runtype was on top +/** + * Create a fail or raise the error exception if the called runtype was on top + */ export function createFail( failOrThrow: typeof failSymbol | undefined, msg: string, @@ -72,25 +114,30 @@ export function createFail( } } -// pass the fail up to the caller or, if on top, raise the error exception +/** + * Pass the fail up to the caller or, if on top, raise the error exception + */ export function propagateFail( failOrThrow: typeof failSymbol | undefined, failObj: Fail, topLevelValue?: unknown, key?: string | number, ): Fail { + // while unwinding the stack, add path information if (key !== undefined) { failObj.path.push(key) } if (failOrThrow === undefined) { - // runtype check failed + // toplevel runtype invocation: throw throw new RuntypeError(failObj.reason, topLevelValue, failObj.path) } else if (failOrThrow === failSymbol) { + // either non-throw invocation or not at the toplevel return failObj } else { + // some passed an invalid second argument to the runtype throw new RuntypeUsageError( - `failOrThrow must be undefined or the failSymbol, not ${JSON.stringify( + `do not pass a second argument to a runtype - failOrThrow must be undefined or the failSymbol, not ${JSON.stringify( failOrThrow, )}`, ) @@ -98,94 +145,80 @@ export function propagateFail( } /** - * Runtype - * - * Just a function. The returned value may be a copy of v, depending on the - * runtypes implementation. + * Check whether a returned value is a failure */ -export interface Runtype { - /** - * A function to check that v 'conforms' to type T - * - * By default, Raises a RuntypeError if the check fails. - * With `useRuntype(runtype, value)` it will return a `ValidationResult` instead. - */ - (v: unknown): T +export function isFail(v: unknown): v is Fail { + if (typeof v !== 'object' || !v) { + return false + } + + return (v as any)[failSymbol] } /** - * Special runtype for use in record definitions to mark optional keys. + * Internals of a runtype. + * + * Used to implement combinators (pick, omit, intersection, ...) and + * optimizations. */ -export interface OptionalRuntype { - isOptionalRuntype: true - (v: unknown): T -} - -export type Unpack = T extends Runtype - ? U - : T extends OptionalRuntype - ? V - : never - -// force Typescript to boil down complex mapped types to a plain interface -export type Collapse = T extends infer U ? { [K in keyof U]: U[K] } : never - -export const isPureRuntypeSymbol = Symbol('isPure') - -// The internal runtype is one that receives an additional flag that -// determines whether the runtype should throw a RuntypeError or whether it -// should return a Fail up to the caller. -// -// Use this to: -// * accumulate additional path data when unwinding a fail (propagateFail) -// * have runtypes return a dedicated fail value to implement union over any -// runtypes (isFail) -// -// Pass `true` as isPure to signal that this runtype is not modifying its -// value (checked with `isPureRuntype` -export function internalRuntype( - fn: (v: unknown, failOrThrow?: typeof failSymbol) => T, - isPure?: boolean, -): Runtype { - if (isPure === true) { - return Object.assign(fn, { isPure: isPureRuntypeSymbol }) - } else if (isPure === undefined || isPure === false) { - return fn - } else { - throw new RuntypeUsageError( - 'expected "isPure" or undefined as the second argument', - ) - } +export interface RuntypeMetadata { + // fields of a record runtype + // Used by combinators to build new records. + fields?: Record> + + // true if the record runtype ignores additional fields + // Used by combinators to preserve non-strictness. + isNonStrict?: boolean + + // true if the runtype (after the check) does return the passed value as-is. + // Nested pure runtypes can save allocations by just passing the value back + // after a successful check. Impure runtypes must always copy its values to + // be safe. + // Container runtypes made of impure elements must also copy the whole + // container. + isPure: boolean + + // the elements of a union runtype, used to implement distribution of union + // intersections + unions?: Runtype[] } /** - * A pure runtype does not change its value. + * The internal runtype is one that receives an additional flag that + * determines whether the runtype should throw a RuntypeError or whether it + * should return a Fail up to the caller. * - * A non-pure runtype may return a changed value. - * This is used to get rid of redundant object copying + * Use this to: + * * accumulate additional path data when unwinding a fail (propagateFail) + * * have runtypes return a dedicated fail value to implement union over any + * runtypes (isFail) */ -export function isPureRuntype(fn: Runtype): boolean { - return !!(fn as any).isPure +export interface InternalRuntype { + (v: unknown, failOrThrow?: typeof failSymbol): T | Fail + meta?: RuntypeMetadata } -export type InternalRuntype = ( - v: unknown, - failOrThrow: typeof failSymbol | undefined, -) => any +export type InternalRuntypeOf = T extends Runtype + ? InternalRuntype + : never /** - * Check whether a returned value is a failure. + * Setup the internal runtype with metadata. */ -export function isFail(v: unknown): v is Fail { - if (typeof v !== 'object' || !v) { - return false +export function setupInternalRuntype( + fn: InternalRuntype, + meta: RuntypeMetadata, +): Runtype { + if (!meta) { + return fn as Runtype } - return (v as any)[failSymbol] + return Object.assign(fn, { meta }) as Runtype } -export function isNonStrictRuntype(fn: Runtype): boolean { - return !!(fn as any).isNonStrict +/** + * Return an internal runtype. + */ +export function getInternalRuntype(rt: Runtype): InternalRuntype { + return rt as InternalRuntype } - -export const isNonStrictRuntypeSymbol = Symbol('isNonStrict') diff --git a/src/string.ts b/src/string.ts index e9022fc..6892992 100644 --- a/src/string.ts +++ b/src/string.ts @@ -1,13 +1,13 @@ import { createFail, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, propagateFail, Runtype, } from './runtype' -const stringRuntype = internalRuntype((v, failOrThrow) => { +const stringRuntype = setupInternalRuntype((v, failOrThrow) => { if (typeof v === 'string') { return v } @@ -39,7 +39,7 @@ export function string(options?: { const isPure = !trim // trim modifies the string - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { const s: string = (stringRuntype as InternalRuntype)(v, failOrThrow) if (isFail(s)) { diff --git a/src/stringAsInteger.ts b/src/stringAsInteger.ts index 9bf2903..d7345f8 100644 --- a/src/stringAsInteger.ts +++ b/src/stringAsInteger.ts @@ -3,13 +3,13 @@ import { createFail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, propagateFail, Runtype, } from './runtype' -export const stringAsIntegerRuntype = internalRuntype( +export const stringAsIntegerRuntype = setupInternalRuntype( (v, failOrThrow) => { if (typeof v === 'string') { const parsedNumber = parseInt(v, 10) @@ -69,7 +69,7 @@ export function stringAsInteger(options?: { const { min, max } = options - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { const n = (stringAsIntegerRuntype as InternalRuntype)(v, failOrThrow) if (isFail(n)) { diff --git a/src/stringLiteralUnion.ts b/src/stringLiteralUnion.ts index 145f24b..8b72df7 100644 --- a/src/stringLiteralUnion.ts +++ b/src/stringLiteralUnion.ts @@ -1,4 +1,4 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * A union of string literals. @@ -8,7 +8,7 @@ export function stringLiteralUnion( ): Runtype { const valuesIndex = new Set(values) - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (typeof v !== 'string' || !valuesIndex.has(v)) { return createFail(failOrThrow, `expected one of ${values}`, v) } diff --git a/src/tuple.ts b/src/tuple.ts index 6a80e22..9c076c4 100644 --- a/src/tuple.ts +++ b/src/tuple.ts @@ -2,7 +2,7 @@ import { createFail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, isPureRuntype, propagateFail, @@ -42,7 +42,7 @@ export function tuple( export function tuple(...types: Runtype[]): Runtype { const isPure = types.every((t) => isPureRuntype(t)) - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { const a = (arrayRuntype as InternalRuntype)(v, failOrThrow) if (isFail(a)) { diff --git a/src/undefined.ts b/src/undefined.ts index 49d3ce5..aa3bb5e 100644 --- a/src/undefined.ts +++ b/src/undefined.ts @@ -1,11 +1,11 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, setupInternalRuntype, Runtype } from './runtype' /** * undefined */ // eslint-disable-next-line no-shadow-restricted-names function undefinedRuntype(): Runtype { - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (v !== undefined) { return createFail(failOrThrow, 'expected undefined', v) } diff --git a/src/undefinedOr.ts b/src/undefinedOr.ts index 020740b..71d05f4 100644 --- a/src/undefinedOr.ts +++ b/src/undefinedOr.ts @@ -1,6 +1,6 @@ import { InternalRuntype, - internalRuntype, + setupInternalRuntype, isPureRuntype, Runtype, } from './runtype' @@ -11,7 +11,7 @@ import { export function undefinedOr(t: Runtype): Runtype { const isPure = isPureRuntype(t) - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { if (v === undefined) { return undefined } diff --git a/src/union.ts b/src/union.ts index 5a11dc9..f2b446a 100644 --- a/src/union.ts +++ b/src/union.ts @@ -5,7 +5,7 @@ import { Fail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, isPureRuntype, propagateFail, @@ -58,7 +58,7 @@ function internalDiscriminatedUnion( const isPure = runtypes.every((t) => isPureRuntype(t)) - const resultingRuntype = internalRuntype((v, failOrThrow) => { + const resultingRuntype = setupInternalRuntype((v, failOrThrow) => { const o: any = (objectRuntype as InternalRuntype)(v, failOrThrow) if (isFail(o)) { @@ -165,7 +165,7 @@ export function union[]>( // simple union validation: try all runtypes and use the first one that // doesn't fail - return internalRuntype((v, failOrThrow) => { + return setupInternalRuntype((v, failOrThrow) => { let lastFail: Fail | undefined for (let i = 0; i < runtypes.length; i++) { diff --git a/src/unknown.ts b/src/unknown.ts index afa5b60..2b0482e 100644 --- a/src/unknown.ts +++ b/src/unknown.ts @@ -1,10 +1,10 @@ -import { internalRuntype, Runtype } from './runtype' +import { setupInternalRuntype, Runtype } from './runtype' /** * A value to check later. */ export function unknown(): Runtype { - return internalRuntype((v) => { + return setupInternalRuntype((v) => { return v }, true) }