From 6988517e2da6908b18a979e823180ff89e000bf4 Mon Sep 17 00:00:00 2001 From: Erik Soehnel Date: Wed, 1 Nov 2023 11:40:46 +0100 Subject: [PATCH] metadata for runtypes --- README.md | 44 +++++---- src/any.ts | 11 ++- src/array.ts | 79 ++++++++------- src/boolean.ts | 17 ++-- src/custom.ts | 25 +++-- src/dictionary.ts | 101 ++++++++++---------- src/enum.ts | 38 ++++---- src/guardedBy.ts | 17 ++-- src/ignore.ts | 6 +- src/integer.ts | 54 +++++++---- src/intersection.ts | 70 ++++++++------ src/json.ts | 58 +++++++----- src/literal.ts | 27 +++--- src/nonStrict.ts | 4 +- src/null.ts | 17 ++-- src/nullOr.ts | 24 +++-- src/number.ts | 41 ++++---- src/object.ts | 17 ++-- src/omit.ts | 10 +- src/optional.ts | 24 ++--- src/partial.ts | 10 +- src/pick.ts | 10 +- src/record.ts | 150 ++++++++++++----------------- src/runtype.ts | 195 +++++++++++++++++++++++--------------- src/string.ts | 72 ++++++++------ src/stringAsInteger.ts | 10 +- src/stringLiteralUnion.ts | 17 ++-- src/tuple.ts | 56 +++++------ src/undefined.ts | 17 ++-- src/undefinedOr.ts | 24 +++-- src/union.ts | 95 +++++++++---------- src/unknown.ts | 11 ++- test/helpers.ts | 3 +- 33 files changed, 731 insertions(+), 623 deletions(-) diff --git a/README.md b/README.md index 23fd2e3..c542d20 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,17 @@ That's how runtypes work. - [Why?](#why) - [Benchmarks](#benchmarks) - [Documentation](#documentation) - * [Intro](#intro) - * [Usage Examples](#usage-examples) - + [Nesting](#nesting) - + [Strict Property Checks](#strict-property-checks) - + [Ignore Individual Properties](#ignore-individual-properties) - + [Optional Properties](#optional-properties) - + [Non-strict Property Checks](#non-strict-property-checks) - + [Discriminating Unions](#discriminating-unions) - + [Custom Runtypes](#custom-runtypes) - * [Reference](#reference) - * [Roadmap / Todos](#roadmap--todos) + - [Intro](#intro) + - [Usage Examples](#usage-examples) + - [Nesting](#nesting) + - [Strict Property Checks](#strict-property-checks) + - [Ignore Individual Properties](#ignore-individual-properties) + - [Optional Properties](#optional-properties) + - [Non-strict Property Checks](#non-strict-property-checks) + - [Discriminating Unions](#discriminating-unions) + - [Custom Runtypes](#custom-runtypes) + - [Reference](#reference) + - [Roadmap / Todos](#roadmap--todos) @@ -212,7 +212,7 @@ const networkSuccessState = st.record({ title: st.string(), duration: st.number(), summary: st.string(), - }) + }), }) const networdStateRuntype = st.union( @@ -237,14 +237,14 @@ const bigIntRuntype = st.runtype((v) => { const stringCheck = st.use(bigIntStringRuntype, v) if (!stringCheck.ok) { - return stringCheck.error + return stringCheck.error } return BigInt(stringCheck.result.slice(0, -1)) }) -bigIntRuntype("123n") // => 123n -bigIntRuntype("2.2") // => error: "expected string to match ..." +bigIntRuntype('123n') // => 123n +bigIntRuntype('2.2') // => error: "expected string to match ..." ``` ### Reference @@ -298,13 +298,21 @@ Shortcuts: on all types that [`literal`](src/literal.ts#L10) accepts - rename record to object: [#69](https://github.com/hoeck/simple-runtypes/issues/69) - improve docs: - - *preface*: what is a runtype and why is it useful - - *why*: explain or link to example that shows "strict by default" + - _preface_: what is a runtype and why is it useful + - _why_: explain or link to example that shows "strict by default" - show that `simple-runtypes` is feature complete because it can 1. express all TypeScript types 2. is extendable with custom runtypes (add documentation) - add small frontend and backend example projects that show how to use `simple-runtypes` in production -- test *all* types with [tsd](https://github.com/SamVerschueren/tsd) +- test _all_ types with [tsd](https://github.com/SamVerschueren/tsd) - 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 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..6b10089 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,23 +9,29 @@ type EnumObject = { [key: string]: string | number } function enumRuntype( enumObject: T, ): Runtype { - return internalRuntype((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) { - return (v as unknown) as T[S] - } + 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 + ) { + return (v as unknown) as T[S] + } - if (Object.values(enumObject).indexOf(v as any) !== -1) { - return v as T[S] - } + if (Object.values(enumObject).indexOf(v as any) !== -1) { + return v as T[S] + } - return createFail( - failOrThrow, - `expected a value that belongs to the enum ${debugValue(enumObject)}`, - v, - ) - }, true) + return createFail( + failOrThrow, + `expected a value that belongs to the enum ${debugValue(enumObject)}`, + v, + ) + }, + { isPure: true }, + ) } export { enumRuntype as enum } diff --git a/src/guardedBy.ts b/src/guardedBy.ts index 7c90c61..9a4c287 100644 --- a/src/guardedBy.ts +++ b/src/guardedBy.ts @@ -1,14 +1,17 @@ -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) => { - if (!typeGuard(v)) { - return createFail(failOrThrow, 'expected typeguard to return true', v) - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (!typeGuard(v)) { + return createFail(failOrThrow, 'expected typeguard to return true', v) + } - return v - }, true) + return v + }, + { isPure: 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..e03a57c 100644 --- a/src/integer.ts +++ b/src/integer.ts @@ -1,19 +1,22 @@ import { createFail, InternalRuntype, - internalRuntype, isFail, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' -export const integerRuntype = internalRuntype((v, failOrThrow) => { - if (typeof v === 'number' && Number.isSafeInteger(v)) { - return v - } +export const integerRuntype = setupInternalRuntype( + (v, failOrThrow) => { + if (typeof v === 'number' && Number.isSafeInteger(v)) { + return v + } - return createFail(failOrThrow, 'expected a safe integer', v) -}, true) + return createFail(failOrThrow, 'expected a safe integer', v) + }, + { isPure: true }, +) /** * A Number that is a `isSafeInteger()` @@ -33,21 +36,32 @@ 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) - } + if (isFail(n)) { + return propagateFail(failOrThrow, n, v) + } - if (min !== undefined && n < min) { - return createFail(failOrThrow, `expected the integer to be >= ${min}`, v) - } + if (min !== undefined && n < min) { + return createFail( + failOrThrow, + `expected the integer to be >= ${min}`, + v, + ) + } - if (max !== undefined && n > max) { - return createFail(failOrThrow, `expected the integer to be <= ${max}`, v) - } + if (max !== undefined && n > max) { + return createFail( + failOrThrow, + `expected the integer to be <= ${max}`, + v, + ) + } - return n - }, true) + return n + }, + { isPure: true }, + ) } diff --git a/src/intersection.ts b/src/intersection.ts index 3bef824..de109d6 100644 --- a/src/intersection.ts +++ b/src/intersection.ts @@ -1,23 +1,22 @@ -import { union } from './union' import { record } from './record' import { InternalRuntype, - internalRuntype, isFail, - isPureRuntype, propagateFail, Runtype, RuntypeUsageError, + setupInternalRuntype, } from './runtype' +import { union } from './union' // An intersection of two record runtypes function recordIntersection2( - recordA: Runtype, - recordB: Runtype, -): Runtype { + recordA: InternalRuntype, + recordB: InternalRuntype, +): InternalRuntype { const fields: { [key: string]: Runtype } = {} - const a = (recordA as any).fields - const b = (recordB as any).fields + const a = recordA.meta?.fields ?? {} + const b = recordB.meta?.fields ?? {} for (const k in { ...a, ...b }) { if (a[k] && b[k]) { @@ -37,10 +36,10 @@ function recordIntersection2( // An intersection of a union with another type function unionIntersection2( - u: Runtype, - b: Runtype, -): Runtype { - const unionRuntypes: Runtype[] = (u as any).unions + u: InternalRuntype, + b: InternalRuntype, +): InternalRuntype { + const unionRuntypes = u.meta?.unions if ( !unionRuntypes || @@ -64,37 +63,46 @@ function unionIntersection2( * In case the intersection contains records or unions (of records), create a * completely new record or union runtype. */ -function intersection2(a: Runtype, b: Runtype): Runtype -function intersection2(a: Runtype, b: Runtype): Runtype { - if ('fields' in a && 'fields' in b) { +function intersection2( + a: InternalRuntype, + b: InternalRuntype, +): InternalRuntype +function intersection2( + a: InternalRuntype, + b: InternalRuntype, +): InternalRuntype { + if (a.meta?.fields && b.meta?.fields) { return recordIntersection2(a, b) - } else if ('unions' in a && 'fields' in b) { + } else if (a.meta?.unions && b.meta?.fields) { return unionIntersection2(a, b) - } else if ('unions' in b && 'fields' in a) { + } else if (b.meta?.unions && a.meta?.fields) { return unionIntersection2(b, a) - } else if ('fields' in a || 'fields' in b) { + } else if (a.meta?.fields || b.meta?.fields) { // Does such an intersection (e.g. string | {a: number} even make sense? // And how would you implement it? throw new RuntypeUsageError( '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 + }, + { + isPure: a.meta?.isPure || b.meta?.isPure, + }, + ) } } diff --git a/src/json.ts b/src/json.ts index 7155991..8eb290b 100644 --- a/src/json.ts +++ b/src/json.ts @@ -1,43 +1,53 @@ +import { use } from './custom' import { createFail, InternalRuntype, - internalRuntype, isFail, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' -import { use } from './custom' -export const jsonRuntype = internalRuntype((v, failOrThrow) => { - if (!(typeof v === 'string')) { - return createFail(failOrThrow, 'expected a json string', v) - } +export const jsonRuntype = setupInternalRuntype( + (v, failOrThrow) => { + if (!(typeof v === 'string')) { + return createFail(failOrThrow, 'expected a json string', v) + } - try { - const jsonData = JSON.parse(v) - return jsonData - } catch (err) { - return createFail(failOrThrow, 'expected a json string: ' + String(err), v) - } -}, false) + try { + const jsonData = JSON.parse(v) + return jsonData + } catch (err) { + return createFail( + failOrThrow, + 'expected a json string: ' + String(err), + v, + ) + } + }, + { isPure: false }, +) /** * A String that is valid json */ export function json(rt: Runtype): Runtype { - return internalRuntype((v, failOrThrow) => { - const n = (jsonRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype( + (v, failOrThrow) => { + const n = (jsonRuntype as InternalRuntype)(v, failOrThrow) - if (isFail(n)) { - return propagateFail(failOrThrow, n, v) - } + if (isFail(n)) { + return propagateFail(failOrThrow, n, v) + } - const validationResult = use(rt, n) + const validationResult = use(rt, n) - if (!validationResult.ok) { - return propagateFail(failOrThrow, validationResult.error, v) - } + if (!validationResult.ok) { + return propagateFail(failOrThrow, validationResult.error, v) + } - return validationResult.result - }, false) + return validationResult.result + }, + { isPure: true }, + ) } diff --git a/src/literal.ts b/src/literal.ts index a513a4d..54a1e17 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,17 +8,18 @@ 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) => { - if (v === lit) { - return lit - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v === lit) { + return lit + } - return createFail(failOrThrow, `expected a literal: ${debugValue(lit)}`, v) - }, true) - - // keep the literal as metadata on the runtype itself to be able to use it - // in record intersections to determine the right record runtype - rt.literal = lit - - return rt + return createFail( + failOrThrow, + `expected a literal: ${debugValue(lit)}`, + v, + ) + }, + { isPure: true, literal: lit }, + ) } diff --git a/src/nonStrict.ts b/src/nonStrict.ts index 2312c9f..2af4440 100644 --- a/src/nonStrict.ts +++ b/src/nonStrict.ts @@ -1,5 +1,5 @@ import { internalRecord } from './record' -import { Runtype, RuntypeUsageError } from './runtype' +import { InternalRuntype, Runtype, RuntypeUsageError } from './runtype' /** * Build a non-strict `record` runtype from the provided `record` runtype. @@ -14,7 +14,7 @@ import { Runtype, RuntypeUsageError } from './runtype' * to a nested typemap, `nonStrict` needs to be used at each level of the typemap. */ export function nonStrict(original: Runtype): Runtype { - const fields = (original as any).fields + const fields = (original as InternalRuntype).meta?.fields if (!fields) { throw new RuntypeUsageError('expected a record runtype') diff --git a/src/null.ts b/src/null.ts index 482cd52..a9d9c12 100644 --- a/src/null.ts +++ b/src/null.ts @@ -1,17 +1,20 @@ -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) => { - if (v !== null) { - return createFail(failOrThrow, 'expected null', v) - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v !== null) { + return createFail(failOrThrow, 'expected null', v) + } - return v - }, true) + return v + }, + { isPure: true }, + ) } export { nullRuntype as null } diff --git a/src/nullOr.ts b/src/nullOr.ts index 2fabd0b..90143f3 100644 --- a/src/nullOr.ts +++ b/src/nullOr.ts @@ -1,21 +1,19 @@ -import { - InternalRuntype, - internalRuntype, - isPureRuntype, - Runtype, -} from './runtype' +import { getInternalRuntype, Runtype, setupInternalRuntype } from './runtype' /** * Shortcut for a type or null. */ export function nullOr(t: Runtype): Runtype { - const isPure = isPureRuntype(t) + const ti = getInternalRuntype(t) - return internalRuntype((v, failOrThrow) => { - if (v === null) { - return null - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v === null) { + return null + } - return (t as InternalRuntype)(v, failOrThrow) - }, isPure) + return ti(v, failOrThrow) + }, + { isPure: ti?.meta?.isPure }, + ) } diff --git a/src/number.ts b/src/number.ts index 36ee29b..febd12b 100644 --- a/src/number.ts +++ b/src/number.ts @@ -1,4 +1,4 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, Runtype, setupInternalRuntype } from './runtype' /** * A number. By default reject NaN and Infinity values. @@ -18,27 +18,30 @@ export function number(options?: { }): Runtype { const { allowNaN, allowInfinity, min, max } = options || {} - return internalRuntype((v, failOrThrow) => { - if (typeof v !== 'number') { - return createFail(failOrThrow, 'expected a number', v) - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (typeof v !== 'number') { + return createFail(failOrThrow, 'expected a number', v) + } - if (!allowNaN && isNaN(v)) { - return createFail(failOrThrow, 'expected a number that is not NaN', v) - } + if (!allowNaN && isNaN(v)) { + return createFail(failOrThrow, 'expected a number that is not NaN', v) + } - if (!allowInfinity && (v === Infinity || v === -Infinity)) { - return createFail(failOrThrow, 'expected a finite number', v) - } + if (!allowInfinity && (v === Infinity || v === -Infinity)) { + return createFail(failOrThrow, 'expected a finite number', v) + } - if (min !== undefined && v < min) { - return createFail(failOrThrow, `expected number to be >= ${min}`, v) - } + if (min !== undefined && v < min) { + return createFail(failOrThrow, `expected number to be >= ${min}`, v) + } - if (max !== undefined && v > max) { - return createFail(failOrThrow, `expected number to be <= ${max}`, v) - } + if (max !== undefined && v > max) { + return createFail(failOrThrow, `expected number to be <= ${max}`, v) + } - return v - }, true) + return v + }, + { isPure: true }, + ) } 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/omit.ts b/src/omit.ts index 4d2845a..d136dc2 100644 --- a/src/omit.ts +++ b/src/omit.ts @@ -1,5 +1,5 @@ import { internalRecord } from './record' -import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' +import { InternalRuntype, Runtype, RuntypeUsageError } from './runtype' /** * Build a new record runtype that omits some keys from the original. @@ -9,7 +9,8 @@ export function omit( original: Runtype, ...keys: K[] ): Runtype> { - const fields = (original as any).fields + const fields = (original as InternalRuntype).meta?.fields + const isNonStrict = (original as InternalRuntype).meta?.isNonStrict if (!fields) { throw new RuntypeUsageError(`expected a record runtype`) @@ -21,8 +22,5 @@ export function omit( delete newRecordFields[k] }) - return internalRecord( - newRecordFields, - isNonStrictRuntype(original), - ) as Runtype + return internalRecord(newRecordFields, isNonStrict) } diff --git a/src/optional.ts b/src/optional.ts index b27e842..19d7a68 100644 --- a/src/optional.ts +++ b/src/optional.ts @@ -1,9 +1,8 @@ import { - InternalRuntype, - internalRuntype, - isPureRuntype, + getInternalRuntype, OptionalRuntype, Runtype, + setupInternalRuntype, } from './runtype' /** @@ -16,15 +15,16 @@ import { * => {foo?: string} */ export function optional(t: Runtype): OptionalRuntype { - const isPure = isPureRuntype(t) + const ti = getInternalRuntype(t) - const rt = internalRuntype((v, failOrThrow) => { - if (v === undefined) { - return undefined - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v === undefined) { + return undefined + } - return (t as InternalRuntype)(v, failOrThrow) - }, isPure) as OptionalRuntype - - return rt + return ti(v, failOrThrow) + }, + { isPure: ti.meta?.isPure }, + ) as OptionalRuntype } diff --git a/src/partial.ts b/src/partial.ts index a7ab8c2..b4ccc99 100644 --- a/src/partial.ts +++ b/src/partial.ts @@ -1,6 +1,6 @@ import { optional } from './optional' import { internalRecord } from './record' -import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' +import { InternalRuntype, Runtype, RuntypeUsageError } from './runtype' /** * Build a new record runtype that marks all keys as optional. @@ -10,7 +10,8 @@ import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' export function partial( original: Runtype, ): Runtype> { - const fields = (original as any).fields + const fields = (original as InternalRuntype).meta?.fields + const isNonStrict = (original as InternalRuntype).meta?.isNonStrict if (!fields) { throw new RuntypeUsageError(`expected a record runtype`) @@ -26,8 +27,5 @@ export function partial( } } - return internalRecord( - newRecordFields, - isNonStrictRuntype(original), - ) as Runtype + return internalRecord(newRecordFields, isNonStrict) } diff --git a/src/pick.ts b/src/pick.ts index 9584d04..8ce13b9 100644 --- a/src/pick.ts +++ b/src/pick.ts @@ -1,5 +1,5 @@ import { internalRecord } from './record' -import { Runtype, RuntypeUsageError, isNonStrictRuntype } from './runtype' +import { InternalRuntype, Runtype, RuntypeUsageError } from './runtype' /** * Build a new record runtype that contains some keys from the original @@ -8,7 +8,8 @@ export function pick( original: Runtype, ...keys: K[] ): Runtype> { - const fields = (original as any).fields + const fields = (original as InternalRuntype).meta?.fields + const isNonStrict = (original as InternalRuntype).meta?.isNonStrict if (!fields) { throw new RuntypeUsageError(`expected a record runtype`) @@ -20,8 +21,5 @@ export function pick( newRecordFields[k] = fields[k] }) - return internalRecord( - newRecordFields, - isNonStrictRuntype(original), - ) as Runtype + return internalRecord(newRecordFields, isNonStrict) } diff --git a/src/record.ts b/src/record.ts index 48caf7d..6c06a44 100644 --- a/src/record.ts +++ b/src/record.ts @@ -1,26 +1,23 @@ import { + Collapse, createFail, failSymbol, + getRuntypeMetadata, InternalRuntype, - internalRuntype, isFail, - isPureRuntype, + OptionalRuntype, propagateFail, Runtype, - OptionalRuntype, - Collapse, + setupInternalRuntype, Unpack, - isNonStrictRuntypeSymbol, } from './runtype' import { debugValue } from './runtypeError' -function isPureTypemap(typemap: object) { - for (const k in typemap) { - if (!Object.prototype.hasOwnProperty.call(typemap, k)) { - continue - } +function isPureTypemap(typemapValues: (Runtype | OptionalRuntype)[]) { + for (let i = 0; i < typemapValues.length; i++) { + const m = getRuntypeMetadata(typemapValues[i]) - if (!isPureRuntype(typemap[k as keyof typeof typemap])) { + if (!m.isPure) { return false } } @@ -32,101 +29,78 @@ export function internalRecord( typemap: { [key: string]: Runtype | OptionalRuntype }, - isNonStrict = false, + isNonStrict?: boolean, ): Runtype { - // a nonStrict record may ignore keys and so cannot be pure - const isPure = !isNonStrict && isPureTypemap(typemap) - // cache typemap in arrays for a faster loop const typemapKeys = [...Object.keys(typemap)] - const typemapValues = [...Object.values(typemap)] - - const rt = internalRuntype((v, failOrThrow) => { - // inlined object runtype for perf - if (typeof v !== 'object' || Array.isArray(v) || v === null) { - return createFail(failOrThrow, 'expected an object', v) - } - - const o: any = v + const typemapValues = [...Object.values(typemap)] as InternalRuntype[] - // optimize allocations: only create a copy if the record is impure - const res = isPure ? o : {} + // a nonStrict record may ignore keys and so cannot be pure + const isPure = !isNonStrict && isPureTypemap(typemapValues) - for (let i = 0; i < typemapKeys.length; i++) { - const k = typemapKeys[i] - const t = typemapValues[i] as InternalRuntype + return setupInternalRuntype( + (v, failOrThrow) => { + // inlined object runtype for perf + if (typeof v !== 'object' || Array.isArray(v) || v === null) { + return createFail(failOrThrow, 'expected an object', v) + } - // nested types should always fail with explicit `Fail` so we can add additional data - const value = t(o[k], failSymbol) + const o: any = v - if (isFail(value)) { - if (!(k in o)) { - // rt failed because o[k] was undefined bc. the key was missing from o - // use a more specific error message in this case - return createFail( - failOrThrow, - `missing key in record: ${debugValue(k)}`, - ) - } + // optimize allocations: only create a copy if the record is impure + const res = isPure ? o : {} - return propagateFail(failOrThrow, value, v, k) - } + for (let i = 0; i < typemapKeys.length; i++) { + const k = typemapKeys[i] + const t = typemapValues[i] as InternalRuntype - if (!isPure) { - res[k] = value - } - } + // nested types should always fail with explicit `Fail` so we can add additional data + const value = t(o[k], failSymbol) - if (!isNonStrict) { - const unknownKeys: string[] = [] + if (isFail(value)) { + if (!(k in o)) { + // rt failed because o[k] was undefined bc. the key was missing from o + // use a more specific error message in this case + return createFail( + failOrThrow, + `missing key in record: ${debugValue(k)}`, + ) + } - for (const k in o) { - if (!Object.prototype.hasOwnProperty.call(typemap, k)) { - unknownKeys.push(k) + return propagateFail(failOrThrow, value, v, k) } - } - if (unknownKeys.length) { - return createFail( - failOrThrow, - `invalid keys in record: ${debugValue(unknownKeys)}`, - v, - ) + if (!isPure) { + res[k] = value + } } - } - - return res - }, isPure) - - // fields metadata to implement combinators like (discriminated) unions, - // pick, omit and intersection - const fields: { [key: string]: any } = {} - - for (const k in typemap) { - fields[k] = typemap[k] - } - - // eslint-disable-next-line no-extra-semi - ;(rt as any).fields = fields - - if (isNonStrict) { - // eslint-disable-next-line no-extra-semi - ;(rt as any).isNonStrict = isNonStrictRuntypeSymbol - } - return rt -} + if (!isNonStrict) { + const unknownKeys: string[] = [] -export function getRecordFields( - r: Runtype, -): { [key: string]: Runtype } | undefined { - const anyRt: any = r + for (const k in o) { + if (!Object.prototype.hasOwnProperty.call(typemap, k)) { + unknownKeys.push(k) + } + } - if (!anyRt.fields) { - return - } + if (unknownKeys.length) { + return createFail( + failOrThrow, + `invalid keys in record: ${debugValue(unknownKeys)}`, + v, + ) + } + } - return anyRt.fields + return res + }, + { + isPure, + fields: typemap, + isNonStrict, + }, + ) } /** diff --git a/src/runtype.ts b/src/runtype.ts index 55bdf19..efcb626 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,92 @@ export function propagateFail( } /** - * Runtype + * Check whether a returned value is a failure + */ +export function isFail(v: unknown): v is Fail { + if (typeof v !== 'object' || !v) { + return false + } + + return (v as any)[failSymbol] +} + +/** + * Internals of a runtype. * - * Just a function. The returned value may be a copy of v, depending on the - * runtypes implementation. + * Used to implement combinators (pick, omit, intersection, ...) and + * optimizations. */ -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 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?: InternalRuntype[] + + // literal value used to identify tagged union members + literal?: string | boolean | number } /** - * Special runtype for use in record definitions to mark optional keys. + * 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) */ -export interface OptionalRuntype { - isOptionalRuntype: true - (v: unknown): T +export interface InternalRuntype { + (v: unknown, failOrThrow?: typeof failSymbol): T | Fail + meta?: RuntypeMetadata } -export type Unpack = T extends Runtype - ? U - : T extends OptionalRuntype - ? V +export type InternalRuntypeOf = T extends Runtype + ? InternalRuntype : 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', - ) - } -} - /** - * A pure runtype does not change its value. - * - * A non-pure runtype may return a changed value. - * This is used to get rid of redundant object copying + * Cast to an internal runtype. */ -export function isPureRuntype(fn: Runtype): boolean { - return !!(fn as any).isPure +export function getInternalRuntype(rt: Runtype): InternalRuntype { + return rt as InternalRuntype } -export type InternalRuntype = ( - v: unknown, - failOrThrow: typeof failSymbol | undefined, -) => any - /** - * 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 runtype metadata. + * + * Not all runtypes have them set (e.g. user-defined ones). + */ +export function getRuntypeMetadata(rt: Runtype): RuntypeMetadata { + return (rt as any)?.meta || {} } - -export const isNonStrictRuntypeSymbol = Symbol('isNonStrict') diff --git a/src/string.ts b/src/string.ts index e9022fc..cf3420b 100644 --- a/src/string.ts +++ b/src/string.ts @@ -1,19 +1,22 @@ import { createFail, InternalRuntype, - internalRuntype, isFail, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' -const stringRuntype = internalRuntype((v, failOrThrow) => { - if (typeof v === 'string') { - return v - } +const stringRuntype = setupInternalRuntype( + (v, failOrThrow) => { + if (typeof v === 'string') { + return v + } - return createFail(failOrThrow, 'expected a string', v) -}, true) + return createFail(failOrThrow, 'expected a string', v) + }, + { isPure: true }, +) /** * A string. @@ -39,33 +42,40 @@ export function string(options?: { const isPure = !trim // trim modifies the string - return internalRuntype((v, failOrThrow) => { - const s: string = (stringRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype( + (v, failOrThrow) => { + const s: string = (stringRuntype as InternalRuntype)(v, failOrThrow) - if (isFail(s)) { - return propagateFail(failOrThrow, s, v) - } + if (isFail(s)) { + return propagateFail(failOrThrow, s, v) + } - if (minLength !== undefined && s.length < minLength) { - return createFail( - failOrThrow, - `expected the string length to be at least ${minLength}`, - v, - ) - } + if (minLength !== undefined && s.length < minLength) { + return createFail( + failOrThrow, + `expected the string length to be at least ${minLength}`, + v, + ) + } - if (maxLength !== undefined && s.length > maxLength) { - return createFail( - failOrThrow, - `expected the string length to not exceed ${maxLength}`, - v, - ) - } + if (maxLength !== undefined && s.length > maxLength) { + return createFail( + failOrThrow, + `expected the string length to not exceed ${maxLength}`, + v, + ) + } - if (match !== undefined && !match.test(s)) { - return createFail(failOrThrow, `expected the string to match ${match}`, v) - } + if (match !== undefined && !match.test(s)) { + return createFail( + failOrThrow, + `expected the string to match ${match}`, + v, + ) + } - return trim ? s.trim() : s - }, isPure) + return trim ? s.trim() : s + }, + { isPure }, + ) } diff --git a/src/stringAsInteger.ts b/src/stringAsInteger.ts index 9bf2903..cd822b6 100644 --- a/src/stringAsInteger.ts +++ b/src/stringAsInteger.ts @@ -3,17 +3,17 @@ import { createFail, failSymbol, InternalRuntype, - internalRuntype, isFail, propagateFail, Runtype, + setupInternalRuntype, } from './runtype' -export const stringAsIntegerRuntype = internalRuntype( +export const stringAsIntegerRuntype = setupInternalRuntype( (v, failOrThrow) => { if (typeof v === 'string') { const parsedNumber = parseInt(v, 10) - const n: number = (integerRuntype as InternalRuntype)( + const n: number = (integerRuntype as InternalRuntype)( parsedNumber, failSymbol, ) @@ -69,8 +69,8 @@ export function stringAsInteger(options?: { const { min, max } = options - return internalRuntype((v, failOrThrow) => { - const n = (stringAsIntegerRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype((v, failOrThrow) => { + const n = (stringAsIntegerRuntype as InternalRuntype)(v, failOrThrow) if (isFail(n)) { return propagateFail(failOrThrow, n, v) diff --git a/src/stringLiteralUnion.ts b/src/stringLiteralUnion.ts index 145f24b..06603c1 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,11 +8,14 @@ export function stringLiteralUnion( ): Runtype { const valuesIndex = new Set(values) - return internalRuntype((v, failOrThrow) => { - if (typeof v !== 'string' || !valuesIndex.has(v)) { - return createFail(failOrThrow, `expected one of ${values}`, v) - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (typeof v !== 'string' || !valuesIndex.has(v)) { + return createFail(failOrThrow, `expected one of ${values}`, v) + } - return v - }, true) + return v + }, + { isPure: true }, + ) } diff --git a/src/tuple.ts b/src/tuple.ts index 6a80e22..fe42b0b 100644 --- a/src/tuple.ts +++ b/src/tuple.ts @@ -2,13 +2,11 @@ import { createFail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, - isPureRuntype, propagateFail, Runtype, } from './runtype' - import { arrayRuntype } from './array' // TODO: find a simple (not type-computationally expensive) generic tuple definition. @@ -40,37 +38,41 @@ export function tuple( e: Runtype, ): Runtype<[A, B, C, D, E]> export function tuple(...types: Runtype[]): Runtype { - const isPure = types.every((t) => isPureRuntype(t)) + const itypes = types as InternalRuntype[] + const isPure = itypes.every((t) => t.meta?.isPure) - return internalRuntype((v, failOrThrow) => { - const a = (arrayRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype( + (v, failOrThrow) => { + const a = (arrayRuntype as InternalRuntype)(v, failOrThrow) - if (isFail(a)) { - return propagateFail(failOrThrow, a, v) - } + if (isFail(a)) { + return propagateFail(failOrThrow, a, v) + } - if (a.length !== types.length) { - return createFail( - failOrThrow, - 'tuple array does not have the required length', - v, - ) - } + if (a.length !== types.length) { + return createFail( + failOrThrow, + 'tuple array does not have the required length', + v, + ) + } - const res: any[] = isPure ? a : new Array(a.length) + const res: any[] = isPure ? a : new Array(a.length) - for (let i = 0; i < types.length; i++) { - const item = (types[i] as InternalRuntype)(a[i], failSymbol) + for (let i = 0; i < types.length; i++) { + const item = itypes[i](a[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/undefined.ts b/src/undefined.ts index 49d3ce5..747ec55 100644 --- a/src/undefined.ts +++ b/src/undefined.ts @@ -1,17 +1,20 @@ -import { createFail, internalRuntype, Runtype } from './runtype' +import { createFail, Runtype, setupInternalRuntype } from './runtype' /** * undefined */ // eslint-disable-next-line no-shadow-restricted-names function undefinedRuntype(): Runtype { - return internalRuntype((v, failOrThrow) => { - if (v !== undefined) { - return createFail(failOrThrow, 'expected undefined', v) - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v !== undefined) { + return createFail(failOrThrow, 'expected undefined', v) + } - return v - }, true) + return v + }, + { isPure: true }, + ) } export { undefinedRuntype as undefined } diff --git a/src/undefinedOr.ts b/src/undefinedOr.ts index 020740b..4df0f42 100644 --- a/src/undefinedOr.ts +++ b/src/undefinedOr.ts @@ -1,21 +1,19 @@ -import { - InternalRuntype, - internalRuntype, - isPureRuntype, - Runtype, -} from './runtype' +import { getInternalRuntype, Runtype, setupInternalRuntype } from './runtype' /** * Shortcut for a type or undefined. */ export function undefinedOr(t: Runtype): Runtype { - const isPure = isPureRuntype(t) + const ti = getInternalRuntype(t) - return internalRuntype((v, failOrThrow) => { - if (v === undefined) { - return undefined - } + return setupInternalRuntype( + (v, failOrThrow) => { + if (v === undefined) { + return undefined + } - return (t as InternalRuntype)(v, failOrThrow) - }, isPure) + return ti(v, failOrThrow) + }, + { isPure: ti.meta?.isPure }, + ) } diff --git a/src/union.ts b/src/union.ts index 5a11dc9..4819252 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,13 +1,11 @@ import { objectRuntype } from './object' -import { getRecordFields } from './record' import { createFail, Fail, failSymbol, InternalRuntype, - internalRuntype, + setupInternalRuntype, isFail, - isPureRuntype, propagateFail, Runtype, RuntypeUsageError, @@ -22,14 +20,14 @@ import { debugValue } from './runtypeError' // runtype check vs man in the naive union check implementation. function internalDiscriminatedUnion( key: string, - runtypes: Runtype[], -): Runtype { - const typeMap = new Map>() + runtypes: InternalRuntype[], +): InternalRuntype { + const typeMap = new Map>() // build an index for fast runtype lookups by literal - runtypes.forEach((t: any) => { - const rt = t.fields[key] - const tagValue = rt.literal + runtypes.forEach((t) => { + const rt = t.meta?.fields?.[key] + const tagValue = rt?.meta?.literal if (tagValue === undefined) { throw new RuntypeUsageError( @@ -56,48 +54,46 @@ function internalDiscriminatedUnion( typeMap.set(tagValue, t) }) - const isPure = runtypes.every((t) => isPureRuntype(t)) + const isPure = runtypes.every((t) => t.meta?.isPure) - const resultingRuntype = internalRuntype((v, failOrThrow) => { - const o: any = (objectRuntype as InternalRuntype)(v, failOrThrow) + return setupInternalRuntype( + (v, failOrThrow) => { + const o: any = (objectRuntype as InternalRuntype)(v, failOrThrow) - if (isFail(o)) { - return propagateFail(failOrThrow, o, v) - } - - const tagValue = o[key] - const rt = typeMap.get(tagValue) - - if (rt === undefined) { - return createFail( - failOrThrow, - `no Runtype found for discriminated union tag ${key}: ${debugValue( - tagValue, - )}`, - v, - ) - } - - return (rt as InternalRuntype)(v, failOrThrow) - }, isPure) + if (isFail(o)) { + return propagateFail(failOrThrow, o, v) + } - // keep the union runtypes around to implement combinators that need to distribute across unions like intersection - ;(resultingRuntype as any).unions = runtypes + const tagValue = o[key] + const rt = typeMap.get(tagValue) + + if (rt === undefined) { + return createFail( + failOrThrow, + `no Runtype found for discriminated union tag ${key}: ${debugValue( + tagValue, + )}`, + v, + ) + } - return resultingRuntype + return rt(v, failOrThrow) + }, + { isPure, unions: runtypes }, + ) } // given a list of runtypes, return the name of the key that acts as the // unique discriminating value across all runtypes // return undefined if no such key exists function findDiscriminatingUnionKey( - runtypes: Runtype[], + runtypes: InternalRuntype[], ): string | undefined { const commonKeys = new Map>() for (let i = 0; i < runtypes.length; i++) { const r = runtypes[i] - const fields = getRecordFields(r) + const fields = r.meta?.fields if (!fields) { // not a record runtype -> no common tag key @@ -161,23 +157,26 @@ export function union[]>( return internalDiscriminatedUnion(commonKey, runtypes) } - const isPure = runtypes.every((t) => isPureRuntype(t)) + const isPure = runtypes.every((t: InternalRuntype) => t.meta?.isPure) // simple union validation: try all runtypes and use the first one that // doesn't fail - return internalRuntype((v, failOrThrow) => { - let lastFail: Fail | undefined + return setupInternalRuntype( + (v, failOrThrow) => { + let lastFail: Fail | undefined - for (let i = 0; i < runtypes.length; i++) { - const val = (runtypes[i] as InternalRuntype)(v, failSymbol) + for (let i = 0; i < runtypes.length; i++) { + const val = (runtypes[i] as InternalRuntype)(v, failSymbol) - if (!isFail(val)) { - return val - } else { - lastFail = val + if (!isFail(val)) { + return val + } else { + lastFail = val + } } - } - return propagateFail(failOrThrow, lastFail as any, v) - }, isPure) + return propagateFail(failOrThrow, lastFail as any, v) + }, + { isPure, unions: runtypes }, + ) } diff --git a/src/unknown.ts b/src/unknown.ts index afa5b60..3f1fe10 100644 --- a/src/unknown.ts +++ b/src/unknown.ts @@ -1,10 +1,13 @@ -import { internalRuntype, Runtype } from './runtype' +import { setupInternalRuntype, Runtype } from './runtype' /** * A value to check later. */ export function unknown(): Runtype { - return internalRuntype((v) => { - return v - }, true) + return setupInternalRuntype( + (v) => { + return v + }, + { isPure: true }, + ) } diff --git a/test/helpers.ts b/test/helpers.ts index 37ca7d5..0728ae1 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,4 @@ import * as st from '../src' -import { isPureRuntypeSymbol } from '../src/runtype' // re-export so tests don't depend on the weird src directory export * as st from '../src' @@ -35,7 +34,7 @@ export function expectAcceptValuesPure( rt: st.Runtype, values: unknown[], ): void { - expect(rt).toHaveProperty('isPure', isPureRuntypeSymbol) + expect(rt).toHaveProperty('meta', expect.objectContaining({ isPure: true })) values.forEach((v) => { const result = st.use(rt, v)