@naturalcycles/nodejs-lib
Version:
Standard library for Node.js
1,676 lines (1,441 loc) • 66.6 kB
text/typescript
import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycles/js-lib'
import {
_isObject,
_isUndefined,
_numberEnumValues,
_stringEnumValues,
getEnumType,
} from '@naturalcycles/js-lib'
import { _uniq } from '@naturalcycles/js-lib/array'
import { _assert, _try } from '@naturalcycles/js-lib/error'
import type { Set2 } from '@naturalcycles/js-lib/object'
import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object'
import { _substringBefore } from '@naturalcycles/js-lib/string'
import type {
AnyObject,
BaseDBEntity,
IANATimezone,
Inclusiveness,
IsoDate,
IsoDateTime,
IsoMonth,
NumberEnum,
StringEnum,
StringMap,
UnixTimestamp,
UnixTimestampMillis,
} from '@naturalcycles/js-lib/types'
import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types'
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
import type { Ajv, ErrorObject } from 'ajv'
import { _inspect } from '../../string/inspect.js'
import {
BASE64URL_REGEX,
COUNTRY_CODE_REGEX,
CURRENCY_REGEX,
IPV4_REGEX,
IPV6_REGEX,
LANGUAGE_TAG_REGEX,
SEMVER_REGEX,
SLUG_REGEX,
URL_REGEX,
UUID_REGEX,
} from '../regexes.js'
import { TIMEZONES } from '../timezones.js'
import { AjvValidationError } from './ajvValidationError.js'
import { getAjv } from './getAjv.js'
import {
isEveryItemNumber,
isEveryItemPrimitive,
isEveryItemString,
JSON_SCHEMA_ORDER,
mergeJsonSchemaObjects,
} from './jsonSchemaBuilder.util.js'
// ==== j (factory object) ====
export const j = {
/**
* Matches literally any value - equivalent to TypeScript's `any` type.
* Use sparingly, as it bypasses type validation entirely.
*/
any(): JBuilder<any, false> {
return new JBuilder({})
},
string(): JString<string, false> {
return new JString()
},
number(): JNumber<number, false> {
return new JNumber()
},
boolean(): JBoolean<boolean, false> {
return new JBoolean()
},
object: Object.assign(object, {
dbEntity: objectDbEntity,
infer: objectInfer,
any() {
return j.object<AnyObject>({}).allowAdditionalProperties()
},
stringMap<S extends JSchema<any, any>>(schema: S): JObject<StringMap<SchemaOut<S>>> {
const isValueOptional = schema.getSchema().optionalField
const builtSchema = schema.build()
const finalValueSchema: JsonSchema = isValueOptional
? { anyOf: [{ isUndefined: true }, builtSchema] }
: builtSchema
return new JObject<StringMap<SchemaOut<S>>>(
{},
{
hasIsOfTypeCheck: false,
patternProperties: {
'^.+$': finalValueSchema,
},
},
)
},
/**
* @experimental Look around, maybe you find a rule that is better for your use-case.
*
* For Record<K, V> type of validations.
* ```ts
* const schema = j.object
* .record(
* j
* .string()
* .regex(/^\d{3,4}$/)
* .branded<B>(),
* j.number().nullable(),
* )
* .isOfType<Record<B, number | null>>()
* ```
*
* When the keys of the Record are values from an Enum, prefer `j.object.withEnumKeys`!
*
* Non-matching keys will be stripped from the object, i.e. they will not cause an error.
*
* Caveat: This rule first validates values of every properties of the object, and only then validates the keys.
* A consequence of that is that the validation will throw when there is an unexpected property with a value not matching the value schema.
*/
record,
/**
* For Record<ENUM, V> type of validations.
*
* When the keys of the Record are values from an Enum,
* this helper is more performant and behaves in a more conventional manner than `j.object.record` would.
*
*
*/
withEnumKeys,
withRegexKeys,
/**
* Validates that the value is an instance of the given class/constructor.
*
* ```ts
* j.object.instanceOf(Date) // typed as Date
* j.object.instanceOf(Date).optional() // typed as Date | undefined
* ```
*/
instanceOf<T>(ctor: new (...args: any[]) => T): JBuilder<T, false> {
return new JBuilder<T, false>({
type: 'object',
instanceof: ctor.name,
hasIsOfTypeCheck: true,
})
},
}),
array<OUT, Opt>(itemSchema: JSchema<OUT, Opt>): JArray<OUT, Opt> {
return new JArray(itemSchema)
},
tuple<const S extends JSchema<any, any>[]>(items: S): JTuple<S> {
return new JTuple<S>(items)
},
set<OUT, Opt>(itemSchema: JSchema<OUT, Opt>): JSet2Builder<OUT, Opt> {
return new JSet2Builder(itemSchema)
},
buffer(): JBuilder<Buffer, false> {
return new JBuilder<Buffer, false>({
Buffer: true,
})
},
enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
input: T,
opt?: JsonBuilderRuleOpt,
): JEnum<
T extends readonly (infer U)[]
? U
: T extends StringEnum
? T[keyof T]
: T extends NumberEnum
? T[keyof T]
: never
> {
let enumValues: readonly (string | number | boolean | null)[] | undefined
let baseType: EnumBaseType = 'other'
if (Array.isArray(input)) {
enumValues = input
if (isEveryItemNumber(input)) {
baseType = 'number'
} else if (isEveryItemString(input)) {
baseType = 'string'
}
} else if (typeof input === 'object') {
const enumType = getEnumType(input)
if (enumType === 'NumberEnum') {
enumValues = _numberEnumValues(input as NumberEnum)
baseType = 'number'
} else if (enumType === 'StringEnum') {
enumValues = _stringEnumValues(input as StringEnum)
baseType = 'string'
}
}
_assert(enumValues, 'Unsupported enum input')
return new JEnum(enumValues as any, baseType, opt)
},
/**
* Use only with primitive values, otherwise this function will throw to avoid bugs.
* To validate objects, use `anyOfBy`.
*
* Our Ajv is configured to strip unexpected properties from objects,
* and since Ajv is mutating the input, this means that it cannot
* properly validate the same data over multiple schemas.
*
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
* Use `oneOf` when schemas are mutually exclusive.
*/
oneOf<B extends readonly JSchema<any, boolean>[]>(
items: [...B],
): JBuilder<BuilderOutUnion<B>, false> {
const schemas = items.map(b => b.build())
_assert(
schemas.every(hasNoObjectSchemas),
'Do not use `oneOf` validation with non-primitive types!',
)
return new JBuilder<BuilderOutUnion<B>, false>({
oneOf: schemas,
})
},
/**
* Use only with primitive values, otherwise this function will throw to avoid bugs.
* To validate objects, use `anyOfBy` or `anyOfThese`.
*
* Our Ajv is configured to strip unexpected properties from objects,
* and since Ajv is mutating the input, this means that it cannot
* properly validate the same data over multiple schemas.
*
* Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
* Use `oneOf` when schemas are mutually exclusive.
*/
anyOf<B extends readonly JSchema<any, boolean>[]>(
items: [...B],
): JBuilder<BuilderOutUnion<B>, false> {
const schemas = items.map(b => b.build())
_assert(
schemas.every(hasNoObjectSchemas),
'Do not use `anyOf` validation with non-primitive types!',
)
return new JBuilder<BuilderOutUnion<B>, false>({
anyOf: schemas,
})
},
/**
* Pick validation schema for an object based on the value of a specific property.
*
* ```
* const schemaMap = {
* true: successSchema,
* false: errorSchema
* }
*
* const schema = j.anyOfBy('success', schemaMap)
* ```
*/
anyOfBy<D extends Record<PropertyKey, JSchema<any, any>>>(
propertyName: string,
schemaDictionary: D,
): JBuilder<AnyOfByOut<D>, false> {
const builtSchemaDictionary: Record<string, JsonSchema> = {}
for (const [key, schema] of Object.entries(schemaDictionary)) {
builtSchemaDictionary[key] = schema.build()
}
return new JBuilder<AnyOfByOut<D>, false>({
type: 'object',
hasIsOfTypeCheck: true,
anyOfBy: {
propertyName,
schemaDictionary: builtSchemaDictionary,
},
})
},
/**
* Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
* This comes with a performance penalty, so do not use it where performance matters.
*
* ```
* const schema = j.anyOfThese([successSchema, errorSchema])
* ```
*/
anyOfThese<B extends readonly JSchema<any, boolean>[]>(
items: [...B],
): JBuilder<BuilderOutUnion<B>, false> {
return new JBuilder<BuilderOutUnion<B>, false>({
anyOfThese: items.map(b => b.build()),
})
},
and() {
return {
silentBob: () => {
throw new Error('...strike back!')
},
}
},
literal<const V extends string | number | boolean | null>(v: V) {
let baseType: EnumBaseType = 'other'
if (typeof v === 'string') baseType = 'string'
if (typeof v === 'number') baseType = 'number'
return new JEnum<V>([v], baseType)
},
/**
* Create a JSchema from a plain JsonSchema object.
* Useful when the schema is loaded from a JSON file or generated externally.
*
* Optionally accepts a custom Ajv instance and/or inputName for error messages.
*/
fromSchema<OUT>(
schema: JsonSchema<OUT>,
cfg?: { ajv?: Ajv; inputName?: string },
): JSchema<OUT, false> {
return new JSchema<OUT, false>(schema, cfg)
},
}
// ==== Symbol for caching compiled AjvSchema ====
export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
export type WithCachedAjvSchema<Base, OUT> = Base & {
[HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
}
// ==== JSchema (locked base) ====
/*
Notes for future reference
Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`?
A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`,
which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well.
With `Opt`, we can infer it as `{ foo?: string | undefined }`.
*/
export class JSchema<OUT, Opt>
implements StandardSchemaV1<unknown, OUT>, StandardJSONSchemaV1<unknown, OUT>
{
protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
protected schema: JsonSchema
private _cfg?: { ajv?: Ajv; inputName?: string }
constructor(schema: JsonSchema, cfg?: { ajv?: Ajv; inputName?: string }) {
this.schema = schema
this._cfg = cfg
}
private _builtSchema?: JsonSchema
private _compiledFns?: WeakMap<Ajv, any>
private _getBuiltSchema(): JsonSchema {
if (!this._builtSchema) {
const builtSchema = this.build()
if (this instanceof JBuilder) {
_assert(
builtSchema.type !== 'object' || builtSchema.hasIsOfTypeCheck,
'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
)
}
delete builtSchema.optionalField
this._builtSchema = builtSchema
}
return this._builtSchema
}
private _getCompiled(overrideAjv?: Ajv): { fn: any; builtSchema: JsonSchema } {
const builtSchema = this._getBuiltSchema()
const ajv = overrideAjv ?? this._cfg?.ajv ?? getAjv()
this._compiledFns ??= new WeakMap()
let fn = this._compiledFns.get(ajv)
if (!fn) {
fn = ajv.compile(builtSchema as any)
this._compiledFns.set(ajv, fn)
// Cache AjvSchema wrapper for HIDDEN_AJV_SCHEMA backward compat (default ajv only)
if (!overrideAjv) {
this[HIDDEN_AJV_SCHEMA] = AjvSchema._wrap<any>(builtSchema, fn)
}
}
return { fn, builtSchema }
}
getSchema(): JsonSchema {
return this.schema
}
/**
* @deprecated
* The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
*/
castAs<T>(): JSchema<T, Opt> {
return this as unknown as JSchema<T, Opt>
}
/**
* A helper function that takes a type parameter and compares it with the type inferred from the schema.
*
* When the type inferred from the schema differs from the passed-in type,
* the schema becomes unusable, by turning its type into `never`.
*/
isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any
}
/**
* Produces a "clean schema object" without methods.
* Same as if it would be JSON.stringified.
*/
build(): JsonSchema<OUT> {
_assert(
!(this.schema.optionalField && this.schema.default !== undefined),
'.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference',
)
const jsonSchema = _sortObject(
deepCopyPreservingFunctions(this.schema) as AnyObject,
JSON_SCHEMA_ORDER,
) as JsonSchema<OUT>
delete jsonSchema.optionalField
return jsonSchema
}
clone(): this {
const cloned = Object.create(Object.getPrototypeOf(this))
cloned.schema = deepCopyPreservingFunctions(this.schema)
cloned._cfg = this._cfg
return cloned
}
cloneAndUpdateSchema(schema: Partial<JsonSchema>): this {
const clone = this.clone()
_objectAssign(clone.schema, schema)
return clone
}
get ['~standard'](): StandardSchemaV1.Props<unknown, OUT> &
StandardJSONSchemaV1.Props<unknown, OUT> {
const value: StandardSchemaV1.Props<unknown, OUT> & StandardJSONSchemaV1.Props<unknown, OUT> = {
version: 1,
vendor: 'j',
validate: v => {
const [err, output] = this.getValidationResult(v)
if (err) {
// todo: make getValidationResult return issues with path, so we can pass the path here too
return { issues: [{ message: err.message }] }
}
return { value: output }
},
jsonSchema: {
input: () => this.build() as Record<string, unknown>,
output: () => this.build() as Record<string, unknown>,
},
}
Object.defineProperty(this, '~standard', { value })
return value
}
validate(input: unknown, opt?: AjvValidationOptions): OUT {
const [err, output] = this.getValidationResult(input, opt)
if (err) throw err
return output
}
isValid(input: unknown, opt?: AjvValidationOptions): boolean {
const [err] = this.getValidationResult(input, opt)
return !err
}
getValidationResult(
input: unknown,
opt: AjvValidationOptions = {},
): ValidationFunctionResult<OUT, AjvValidationError> {
const { fn, builtSchema } = this._getCompiled(opt.ajv)
const inputName =
this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined)
return executeValidation<OUT>(fn, builtSchema, input, opt, inputName)
}
getValidationFunction(
opt: AjvValidationOptions = {},
): ValidationFunction<OUT, AjvValidationError> {
return (input, opt2) => {
return this.getValidationResult(input, {
ajv: opt.ajv,
mutateInput: opt2?.mutateInput ?? opt.mutateInput,
inputName: opt2?.inputName ?? opt.inputName,
inputId: opt2?.inputId ?? opt.inputId,
})
}
}
/**
* Specify a function to be called after the normal validation is finished.
*
* This function will receive the validated, type-safe data, and you can use it
* to do further validations, e.g. conditional validations based on certain property values,
* or to do data modifications either by mutating the input or returning a new value.
*
* If you throw an error from this function, it will show up as an error in the validation.
*/
postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JSchema<OUT2, Opt> {
const clone = this.cloneAndUpdateSchema({
postValidation: fn,
})
return clone as unknown as JSchema<OUT2, Opt>
}
/**
* @experimental
*/
out!: OUT
opt!: Opt
/** Forces OUT to be invariant (prevents covariant subtype matching in object property constraints). */
declare protected _invariantOut: (x: OUT) => void
}
// ==== JBuilder (chainable base) ====
export class JBuilder<OUT, Opt> extends JSchema<OUT, Opt> {
protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
if (_isUndefined(errorMessage)) return
this.schema.errorMessages ||= {}
this.schema.errorMessages[ruleName] = errorMessage
}
/**
* @deprecated
* The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
*/
override castAs<T>(): JBuilder<T, Opt> {
return this as unknown as JBuilder<T, Opt>
}
$schema($schema: string): this {
return this.cloneAndUpdateSchema({ $schema })
}
$schemaDraft7(): this {
return this.$schema('http://json-schema.org/draft-07/schema#')
}
$id($id: string): this {
return this.cloneAndUpdateSchema({ $id })
}
title(title: string): this {
return this.cloneAndUpdateSchema({ title })
}
description(description: string): this {
return this.cloneAndUpdateSchema({ description })
}
deprecated(deprecated = true): this {
return this.cloneAndUpdateSchema({ deprecated })
}
type(type: string): this {
return this.cloneAndUpdateSchema({ type })
}
default(v: any): this {
return this.cloneAndUpdateSchema({ default: v })
}
instanceof(of: string): this {
return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
}
/**
* @param optionalValues List of values that should be considered/converted as `undefined`.
*
* This `optionalValues` feature only works when the current schema is nested in an object or array schema,
* due to how mutability works in Ajv.
*
* Make sure this `optional()` call is at the end of your call chain.
*
* When `null` is included in optionalValues, the return type becomes `JSchema`
* (no further chaining allowed) because the schema is wrapped in an anyOf structure.
*/
optional<T extends readonly (string | number | boolean | null)[] | undefined = undefined>(
optionalValues?: T,
): T extends readonly (string | number | boolean | null)[]
? JSchema<OUT | undefined, true>
: JBuilder<OUT | undefined, true> {
if (!optionalValues?.length) {
const clone = this.cloneAndUpdateSchema({ optionalField: true })
return clone as any
}
const builtSchema = this.build()
// When optionalValues is just [null], use a simple null-wrapping structure.
// If the schema already has anyOf with a null branch (from nullable()),
// inject optionalValues directly into it.
if (optionalValues.length === 1 && optionalValues[0] === null) {
if (builtSchema.anyOf) {
const nullBranch = builtSchema.anyOf.find(b => b.type === 'null')
if (nullBranch) {
nullBranch.optionalValues = [null]
return new JSchema({ ...builtSchema, optionalField: true }) as any
}
}
// Wrap with null type branch
return new JSchema({
anyOf: [{ type: 'null', optionalValues: [null] }, builtSchema],
optionalField: true,
}) as any
}
// General case: create anyOf with current schema + alternatives.
// Preserve the original type for Ajv strict mode (optionalValues keyword requires a type).
const alternativesSchema = j.enum(optionalValues).build()
const innerSchema: JsonSchema = {
...(builtSchema.type ? { type: builtSchema.type } : {}),
anyOf: [builtSchema, alternativesSchema],
optionalValues: [...optionalValues],
}
// When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
// so we must allow `null` values to be parsed by Ajv,
// but the typing should not reflect that.
if (optionalValues.includes(null)) {
return new JSchema({
anyOf: [{ type: 'null', optionalValues: [...optionalValues] }, innerSchema],
optionalField: true,
}) as any
}
return new JSchema({ ...innerSchema, optionalField: true }) as any
}
nullable(): JBuilder<OUT | null, Opt> {
return new JBuilder({
anyOf: [this.build(), { type: 'null' }],
})
}
/**
* Locks the given schema chain and no other modification can be done to it.
*/
final(): JSchema<OUT, Opt> {
return new JSchema<OUT, Opt>(this.schema)
}
/**
*
* @param validator A validator function that returns an error message or undefined.
*
* You may add multiple custom validators and they will be executed in the order you added them.
*/
custom<OUT2 = OUT>(validator: CustomValidatorFn): JBuilder<OUT2, Opt> {
const { customValidations = [] } = this.schema
return this.cloneAndUpdateSchema({
customValidations: [...customValidations, validator],
}) as unknown as JBuilder<OUT2, Opt>
}
/**
*
* @param converter A converter function that returns a new value.
*
* You may add multiple converters and they will be executed in the order you added them,
* each converter receiving the result from the previous one.
*
* This feature only works when the current schema is nested in an object or array schema,
* due to how mutability works in Ajv.
*/
convert<OUT2>(converter: CustomConverterFn<OUT2>): JBuilder<OUT2, Opt> {
const { customConversions = [] } = this.schema
return this.cloneAndUpdateSchema({
customConversions: [...customConversions, converter],
}) as unknown as JBuilder<OUT2, Opt>
}
}
// ==== Consts
const TS_2500 = 16725225600 // 2500-01-01
const TS_2500_MILLIS = TS_2500 * 1000
const TS_2000 = 946684800 // 2000-01-01
const TS_2000_MILLIS = TS_2000 * 1000
// ==== Type-specific builders ====
export class JString<
OUT extends string | undefined = string,
Opt extends boolean = false,
> extends JBuilder<OUT, Opt> {
constructor() {
super({
type: 'string',
})
}
regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
_assert(
!pattern.flags,
`Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
)
return this.pattern(pattern.source, opt)
}
pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
const clone = this.cloneAndUpdateSchema({ pattern })
if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
return clone
}
minLength(minLength: number): this {
return this.cloneAndUpdateSchema({ minLength })
}
maxLength(maxLength: number): this {
return this.cloneAndUpdateSchema({ maxLength })
}
length(exactLength: number): this
length(minLength: number, maxLength: number): this
length(minLengthOrExactLength: number, maxLength?: number): this {
const maxLengthActual = maxLength ?? minLengthOrExactLength
return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
}
email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
.trim()
.toLowerCase()
}
trim(): this {
return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
}
toLowerCase(): this {
return this.cloneAndUpdateSchema({
transform: { ...this.schema.transform, toLowerCase: true },
})
}
toUpperCase(): this {
return this.cloneAndUpdateSchema({
transform: { ...this.schema.transform, toUpperCase: true },
})
}
truncate(toLength: number): this {
return this.cloneAndUpdateSchema({
transform: { ...this.schema.transform, truncate: toLength },
})
}
branded<B extends string>(): JString<B, Opt> {
return this as unknown as JString<B, Opt>
}
/**
* Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
*
* All previous expectations in the schema chain are dropped - including `.optional()` -
* because this call effectively starts a new schema chain.
*/
isoDate(): JIsoDate {
return new JIsoDate()
}
isoDateTime(): JString<IsoDateTime, Opt> {
return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
}
isoMonth(): JBuilder<IsoMonth, false> {
return new JBuilder<IsoMonth, false>({
type: 'string',
IsoMonth: {},
})
}
/**
* Validates the string format to be JWT.
* Expects the JWT to be signed!
*/
jwt(): this {
return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
}
url(): this {
return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
}
ipv4(): this {
return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
}
ipv6(): this {
return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
}
slug(): this {
return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
}
semVer(): this {
return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
}
languageTag(): this {
return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
}
countryCode(): this {
return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
}
currency(): this {
return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
}
/**
* Validates that the input is a valid IANATimzone value.
*
* All previous expectations in the schema chain are dropped - including `.optional()` -
* because this call effectively starts a new schema chain as an `enum` validation.
*/
ianaTimezone(): JEnum<IANATimezone, false> {
// UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
}
base64Url(): this {
return this.regex(BASE64URL_REGEX, {
msg: 'contains characters not allowed in Base64 URL characterset',
})
}
uuid(): this {
return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
}
}
export interface JsonSchemaStringEmailOptions {
checkTLD: boolean
}
export class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoDate, Opt> {
constructor() {
super({
type: 'string',
IsoDate: {},
})
}
before(date: string): this {
return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
}
sameOrBefore(date: string): this {
return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
}
after(date: string): this {
return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
}
sameOrAfter(date: string): this {
return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
}
between(fromDate: string, toDate: string, incl: Inclusiveness): this {
let schemaPatch: Partial<JsonSchema> = {}
if (incl === '[)') {
schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
} else if (incl === '[]') {
schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
}
return this.cloneAndUpdateSchema(schemaPatch)
}
}
export interface JsonSchemaIsoDateOptions {
before?: string
sameOrBefore?: string
after?: string
sameOrAfter?: string
}
export interface JsonSchemaIsoMonthOptions {}
export class JNumber<
OUT extends number | undefined = number,
Opt extends boolean = false,
> extends JBuilder<OUT, Opt> {
constructor() {
super({
type: 'number',
})
}
integer(): this {
return this.cloneAndUpdateSchema({ type: 'integer' })
}
branded<B extends number>(): JNumber<B, Opt> {
return this as unknown as JNumber<B, Opt>
}
multipleOf(multipleOf: number): this {
return this.cloneAndUpdateSchema({ multipleOf })
}
min(minimum: number): this {
return this.cloneAndUpdateSchema({ minimum })
}
exclusiveMin(exclusiveMinimum: number): this {
return this.cloneAndUpdateSchema({ exclusiveMinimum })
}
max(maximum: number): this {
return this.cloneAndUpdateSchema({ maximum })
}
exclusiveMax(exclusiveMaximum: number): this {
return this.cloneAndUpdateSchema({ exclusiveMaximum })
}
lessThan(value: number): this {
return this.exclusiveMax(value)
}
lessThanOrEqual(value: number): this {
return this.max(value)
}
moreThan(value: number): this {
return this.exclusiveMin(value)
}
moreThanOrEqual(value: number): this {
return this.min(value)
}
equal(value: number): this {
return this.min(value).max(value)
}
range(minimum: number, maximum: number, incl: Inclusiveness): this {
if (incl === '[)') {
return this.moreThanOrEqual(minimum).lessThan(maximum)
}
return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
}
int32(): this {
const MIN_INT32 = -(2 ** 31)
const MAX_INT32 = 2 ** 31 - 1
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
const newMin = Math.max(MIN_INT32, currentMin)
const newMax = Math.min(MAX_INT32, currentMax)
return this.integer().min(newMin).max(newMax)
}
int64(): this {
const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
return this.integer().min(newMin).max(newMax)
}
float(): this {
return this
}
double(): this {
return this
}
unixTimestamp(): JNumber<UnixTimestamp, Opt> {
return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
}
unixTimestamp2000(): JNumber<UnixTimestamp, Opt> {
return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
}
unixTimestampMillis(): JNumber<UnixTimestampMillis, Opt> {
return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
}
unixTimestamp2000Millis(): JNumber<UnixTimestampMillis, Opt> {
return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
}
utcOffset(): this {
return this.integer()
.multipleOf(15)
.min(-12 * 60)
.max(14 * 60)
}
utcOffsetHour(): this {
return this.integer().min(-12).max(14)
}
/**
* Specify the precision of the floating point numbers by the number of digits after the ".".
* Excess digits will be cut-off when the current schema is nested in an object or array schema,
* due to how mutability works in Ajv.
*/
precision(numberOfDigits: number): this {
return this.cloneAndUpdateSchema({ precision: numberOfDigits })
}
}
export class JBoolean<
OUT extends boolean | undefined = boolean,
Opt extends boolean = false,
> extends JBuilder<OUT, Opt> {
constructor() {
super({
type: 'boolean',
})
}
}
export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends JBuilder<
OUT,
Opt
> {
constructor(props?: AnyObject, opt?: JObjectOpts) {
super({
type: 'object',
properties: {},
required: [],
additionalProperties: false,
hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
patternProperties: opt?.patternProperties ?? undefined,
keySchema: opt?.keySchema ?? undefined,
})
if (props) addPropertiesToSchema(this.schema, props)
}
/**
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
*/
allowAdditionalProperties(): this {
return this.cloneAndUpdateSchema({ additionalProperties: true })
}
extend<P extends Record<string, JSchema<any, any>>>(
props: P,
): JObject<
Expand<
Override<
OUT,
{
// required keys
[K in keyof P as P[K] extends JSchema<any, infer IsOpt>
? IsOpt extends true
? never
: K
: never]: P[K] extends JSchema<infer OUT2, any> ? OUT2 : never
} & {
// optional keys
[K in keyof P as P[K] extends JSchema<any, infer IsOpt>
? IsOpt extends true
? K
: never
: never]?: P[K] extends JSchema<infer OUT2, any> ? OUT2 : never
}
>
>,
false
> {
const newBuilder = new JObject()
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
const incomingSchemaBuilder = new JObject(props)
mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
return newBuilder as any
}
/**
* Concatenates another schema to the current schema.
*
* It expects you to use `isOfType<T>()` in the chain,
* otherwise the validation will throw. This is to ensure
* that the schemas you concatenated match the intended final type.
*/
concat<OUT2 extends AnyObject>(other: JObject<OUT2, any>): JObject<OUT & OUT2, false> {
const clone = this.clone()
mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
_objectAssign(clone.schema, { hasIsOfTypeCheck: false })
return clone as unknown as JObject<OUT & OUT2, false>
}
/**
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
*/
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type
dbEntity() {
return this.extend({
id: j.string(),
created: j.number().unixTimestamp2000(),
updated: j.number().unixTimestamp2000(),
})
}
minProperties(minProperties: number): this {
return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
}
maxProperties(maxProperties: number): this {
return this.cloneAndUpdateSchema({ maxProperties })
}
exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
const exclusiveProperties = this.schema.exclusiveProperties ?? []
return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
}
}
interface JObjectOpts {
hasIsOfTypeCheck?: false
patternProperties?: StringMap<JsonSchema<any>>
keySchema?: JsonSchema
}
export class JObjectInfer<
PROPS extends Record<string, JSchema<any, any>>,
Opt extends boolean = false,
> extends JBuilder<
Expand<
{
[K in keyof PROPS as PROPS[K] extends JSchema<any, infer IsOpt>
? IsOpt extends true
? never
: K
: never]: PROPS[K] extends JSchema<infer OUT, any> ? OUT : never
} & {
[K in keyof PROPS as PROPS[K] extends JSchema<any, infer IsOpt>
? IsOpt extends true
? K
: never
: never]?: PROPS[K] extends JSchema<infer OUT, any> ? OUT : never
}
>,
Opt
> {
constructor(props?: PROPS) {
super({
type: 'object',
properties: {},
required: [],
additionalProperties: false,
})
if (props) addPropertiesToSchema(this.schema, props)
}
/**
* When set, the validation will not strip away properties that are not specified explicitly in the schema.
*/
allowAdditionalProperties(): this {
return this.cloneAndUpdateSchema({ additionalProperties: true })
}
extend<NEW_PROPS extends Record<string, JSchema<any, any>>>(
props: NEW_PROPS,
): JObjectInfer<
{
[K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
? NEW_PROPS[K]
: K extends keyof PROPS
? PROPS[K]
: never
},
Opt
> {
const newBuilder = new JObjectInfer<PROPS, Opt>()
_objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
const incomingSchemaBuilder = new JObjectInfer<NEW_PROPS, false>(props)
mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
// This extend function is not type-safe as it is inferring,
// so even if the base schema was already type-checked,
// the new schema loses that quality.
_objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
return newBuilder as unknown as JObjectInfer<
{
[K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
? NEW_PROPS[K]
: K extends keyof PROPS
? PROPS[K]
: never
},
Opt
>
}
/**
* Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
*/
// oxlint-disable-next-line @typescript-eslint/explicit-function-return-type
dbEntity() {
return this.extend({
id: j.string(),
created: j.number().unixTimestamp2000(),
updated: j.number().unixTimestamp2000(),
})
}
}
export class JArray<OUT, Opt> extends JBuilder<OUT[], Opt> {
constructor(itemsSchema: JSchema<OUT, Opt>) {
super({
type: 'array',
items: itemsSchema.build(),
})
}
minLength(minItems: number): this {
return this.cloneAndUpdateSchema({ minItems })
}
maxLength(maxItems: number): this {
return this.cloneAndUpdateSchema({ maxItems })
}
length(exactLength: number): this
length(minItems: number, maxItems: number): this
length(minItemsOrExact: number, maxItems?: number): this {
const maxItemsActual = maxItems ?? minItemsOrExact
return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
}
exactLength(length: number): this {
return this.minLength(length).maxLength(length)
}
unique(): this {
return this.cloneAndUpdateSchema({ uniqueItems: true })
}
}
class JSet2Builder<OUT, Opt> extends JBuilder<Set2<OUT>, Opt> {
constructor(itemsSchema: JSchema<OUT, Opt>) {
super({
type: ['array', 'object'],
Set2: itemsSchema.build(),
})
}
min(minItems: number): this {
return this.cloneAndUpdateSchema({ minItems })
}
max(maxItems: number): this {
return this.cloneAndUpdateSchema({ maxItems })
}
}
export class JEnum<
OUT extends string | number | boolean | null,
Opt extends boolean = false,
> extends JBuilder<OUT, Opt> {
constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
const jsonSchema: JsonSchema = { enum: enumValues }
// Specifying the base type helps in cases when we ask Ajv to coerce the types.
// Having only the `enum` in the schema does not trigger a coercion in Ajv.
if (baseType === 'string') jsonSchema.type = 'string'
if (baseType === 'number') jsonSchema.type = 'number'
super(jsonSchema)
if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
if (opt?.msg) this.setErrorMessage('enum', opt.msg)
}
branded<B extends OUT>(): JEnum<B, Opt> {
return this as unknown as JEnum<B, Opt>
}
}
export class JTuple<ITEMS extends JSchema<any, any>[]> extends JBuilder<TupleOut<ITEMS>, false> {
constructor(items: ITEMS) {
super({
type: 'array',
prefixItems: items.map(i => i.build()),
minItems: items.length,
maxItems: items.length,
})
}
}
// ==== Standalone functions for j.object ====
function object(props: AnyObject): never
function object<OUT extends AnyObject>(
props: [keyof OUT] extends [never]
? Record<string, never>
: { [K in keyof Required<OUT>]-?: JSchema<OUT[K], any> },
): [keyof OUT] extends [never] ? never : JObject<OUT, false>
function object<OUT extends AnyObject>(props: {
[key in keyof OUT]: JSchema<OUT[key], any>
}): JObject<OUT, false> {
return new JObject<OUT, false>(props)
}
function objectInfer<P extends Record<string, JSchema<any, any>>>(
props: P,
): JObjectInfer<P, false> {
return new JObjectInfer<P, false>(props)
}
function objectDbEntity(props: AnyObject): never
function objectDbEntity<
OUT extends BaseDBEntity,
EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
keyof OUT,
keyof BaseDBEntity
>,
>(
props: {
// ✅ all non-system fields must be explicitly provided
[K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
} &
// ✅ if `id` differs, it's required
(ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
? { id?: BuilderFor<BaseDBEntity['id']> }
: { id: BuilderFor<OUT['id']> }) &
(ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
? { created?: BuilderFor<BaseDBEntity['created']> }
: { created: BuilderFor<OUT['created']> }) &
(ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
? { updated?: BuilderFor<BaseDBEntity['updated']> }
: { updated: BuilderFor<OUT['updated']> }),
): JObject<OUT, false>
function objectDbEntity(props: AnyObject): any {
return j.object({
id: j.string(),
created: j.number().unixTimestamp2000(),
updated: j.number().unixTimestamp2000(),
...props,
})
}
function record<
KS extends JSchema<any, any>,
VS extends JSchema<any, any>,
Opt extends boolean = SchemaOpt<VS>,
>(
keySchema: KS,
valueSchema: VS,
): SchemaOut<KS> extends string
? JObject<
Opt extends true
? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
: Record<SchemaOut<KS>, SchemaOut<VS>>,
false
>
: never {
const keyJsonSchema = keySchema.build()
_assert(
keyJsonSchema.type !== 'number' && keyJsonSchema.type !== 'integer',
'record() key schema must validate strings, not numbers. JSON object keys are always strings.',
)
// Check if value schema is optional before build() strips the optionalField flag
const isValueOptional = (valueSchema as JSchema<any, any>).getSchema().optionalField
const valueJsonSchema = valueSchema.build()
// When value schema is optional, wrap in anyOf to allow undefined values
const finalValueSchema: JsonSchema = isValueOptional
? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
: valueJsonSchema
return new JObject([], {
hasIsOfTypeCheck: false,
keySchema: keyJsonSchema,
patternProperties: {
['^.*$']: finalValueSchema,
},
}) as any
}
function withRegexKeys<S extends JSchema<any, any>>(
keyRegex: RegExp | string,
schema: S,
): JObject<StringMap<SchemaOut<S>>, false> {
if (keyRegex instanceof RegExp) {
_assert(
!keyRegex.flags,
`Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
)
}
const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
const jsonSchema = schema.build()
return new JObject<StringMap<SchemaOut<S>>, false>([], {
hasIsOfTypeCheck: false,
patternProperties: {
[pattern]: jsonSchema,
},
})
}
/**
* Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
*/
function withEnumKeys<
const T extends readonly (string | number)[] | StringEnum | NumberEnum,
S extends JSchema<any, any>,
K extends string | number = EnumKeyUnion<T>,
Opt extends boolean = SchemaOpt<S>,
>(
keys: T,
schema: S,
): JObject<Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> }, false> {
let enumValues: readonly (string | number)[] | undefined
if (Array.isArray(keys)) {
_assert(
isEveryItemPrimitive(keys),
'Every item in the key list should be string, number or symbol',
)
enumValues = keys
} else if (typeof keys === 'object') {
const enumType = getEnumType(keys)
_assert(
enumType === 'NumberEnum' || enumType === 'StringEnum',
'The key list should be StringEnum or NumberEnum',
)
if (enumType === 'NumberEnum') {
enumValues = _numberEnumValues(keys as NumberEnum)
} else if (enumType === 'StringEnum') {
enumValues = _stringEnumValues(keys as StringEnum)
}
}
_assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
const typedValues = enumValues as readonly K[]
const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
return new JObject<
Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
false
>(props, { hasIsOfTypeCheck: false })
}
// ==== AjvSchema compat wrapper ====
/**
* On creation - compiles ajv validation function.
* Provides convenient methods, error reporting, etc.
*/
export class AjvSchema<OUT> {
private constructor(
public schema: JsonSchema<OUT>,
cfg: Partial<AjvSchemaCfg> = {},
preCompiledFn?: any,
) {
this.cfg = {
lazy: false,
...cfg,
ajv: cfg.ajv || getAjv(),
// Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json")
inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
}
if (preCompiledFn) {
this._compiledFn = preCompiledFn
} else if (!cfg.lazy) {
this._getValidateFn() // compile eagerly
}
}
/**
* Shortcut for AjvSchema.create(schema, { lazy: true })
*/
static createLazy<OUT>(
schema: SchemaHandledByAjv<OUT>,
cfg?: Partial<AjvSchemaCfg>,
): AjvSchema<OUT> {
return AjvSchema.create(schema, {
lazy: true,
...cfg,
})
}
/**
* Conveniently allows to pass either JsonSchema or JSchema builder, or existing AjvSchema.
* If it's already an AjvSchema - it'll just return it without any processing.
* If it's a Builder - will call `build` before proceeding.
* Otherwise - will construct AjvSchema instance ready to be used.
*/
static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
if (schema instanceof AjvSchema) return schema
if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
}
let jsonSchema: JsonSchema<OUT>
if (schema instanceof JSchema) {
jsonSchema = schema.build()
AjvSchema.requireValidJsonSchema(jsonSchema)
} else {
jsonSchema = schema
}
// This is our own helper which marks a schema as optional
// in case it is going to be used in an object schema,
// where we need to mark the given property as not-required.
// But once all compilation is done, the presence of this field
// really upsets Ajv.
delete jsonSchema.optionalField
const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
AjvSchema.cacheAjvSchema(schema, ajvSchema)
return ajvSchema
}
/**
* Creates a minimal AjvSchema wrapper from a pre-compiled validate function.
* Used internally by JSchema to cache a compatible AjvSchema instance.
*/
static _wrap<OUT>(schema: JsonSchema<OUT>, compiledFn: any): AjvSchema<OUT> {
return new AjvSchema<OUT>(schema, {}, compiledFn)
}
static isSchemaWithCachedAjvSchema<Base, OUT>(
schema: Base,
): schema is WithCachedAjvSchema<Base, OUT> {
return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
}
static cacheAjvSchema<Base extends AnyObject, OUT>(
schema: Base,
ajvSchema: AjvSchema<OUT>,
): WithCachedAjvSchema<Base, OUT> {
return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
}
static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
return schema[HIDDEN_AJV_SCHEMA]
}
readonly cfg: AjvSchemaCfg
private _compiledFn: any
private _getValidateFn(): any {
if (!this._compiledFn) {
this._compiledFn = this.cfg.ajv.compile(this.schema as any)
}
return this._compiledFn
}
/**
* It returns the original object just for convenience.
*/
validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
const [err, output] = this.getValidationResult(input, opt)
if (err) throw err
return output
}
isValid(input: unknown, opt?: AjvValidationOptions): boolean {
const [err] = this.getValidationResult(input, opt)
return !err
}
getValidationResult(
input: unknown,
opt: AjvValidationOptions = {},
): ValidationFunctionResult<OUT, AjvValidationError> {
const fn = this._getValidateFn()
return executeValidation<OUT>(fn, this.schema, input, opt, this.cfg.inputName)
}
getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
return (input, opt) => {
return this.getValidationResult(input, {
mutateInput: opt?.mutateInput,
inputName: opt?.inputName,
inputId: opt?.inputId,
})
}
}
private static requireValidJsonSchema(schema: JsonSchema): void {
// For object schemas we require that it is type checked against an external type, e.g.:
// interface Foo { name: string }
// const schema = j.object({ name: j.string() }).ofType<Foo>()
_assert(
schema.type !== 'object' || schema.hasIsOfTypeCheck,
'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
)
}
}
// ==== Shared validation logic ====
const separator = '\n'
function executeValidation<OUT>(
fn: any,
builtSchema: JsonSchema,
input: unknown,
opt: AjvValidationOptions = {},
defaultInputName?: string,
): ValidationFunctionResult<OUT, AjvValidationError> {
const item =
opt.mutateInput !== false || typeof input !== 'object'
? input // mutate
: _deepCopy(input) // not mutate
let valid = fn(item) // mutates item, but not input
_typeCast<OUT>(item)
let output: OUT = item
if (valid && builtSchema.postValidation) {
const [err, result] = _try(() => builtSchema.postValidation!(output))
if (err) {
valid = false
fn.errors = [
{