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 { return new JBuilder({}) }, string(): JString { return new JString() }, number(): JNumber { return new JNumber() }, boolean(): JBoolean { return new JBoolean() }, object: Object.assign(object, { dbEntity: objectDbEntity, infer: objectInfer, any() { return j.object({}).allowAdditionalProperties() }, stringMap>(schema: S): JObject>> { const isValueOptional = schema.getSchema().optionalField const builtSchema = schema.build() const finalValueSchema: JsonSchema = isValueOptional ? { anyOf: [{ isUndefined: true }, builtSchema] } : builtSchema return new JObject>>( {}, { hasIsOfTypeCheck: false, patternProperties: { '^.+$': finalValueSchema, }, }, ) }, /** * @experimental Look around, maybe you find a rule that is better for your use-case. * * For Record type of validations. * ```ts * const schema = j.object * .record( * j * .string() * .regex(/^\d{3,4}$/) * .branded(), * j.number().nullable(), * ) * .isOfType>() * ``` * * 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 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(ctor: new (...args: any[]) => T): JBuilder { return new JBuilder({ type: 'object', instanceof: ctor.name, hasIsOfTypeCheck: true, }) }, }), array(itemSchema: JSchema): JArray { return new JArray(itemSchema) }, tuple[]>(items: S): JTuple { return new JTuple(items) }, set(itemSchema: JSchema): JSet2Builder { return new JSet2Builder(itemSchema) }, buffer(): JBuilder { return new JBuilder({ Buffer: true, }) }, enum( 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[]>( items: [...B], ): JBuilder, false> { const schemas = items.map(b => b.build()) _assert( schemas.every(hasNoObjectSchemas), 'Do not use `oneOf` validation with non-primitive types!', ) return new JBuilder, 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[]>( items: [...B], ): JBuilder, false> { const schemas = items.map(b => b.build()) _assert( schemas.every(hasNoObjectSchemas), 'Do not use `anyOf` validation with non-primitive types!', ) return new JBuilder, 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>>( propertyName: string, schemaDictionary: D, ): JBuilder, false> { const builtSchemaDictionary: Record = {} for (const [key, schema] of Object.entries(schemaDictionary)) { builtSchemaDictionary[key] = schema.build() } return new JBuilder, 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[]>( items: [...B], ): JBuilder, false> { return new JBuilder, false>({ anyOfThese: items.map(b => b.build()), }) }, and() { return { silentBob: () => { throw new Error('...strike back!') }, } }, literal(v: V) { let baseType: EnumBaseType = 'other' if (typeof v === 'string') baseType = 'string' if (typeof v === 'number') baseType = 'number' return new JEnum([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( schema: JsonSchema, cfg?: { ajv?: Ajv; inputName?: string }, ): JSchema { return new JSchema(schema, cfg) }, } // ==== Symbol for caching compiled AjvSchema ==== export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA') export type WithCachedAjvSchema = Base & { [HIDDEN_AJV_SCHEMA]: AjvSchema } // ==== 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 implements StandardSchemaV1, StandardJSONSchemaV1 { protected [HIDDEN_AJV_SCHEMA]: AjvSchema | 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 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(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(): JSchema { return this as unknown as JSchema } /** * 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(): ExactMatch 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 { _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 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): this { const clone = this.clone() _objectAssign(clone.schema, schema) return clone } get ['~standard'](): StandardSchemaV1.Props & StandardJSONSchemaV1.Props { const value: StandardSchemaV1.Props & StandardJSONSchemaV1.Props = { 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, output: () => this.build() as Record, }, } 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 { const { fn, builtSchema } = this._getCompiled(opt.ajv) const inputName = this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined) return executeValidation(fn, builtSchema, input, opt, inputName) } getValidationFunction( opt: AjvValidationOptions = {}, ): ValidationFunction { 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(fn: PostValidatonFn): JSchema { const clone = this.cloneAndUpdateSchema({ postValidation: fn, }) return clone as unknown as JSchema } /** * @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 extends JSchema { 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(): JBuilder { return this as unknown as JBuilder } $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( optionalValues?: T, ): T extends readonly (string | number | boolean | null)[] ? JSchema : JBuilder { 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 { return new JBuilder({ anyOf: [this.build(), { type: 'null' }], }) } /** * Locks the given schema chain and no other modification can be done to it. */ final(): JSchema { return new JSchema(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(validator: CustomValidatorFn): JBuilder { const { customValidations = [] } = this.schema return this.cloneAndUpdateSchema({ customValidations: [...customValidations, validator], }) as unknown as JBuilder } /** * * @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(converter: CustomConverterFn): JBuilder { const { customConversions = [] } = this.schema return this.cloneAndUpdateSchema({ customConversions: [...customConversions, converter], }) as unknown as JBuilder } } // ==== 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 { 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): 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(): JString { return this as unknown as JString } /** * 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 { return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded() } isoMonth(): JBuilder { return new JBuilder({ 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 { // 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() } 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 extends JBuilder { 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 = {} 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 { constructor() { super({ type: 'number', }) } integer(): this { return this.cloneAndUpdateSchema({ type: 'integer' }) } branded(): JNumber { return this as unknown as JNumber } 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 { return this.integer().min(0).max(TS_2500).branded() } unixTimestamp2000(): JNumber { return this.integer().min(TS_2000).max(TS_2500).branded() } unixTimestampMillis(): JNumber { return this.integer().min(0).max(TS_2500_MILLIS).branded() } unixTimestamp2000Millis(): JNumber { return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded() } 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 { constructor() { super({ type: 'boolean', }) } } export class JObject 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

>>( props: P, ): JObject< Expand< Override< OUT, { // required keys [K in keyof P as P[K] extends JSchema ? IsOpt extends true ? never : K : never]: P[K] extends JSchema ? OUT2 : never } & { // optional keys [K in keyof P as P[K] extends JSchema ? IsOpt extends true ? K : never : never]?: P[K] extends JSchema ? 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()` in the chain, * otherwise the validation will throw. This is to ensure * that the schemas you concatenated match the intended final type. */ concat(other: JObject): JObject { const clone = this.clone() mergeJsonSchemaObjects(clone.schema as any, other.schema as any) _objectAssign(clone.schema, { hasIsOfTypeCheck: false }) return clone as unknown as JObject } /** * 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> keySchema?: JsonSchema } export class JObjectInfer< PROPS extends Record>, Opt extends boolean = false, > extends JBuilder< Expand< { [K in keyof PROPS as PROPS[K] extends JSchema ? IsOpt extends true ? never : K : never]: PROPS[K] extends JSchema ? OUT : never } & { [K in keyof PROPS as PROPS[K] extends JSchema ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JSchema ? 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>>( 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() _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema)) const incomingSchemaBuilder = new JObjectInfer(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 extends JBuilder { constructor(itemsSchema: JSchema) { 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 extends JBuilder, Opt> { constructor(itemsSchema: JSchema) { 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 { 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(): JEnum { return this as unknown as JEnum } } export class JTuple[]> extends JBuilder, 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( props: [keyof OUT] extends [never] ? Record : { [K in keyof Required]-?: JSchema }, ): [keyof OUT] extends [never] ? never : JObject function object(props: { [key in keyof OUT]: JSchema }): JObject { return new JObject(props) } function objectInfer

>>( props: P, ): JObjectInfer { return new JObjectInfer(props) } function objectDbEntity(props: AnyObject): never function objectDbEntity< OUT extends BaseDBEntity, EXTRA_KEYS extends Exclude = Exclude< keyof OUT, keyof BaseDBEntity >, >( props: { // ✅ all non-system fields must be explicitly provided [K in EXTRA_KEYS]-?: BuilderFor } & // ✅ if `id` differs, it's required (ExactMatch extends true ? { id?: BuilderFor } : { id: BuilderFor }) & (ExactMatch extends true ? { created?: BuilderFor } : { created: BuilderFor }) & (ExactMatch extends true ? { updated?: BuilderFor } : { updated: BuilderFor }), ): JObject 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, VS extends JSchema, Opt extends boolean = SchemaOpt, >( keySchema: KS, valueSchema: VS, ): SchemaOut extends string ? JObject< Opt extends true ? Partial, SchemaOut>> : Record, SchemaOut>, 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).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>( keyRegex: RegExp | string, schema: S, ): JObject>, 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>, 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, K extends string | number = EnumKeyUnion, Opt extends boolean = SchemaOpt, >( keys: T, schema: S, ): JObject } : { [P in K]: SchemaOut }, 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 } : { [P in K]: SchemaOut }, false >(props, { hasIsOfTypeCheck: false }) } // ==== AjvSchema compat wrapper ==== /** * On creation - compiles ajv validation function. * Provides convenient methods, error reporting, etc. */ export class AjvSchema { private constructor( public schema: JsonSchema, cfg: Partial = {}, 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( schema: SchemaHandledByAjv, cfg?: Partial, ): AjvSchema { 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(schema: SchemaHandledByAjv, cfg?: Partial): AjvSchema { if (schema instanceof AjvSchema) return schema if (AjvSchema.isSchemaWithCachedAjvSchema(schema)) { return AjvSchema.requireCachedAjvSchema(schema) } let jsonSchema: JsonSchema 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(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(schema: JsonSchema, compiledFn: any): AjvSchema { return new AjvSchema(schema, {}, compiledFn) } static isSchemaWithCachedAjvSchema( schema: Base, ): schema is WithCachedAjvSchema { return !!(schema as any)?.[HIDDEN_AJV_SCHEMA] } static cacheAjvSchema( schema: Base, ajvSchema: AjvSchema, ): WithCachedAjvSchema { return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema }) } static requireCachedAjvSchema(schema: WithCachedAjvSchema): AjvSchema { 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 { const fn = this._getValidateFn() return executeValidation(fn, this.schema, input, opt, this.cfg.inputName) } getValidationFunction(): ValidationFunction { 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() _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( fn: any, builtSchema: JsonSchema, input: unknown, opt: AjvValidationOptions = {}, defaultInputName?: string, ): ValidationFunctionResult { const item = opt.mutateInput !== false || typeof input !== 'object' ? input // mutate : _deepCopy(input) // not mutate let valid = fn(item) // mutates item, but not input _typeCast(item) let output: OUT = item if (valid && builtSchema.postValidation) { const [err, result] = _try(() => builtSchema.postValidation!(output)) if (err) { valid = false fn.errors = [ { instancePath: '', message: err.message, }, ] } else { output = result as OUT } } if (valid) return [null, output] const errors = fn.errors! const { inputId = _isObject(input) ? (input as any)['id'] : undefined, inputName = defaultInputName || 'Object', } = opt const dataVar = [inputName, inputId].filter(Boolean).join('.') applyImprovementsOnErrorMessages(errors, builtSchema) let message = getAjv().errorsText(errors, { dataVar, separator, }) // Note: if we mutated the input already, e.g stripped unknown properties, // the error message Input would contain already mutated object print, such as Input: {} // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness. const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 }) // fingerprint is captured before appending the dynamic Input snippet, // so we can group repeated validation errors by rule rather than by unique request content. const fingerprint = message message = [message, 'Input: ' + inputStringified].join(separator) const err = new AjvValidationError( message, _filterNullishValues({ errors, inputName, inputId, fingerprint, }), ) return [err, output] } // ==== Error formatting helpers ==== function applyImprovementsOnErrorMessages( errors: ErrorObject, unknown>[] | null | undefined, schema: JsonSchema, ): void { if (!errors) return filterNullableAnyOfErrors(errors, schema) const { errorMessages } = schema for (const error of errors) { const errorMessage = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword) if (errorMessage) { error.message = errorMessage } else if (errorMessages?.[error.keyword]) { error.message = errorMessages[error.keyword] } else { const unwrapped = unwrapNullableAnyOf(schema) if (unwrapped?.errorMessages?.[error.keyword]) { error.message = unwrapped.errorMessages[error.keyword] } } error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.') } } /** * Filters out noisy errors produced by nullable anyOf patterns. * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`, * AJV produces "must be null" and "must match a schema in anyOf" errors * that are confusing. This method splices them out, keeping only the real errors. */ function filterNullableAnyOfErrors( errors: ErrorObject, unknown>[], schema: JsonSchema, ): void { // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches) const exactPaths: string[] = [] const nullBranchPrefixes: string[] = [] for (const error of errors) { if (error.keyword !== 'anyOf') continue const parentSchema = resolveSchemaPath(schema, error.schemaPath) if (!parentSchema) continue const nullIndex = unwrapNullableAnyOfIndex(parentSchema) if (nullIndex === -1) continue exactPaths.push(error.schemaPath) // e.g. "#/anyOf" const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length) nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`) // e.g. "#/anyOf/1/" } if (!exactPaths.length) return for (let i = errors.length - 1; i >= 0; i--) { const sp = errors[i]!.schemaPath if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) { errors.splice(i, 1) } } } /** * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf") * and returns the parent schema containing the last keyword. */ function resolveSchemaPath(schema: JsonSchema, schemaPath: string): JsonSchema | undefined { // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf" // We want the schema that contains the final keyword (e.g. "anyOf") const segments = schemaPath.replace(/^#\//, '').split('/') // Remove the last segment (the keyword itself, e.g. "anyOf") segments.pop() let current: any = schema for (const segment of segments) { if (!current || typeof current !== 'object') return undefined current = current[segment] } return current as JsonSchema | undefined } function getErrorMessageForInstancePath( schema: JsonSchema | undefined, instancePath: string, keyword: string, ): string | undefined { if (!schema || !instancePath) return undefined const segments = instancePath.split('/').filter(Boolean) return traverseSchemaPath(schema, segments, keyword) } function traverseSchemaPath( schema: JsonSchema, segments: string[], keyword: string, ): string | undefined { if (!segments.length) return undefined const [currentSegment, ...remainingSegments] = segments const nextSchema = getChildSchema(schema, currentSegment) if (!nextSchema) return undefined if (nextSchema.errorMessages?.[keyword]) { return nextSchema.errorMessages[keyword] } // Check through nullable wrapper const unwrapped = unwrapNullableAnyOf(nextSchema) if (unwrapped?.errorMessages?.[keyword]) { return unwrapped.errorMessages[keyword] } if (remainingSegments.length) { return traverseSchemaPath(nextSchema, remainingSegments, keyword) } return undefined } function getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined { if (!segment) return undefined // Unwrap nullable anyOf to find properties/items through nullable wrappers const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema if (/^\d+$/.test(segment) && effectiveSchema.items) { return getArrayItemSchema(effectiveSchema, segment) } return getObjectPropertySchema(effectiveSchema, segment) } function getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined { if (!schema.items) return undefined if (Array.isArray(schema.items)) { return schema.items[Number(indexSegment)] } return schema.items } function getObjectPropertySchema(schema: JsonSchema, segment: string): JsonSchema | undefined { return schema.properties?.[segment as keyof typeof schema.properties] } function unwrapNullableAnyOf(schema: JsonSchema): JsonSchema | undefined { const nullIndex = unwrapNullableAnyOfIndex(schema) if (nullIndex === -1) return undefined return schema.anyOf![1 - nullIndex]! } function unwrapNullableAnyOfIndex(schema: JsonSchema): number { if (schema.anyOf?.length !== 2) return -1 const nullIndex = schema.anyOf.findIndex(s => s.type === 'null') return nullIndex } // ==== Utility helpers ==== function addPropertiesToSchema(schema: JsonSchema, props: AnyObject): void { const properties: Record = {} const required: string[] = [] for (const [key, builder] of Object.entries(props)) { const isOptional = (builder as JSchema).getSchema().optionalField if (!isOptional) { required.push(key) } const builtSchema = builder.build() properties[key] = builtSchema } schema.properties = properties schema.required = _uniq(required).sort() } function hasNoObjectSchemas(schema: JsonSchema): boolean { if (Array.isArray(schema.type)) { return schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type), ) } else if (schema.anyOf) { return schema.anyOf.every(hasNoObjectSchemas) } else if (schema.oneOf) { return schema.oneOf.every(hasNoObjectSchemas) } else if (schema.enum) { return true } else if (schema.type === 'array') { return !schema.items || hasNoObjectSchemas(schema.items) } else { return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type) } return false } type EnumBaseType = 'string' | 'number' | 'other' /** * Deep copy that preserves functions in customValidations/customConversions. * Unlike structuredClone, this handles function references (which only exist in those two properties). */ function deepCopyPreservingFunctions(obj: T): T { if (obj === null || typeof obj !== 'object') return obj if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T const copy = {} as T for (const key of Object.keys(obj)) { const value = (obj as any)[key] // customValidations/customConversions are arrays of functions - shallow copy the array ;(copy as any)[key] = (key === 'customValidations' || key === 'customConversions') && Array.isArray(value) ? [...value] : deepCopyPreservingFunctions(value) } return copy } // ==== Types & Interfaces ==== export interface AjvValidationOptions { /** * Custom Ajv instance to use for this validation. * Overrides the default Ajv or any Ajv set at construction time. * Compiled functions are cached per Ajv instance. */ ajv?: Ajv /** * Defaults to true, * because that's how AJV works by default, * and what gives it performance advantage. * (Because we have found that deep-clone is surprisingly slow, * nearly as slow as Joi validation). * * If set to true - AJV will mutate the input in case it needs to apply transformations * (strip unknown properties, convert types, etc). * * If false - it will deep-clone (using JSON.stringify+parse) the input to prevent its mutation. * Will return the cloned/mutated object. * Please note that JSON.stringify+parse has side-effects, * e.g it will transform Buffer into a weird object. */ mutateInput?: boolean inputName?: string inputId?: string /** * Function that returns "original input". * What is original input? * It's an input in its original non-mutated form. * Why is it needed? * Because we mutates the Input here. And after its been mutated - we no longer * can include it "how it was" in an error message. So, for that reason we'll use * `getOriginalInput()`, if it's provided. */ getOriginalInput?: () => unknown } export interface AjvSchemaCfg { /** * Pass Ajv instance, otherwise Ajv will be created with * AjvSchema default (not the same as Ajv defaults) parameters */ ajv: Ajv inputName?: string /** * If true - schema will be compiled on-demand (lazily). * Default: false. */ lazy?: boolean } export type SchemaHandledByAjv = JSchema | JsonSchema | AjvSchema export interface JsonSchema { readonly out?: OUT $schema?: string $id?: string title?: string description?: string deprecated?: boolean readOnly?: boolean writeOnly?: boolean type?: string | string[] items?: JsonSchema prefixItems?: JsonSchema[] properties?: { [K in keyof OUT]: JsonSchema } patternProperties?: StringMap> required?: string[] additionalProperties?: boolean minProperties?: number maxProperties?: number default?: OUT // https://json-schema.org/understanding-json-schema/reference/conditionals.html#id6 if?: JsonSchema then?: JsonSchema else?: JsonSchema anyOf?: JsonSchema[] oneOf?: JsonSchema[] /** * This is a temporary "intermediate AST" field that is used inside the parser. * In the final schema this field will NOT be present. */ optionalField?: true pattern?: string minLength?: number maxLength?: number format?: string contentMediaType?: string contentEncoding?: string // e.g 'base64' multipleOf?: number minimum?: number exclusiveMinimum?: number maximum?: number exclusiveMaximum?: number minItems?: number maxItems?: number uniqueItems?: boolean enum?: any hasIsOfTypeCheck?: boolean // Below we add custom Ajv keywords email?: JsonSchemaStringEmailOptions Set2?: JsonSchema Buffer?: true IsoDate?: JsonSchemaIsoDateOptions IsoDateTime?: true IsoMonth?: JsonSchemaIsoMonthOptions instanceof?: string | string[] transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number } errorMessages?: StringMap optionalValues?: (string | number | boolean | null)[] keySchema?: JsonSchema isUndefined?: true minProperties2?: number exclusiveProperties?: (readonly string[])[] anyOfBy?: { propertyName: string schemaDictionary: Record } anyOfThese?: JsonSchema[] precision?: number customValidations?: CustomValidatorFn[] customConversions?: CustomConverterFn[] postValidation?: PostValidatonFn } export type PostValidatonFn = (v: OUT) => OUT2 export type CustomValidatorFn = (v: any) => string | undefined export type CustomConverterFn = (v: any) => OUT type Expand = { [K in keyof T]: T[K] } type StripIndexSignatureDeep = T extends readonly unknown[] ? T : T extends Record ? { [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: StripIndexSignatureDeep } : T type RelaxIndexSignature = T extends readonly unknown[] ? T : T extends AnyObject ? { [K in keyof T]: RelaxIndexSignature } : T type Override = Omit & U declare const allowExtraKeysSymbol: unique symbol type HasAllowExtraKeys = T extends { readonly [allowExtraKeysSymbol]?: true } ? true : false type IsAny = 0 extends 1 & T ? true : false type IsAssignableRelaxed = IsAny> extends true ? true : [RelaxIndexSignature] extends [B] ? true : false type ExactMatchBase = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? (() => T extends B ? 1 : 2) extends () => T extends A ? 1 : 2 ? true : false : false type ExactMatch = HasAllowExtraKeys extends true ? IsAssignableRelaxed : ExactMatchBase, Expand> extends true ? true : ExactMatchBase< Expand>, Expand> > extends true ? true : // Fallback for types that are structurally identical but have different internal // representations (e.g. enum values from T[keyof T] vs direct enum references). // Keys must match exactly; then check mutual structural assignability. ExactMatchBase, keyof Expand> extends true ? [Expand] extends [Expand] ? [Expand] extends [Expand] ? true : false : false : false type BuilderOutUnion[]> = { [K in keyof B]: B[K] extends JSchema ? O : never }[number] type AnyOfByOut>> = { [K in keyof D]: D[K] extends JSchema ? O : never }[keyof D] type BuilderFor = JSchema export interface JsonBuilderRuleOpt { /** * Text of error message to return when the validation fails for the given rule: * * `{ msg: "is not a valid Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"` */ msg?: string /** * A friendly name for what we are validating, that will be used in error messages: * * `{ name: "Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"` */ name?: string } type EnumKeyUnion = // array of literals -> union of its elements (stringified) T extends readonly (infer U)[] ? `${U & (string | number)}` : // enum object -> union of its values (stringified) T extends StringEnum | NumberEnum ? `${T[keyof T] & (string | number)}` : never type SchemaOut = S extends JSchema ? OUT : never type SchemaOpt = S extends JSchema ? (Opt extends true ? true : false) : false type TupleOut[]> = { [K in keyof T]: T[K] extends JSchema ? O : never }