@naturalcycles/nodejs-lib 15.93.0 → 15.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,6 @@ import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycle
5
5
  import {
6
6
  _isObject,
7
7
  _isUndefined,
8
- _lazyValue,
9
8
  _numberEnumValues,
10
9
  _stringEnumValues,
11
10
  getEnumType,
@@ -55,469 +54,27 @@ import {
55
54
  mergeJsonSchemaObjects,
56
55
  } from './jsonSchemaBuilder.util.js'
57
56
 
58
- // ==== AJV =====
59
-
60
- /**
61
- * On creation - compiles ajv validation function.
62
- * Provides convenient methods, error reporting, etc.
63
- */
64
- export class AjvSchema<OUT> {
65
- private constructor(
66
- public schema: JsonSchema<OUT>,
67
- cfg: Partial<AjvSchemaCfg> = {},
68
- ) {
69
- this.cfg = {
70
- lazy: false,
71
- ...cfg,
72
- ajv: cfg.ajv || getAjv(),
73
- // Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json")
74
- inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
75
- }
76
-
77
- if (!cfg.lazy) {
78
- this.getAJVValidateFunction() // compile eagerly
79
- }
80
- }
81
-
82
- /**
83
- * Shortcut for AjvSchema.create(schema, { lazy: true })
84
- */
85
- static createLazy<OUT>(
86
- schema: SchemaHandledByAjv<OUT>,
87
- cfg?: Partial<AjvSchemaCfg>,
88
- ): AjvSchema<OUT> {
89
- return AjvSchema.create(schema, {
90
- lazy: true,
91
- ...cfg,
92
- })
93
- }
94
-
95
- /**
96
- * Conveniently allows to pass either JsonSchema or JsonSchemaBuilder, or existing AjvSchema.
97
- * If it's already an AjvSchema - it'll just return it without any processing.
98
- * If it's a Builder - will call `build` before proceeding.
99
- * Otherwise - will construct AjvSchema instance ready to be used.
100
- *
101
- * Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
102
- * correctly for some reason.
103
- */
104
- static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
105
- if (schema instanceof AjvSchema) return schema
106
-
107
- if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
108
- return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
109
- }
110
-
111
- let jsonSchema: JsonSchema<OUT>
112
-
113
- if (AjvSchema.isJsonSchemaBuilder(schema)) {
114
- // oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
115
- jsonSchema = (schema as JsonSchemaTerminal<OUT, any>).build()
116
- AjvSchema.requireValidJsonSchema(jsonSchema)
117
- } else {
118
- jsonSchema = schema
119
- }
120
-
121
- // This is our own helper which marks a schema as optional
122
- // in case it is going to be used in an object schema,
123
- // where we need to mark the given property as not-required.
124
- // But once all compilation is done, the presence of this field
125
- // really upsets Ajv.
126
- delete jsonSchema.optionalField
127
-
128
- const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
129
- AjvSchema.cacheAjvSchema(schema, ajvSchema)
130
-
131
- return ajvSchema
132
- }
133
-
134
- static isJsonSchemaBuilder<OUT>(schema: unknown): schema is JsonSchemaTerminal<OUT, any> {
135
- return schema instanceof JsonSchemaTerminal
136
- }
137
-
138
- readonly cfg: AjvSchemaCfg
139
-
140
- /**
141
- * It returns the original object just for convenience.
142
- * Reminder: Ajv will MUTATE your object under 2 circumstances:
143
- * 1. `useDefaults` option (enabled by default!), which will set missing/empty values that have `default` set in the schema.
144
- * 2. `coerceTypes` (false by default).
145
- *
146
- * Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
147
- */
148
- validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
149
- const [err, output] = this.getValidationResult(input, opt)
150
- if (err) throw err
151
- return output
152
- }
153
-
154
- isValid(input: unknown, opt?: AjvValidationOptions): boolean {
155
- // todo: we can make it both fast and non-mutating by using Ajv
156
- // with "removeAdditional" and "useDefaults" disabled.
157
- const [err] = this.getValidationResult(input, opt)
158
- return !err
159
- }
160
-
161
- getValidationResult(
162
- input: unknown,
163
- opt: AjvValidationOptions = {},
164
- ): ValidationFunctionResult<OUT, AjvValidationError> {
165
- const fn = this.getAJVValidateFunction()
166
-
167
- const item =
168
- opt.mutateInput !== false || typeof input !== 'object'
169
- ? input // mutate
170
- : _deepCopy(input) // not mutate
171
-
172
- let valid = fn(item) // mutates item, but not input
173
- _typeCast<OUT>(item)
174
-
175
- let output: OUT = item
176
- if (valid && this.schema.postValidation) {
177
- const [err, result] = _try(() => this.schema.postValidation!(output))
178
- if (err) {
179
- valid = false
180
- ;(fn as any).errors = [
181
- {
182
- instancePath: '',
183
- message: err.message,
184
- },
185
- ]
186
- } else {
187
- output = result
188
- }
189
- }
190
-
191
- if (valid) return [null, output]
192
-
193
- const errors = fn.errors!
194
-
195
- const {
196
- inputId = _isObject(input) ? (input as any)['id'] : undefined,
197
- inputName = this.cfg.inputName || 'Object',
198
- } = opt
199
- const dataVar = [inputName, inputId].filter(Boolean).join('.')
200
-
201
- this.applyImprovementsOnErrorMessages(errors)
202
-
203
- let message = this.cfg.ajv.errorsText(errors, {
204
- dataVar,
205
- separator,
206
- })
207
-
208
- // Note: if we mutated the input already, e.g stripped unknown properties,
209
- // the error message Input would contain already mutated object print, such as Input: {}
210
- // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness.
211
- const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 })
212
- message = [message, 'Input: ' + inputStringified].join(separator)
213
-
214
- const err = new AjvValidationError(
215
- message,
216
- _filterNullishValues({
217
- errors,
218
- inputName,
219
- inputId,
220
- }),
221
- )
222
- return [err, output]
223
- }
224
-
225
- getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
226
- return (input, opt) => {
227
- return this.getValidationResult(input, {
228
- mutateInput: opt?.mutateInput,
229
- inputName: opt?.inputName,
230
- inputId: opt?.inputId,
231
- })
232
- }
233
- }
234
-
235
- static isSchemaWithCachedAjvSchema<Base, OUT>(
236
- schema: Base,
237
- ): schema is WithCachedAjvSchema<Base, OUT> {
238
- return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
239
- }
240
-
241
- static cacheAjvSchema<Base extends AnyObject, OUT>(
242
- schema: Base,
243
- ajvSchema: AjvSchema<OUT>,
244
- ): WithCachedAjvSchema<Base, OUT> {
245
- return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
246
- }
247
-
248
- static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
249
- return schema[HIDDEN_AJV_SCHEMA]
250
- }
251
-
252
- private getAJVValidateFunction = _lazyValue(() => this.cfg.ajv.compile(this.schema as any))
253
-
254
- private static requireValidJsonSchema(schema: JsonSchema): void {
255
- // For object schemas we require that it is type checked against an external type, e.g.:
256
- // interface Foo { name: string }
257
- // const schema = j.object({ name: j.string() }).ofType<Foo>()
258
- _assert(
259
- schema.type !== 'object' || schema.hasIsOfTypeCheck,
260
- 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
261
- )
262
- }
263
-
264
- private applyImprovementsOnErrorMessages(
265
- errors: ErrorObject<string, Record<string, any>, unknown>[] | null | undefined,
266
- ): void {
267
- if (!errors) return
268
-
269
- this.filterNullableAnyOfErrors(errors)
270
-
271
- const { errorMessages } = this.schema
272
-
273
- for (const error of errors) {
274
- const errorMessage = this.getErrorMessageForInstancePath(
275
- this.schema,
276
- error.instancePath,
277
- error.keyword,
278
- )
279
-
280
- if (errorMessage) {
281
- error.message = errorMessage
282
- } else if (errorMessages?.[error.keyword]) {
283
- error.message = errorMessages[error.keyword]
284
- } else {
285
- const unwrapped = unwrapNullableAnyOf(this.schema)
286
- if (unwrapped?.errorMessages?.[error.keyword]) {
287
- error.message = unwrapped.errorMessages[error.keyword]
288
- }
289
- }
290
-
291
- error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
292
- }
293
- }
294
-
295
- /**
296
- * Filters out noisy errors produced by nullable anyOf patterns.
297
- * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
298
- * AJV produces "must be null" and "must match a schema in anyOf" errors
299
- * that are confusing. This method splices them out, keeping only the real errors.
300
- */
301
- private filterNullableAnyOfErrors(
302
- errors: ErrorObject<string, Record<string, any>, unknown>[],
303
- ): void {
304
- // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches)
305
- const exactPaths: string[] = []
306
- const nullBranchPrefixes: string[] = []
307
-
308
- for (const error of errors) {
309
- if (error.keyword !== 'anyOf') continue
310
-
311
- const parentSchema = this.resolveSchemaPath(error.schemaPath)
312
- if (!parentSchema) continue
313
-
314
- const nullIndex = unwrapNullableAnyOfIndex(parentSchema)
315
- if (nullIndex === -1) continue
316
-
317
- exactPaths.push(error.schemaPath) // e.g. "#/anyOf"
318
- const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length)
319
- nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`) // e.g. "#/anyOf/1/"
320
- }
321
-
322
- if (!exactPaths.length) return
323
-
324
- for (let i = errors.length - 1; i >= 0; i--) {
325
- const sp = errors[i]!.schemaPath
326
- if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) {
327
- errors.splice(i, 1)
328
- }
329
- }
330
- }
331
-
332
- /**
333
- * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
334
- * and returns the parent schema containing the last keyword.
335
- */
336
- private resolveSchemaPath(schemaPath: string): JsonSchema | undefined {
337
- // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf"
338
- // We want the schema that contains the final keyword (e.g. "anyOf")
339
- const segments = schemaPath.replace(/^#\//, '').split('/')
340
- // Remove the last segment (the keyword itself, e.g. "anyOf")
341
- segments.pop()
342
-
343
- let current: any = this.schema
344
- for (const segment of segments) {
345
- if (!current || typeof current !== 'object') return undefined
346
- current = current[segment]
347
- }
348
- return current as JsonSchema | undefined
349
- }
350
-
351
- private getErrorMessageForInstancePath(
352
- schema: JsonSchema<OUT> | undefined,
353
- instancePath: string,
354
- keyword: string,
355
- ): string | undefined {
356
- if (!schema || !instancePath) return undefined
357
-
358
- const segments = instancePath.split('/').filter(Boolean)
359
- return this.traverseSchemaPath(schema, segments, keyword)
360
- }
361
-
362
- private traverseSchemaPath<T>(
363
- schema: JsonSchema<T>,
364
- segments: string[],
365
- keyword: string,
366
- ): string | undefined {
367
- if (!segments.length) return undefined
368
-
369
- const [currentSegment, ...remainingSegments] = segments
370
-
371
- const nextSchema = this.getChildSchema(schema, currentSegment)
372
- if (!nextSchema) return undefined
373
-
374
- if (nextSchema.errorMessages?.[keyword]) {
375
- return nextSchema.errorMessages[keyword]
376
- }
377
-
378
- // Check through nullable wrapper
379
- const unwrapped = unwrapNullableAnyOf(nextSchema)
380
- if (unwrapped?.errorMessages?.[keyword]) {
381
- return unwrapped.errorMessages[keyword]
382
- }
383
-
384
- if (remainingSegments.length) {
385
- return this.traverseSchemaPath(nextSchema, remainingSegments, keyword)
386
- }
387
-
388
- return undefined
389
- }
390
-
391
- private getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined {
392
- if (!segment) return undefined
393
-
394
- // Unwrap nullable anyOf to find properties/items through nullable wrappers
395
- const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema
396
-
397
- if (/^\d+$/.test(segment) && effectiveSchema.items) {
398
- return this.getArrayItemSchema(effectiveSchema, segment)
399
- }
400
-
401
- return this.getObjectPropertySchema(effectiveSchema, segment)
402
- }
403
-
404
- private getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined {
405
- if (!schema.items) return undefined
406
-
407
- if (Array.isArray(schema.items)) {
408
- return schema.items[Number(indexSegment)]
409
- }
410
-
411
- return schema.items
412
- }
413
-
414
- private getObjectPropertySchema(schema: JsonSchema, segment: string): JsonSchema | undefined {
415
- return schema.properties?.[segment as keyof typeof schema.properties]
416
- }
417
- }
418
-
419
- function unwrapNullableAnyOf(schema: JsonSchema): JsonSchema | undefined {
420
- const nullIndex = unwrapNullableAnyOfIndex(schema)
421
- if (nullIndex === -1) return undefined
422
- return schema.anyOf![1 - nullIndex]!
423
- }
424
-
425
- function unwrapNullableAnyOfIndex(schema: JsonSchema): number {
426
- if (schema.anyOf?.length !== 2) return -1
427
- const nullIndex = schema.anyOf.findIndex(s => s.type === 'null')
428
- return nullIndex
429
- }
430
-
431
- const separator = '\n'
432
-
433
- export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
434
-
435
- export type WithCachedAjvSchema<Base, OUT> = Base & {
436
- [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
437
- }
438
-
439
- export interface AjvValidationOptions {
440
- /**
441
- * Defaults to true,
442
- * because that's how AJV works by default,
443
- * and what gives it performance advantage.
444
- * (Because we have found that deep-clone is surprisingly slow,
445
- * nearly as slow as Joi validation).
446
- *
447
- * If set to true - AJV will mutate the input in case it needs to apply transformations
448
- * (strip unknown properties, convert types, etc).
449
- *
450
- * If false - it will deep-clone (using JSON.stringify+parse) the input to prevent its mutation.
451
- * Will return the cloned/mutated object.
452
- * Please note that JSON.stringify+parse has side-effects,
453
- * e.g it will transform Buffer into a weird object.
454
- */
455
- mutateInput?: boolean
456
- inputName?: string
457
- inputId?: string
458
- /**
459
- * Function that returns "original input".
460
- * What is original input?
461
- * It's an input in its original non-mutated form.
462
- * Why is it needed?
463
- * Because we mutates the Input here. And after its been mutated - we no longer
464
- * can include it "how it was" in an error message. So, for that reason we'll use
465
- * `getOriginalInput()`, if it's provided.
466
- */
467
- getOriginalInput?: () => unknown
468
- }
469
-
470
- export interface AjvSchemaCfg {
471
- /**
472
- * Pass Ajv instance, otherwise Ajv will be created with
473
- * AjvSchema default (not the same as Ajv defaults) parameters
474
- */
475
- ajv: Ajv
476
-
477
- inputName?: string
478
-
479
- /**
480
- * Option of Ajv.
481
- * If set to true - will mutate your input objects!
482
- * Defaults to false.
483
- *
484
- * This option is a "shortcut" to skip creating and passing Ajv instance.
485
- */
486
- // coerceTypes?: boolean
487
-
488
- /**
489
- * If true - schema will be compiled on-demand (lazily).
490
- * Default: false.
491
- */
492
- lazy?: boolean
493
- }
494
-
495
- export type SchemaHandledByAjv<OUT> =
496
- | JsonSchemaTerminal<OUT, any>
497
- | JsonSchema<OUT>
498
- | AjvSchema<OUT>
499
-
500
- // ===== JsonSchemaBuilders ===== //
57
+ // ==== j (factory object) ====
501
58
 
502
59
  export const j = {
503
60
  /**
504
61
  * Matches literally any value - equivalent to TypeScript's `any` type.
505
62
  * Use sparingly, as it bypasses type validation entirely.
506
63
  */
507
- any(): JsonSchemaAnyBuilder<any, false> {
508
- return new JsonSchemaAnyBuilder({})
64
+ any(): JBuilder<any, false> {
65
+ return new JBuilder({})
509
66
  },
510
67
 
511
- string(): JsonSchemaStringBuilder<string, false> {
512
- return new JsonSchemaStringBuilder()
68
+ string(): JString<string, false> {
69
+ return new JString()
513
70
  },
514
71
 
515
- number(): JsonSchemaNumberBuilder<number, false> {
516
- return new JsonSchemaNumberBuilder()
72
+ number(): JNumber<number, false> {
73
+ return new JNumber()
517
74
  },
518
75
 
519
- boolean(): JsonSchemaBooleanBuilder<boolean, false> {
520
- return new JsonSchemaBooleanBuilder()
76
+ boolean(): JBoolean<boolean, false> {
77
+ return new JBoolean()
521
78
  },
522
79
 
523
80
  object: Object.assign(object, {
@@ -527,16 +84,14 @@ export const j = {
527
84
  return j.object<AnyObject>({}).allowAdditionalProperties()
528
85
  },
529
86
 
530
- stringMap<S extends JsonSchemaTerminal<any, any>>(
531
- schema: S,
532
- ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>> {
87
+ stringMap<S extends JSchema<any, any>>(schema: S): JObject<StringMap<SchemaOut<S>>> {
533
88
  const isValueOptional = schema.getSchema().optionalField
534
89
  const builtSchema = schema.build()
535
90
  const finalValueSchema: JsonSchema = isValueOptional
536
91
  ? { anyOf: [{ isUndefined: true }, builtSchema] }
537
92
  : builtSchema
538
93
 
539
- return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>>(
94
+ return new JObject<StringMap<SchemaOut<S>>>(
540
95
  {},
541
96
  {
542
97
  hasIsOfTypeCheck: false,
@@ -584,26 +139,28 @@ export const j = {
584
139
  withRegexKeys,
585
140
  }),
586
141
 
587
- array<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaArrayBuilder<OUT, Opt> {
588
- return new JsonSchemaArrayBuilder(itemSchema)
142
+ array<OUT, Opt>(itemSchema: JBuilder<OUT, Opt>): JArray<OUT, Opt> {
143
+ return new JArray(itemSchema)
589
144
  },
590
145
 
591
- tuple<const S extends JsonSchemaAnyBuilder<any, any>[]>(items: S): JsonSchemaTupleBuilder<S> {
592
- return new JsonSchemaTupleBuilder<S>(items)
146
+ tuple<const S extends JBuilder<any, any>[]>(items: S): JTuple<S> {
147
+ return new JTuple<S>(items)
593
148
  },
594
149
 
595
- set<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaSet2Builder<OUT, Opt> {
596
- return new JsonSchemaSet2Builder(itemSchema)
150
+ set<OUT, Opt>(itemSchema: JBuilder<OUT, Opt>): JSet2Builder<OUT, Opt> {
151
+ return new JSet2Builder(itemSchema)
597
152
  },
598
153
 
599
- buffer(): JsonSchemaBufferBuilder {
600
- return new JsonSchemaBufferBuilder()
154
+ buffer(): JBuilder<Buffer, false> {
155
+ return new JBuilder<Buffer, false>({
156
+ Buffer: true,
157
+ })
601
158
  },
602
159
 
603
160
  enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
604
161
  input: T,
605
162
  opt?: JsonBuilderRuleOpt,
606
- ): JsonSchemaEnumBuilder<
163
+ ): JEnum<
607
164
  T extends readonly (infer U)[]
608
165
  ? U
609
166
  : T extends StringEnum
@@ -634,7 +191,7 @@ export const j = {
634
191
  }
635
192
 
636
193
  _assert(enumValues, 'Unsupported enum input')
637
- return new JsonSchemaEnumBuilder(enumValues as any, baseType, opt)
194
+ return new JEnum(enumValues as any, baseType, opt)
638
195
  },
639
196
 
640
197
  /**
@@ -648,16 +205,16 @@ export const j = {
648
205
  * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
649
206
  * Use `oneOf` when schemas are mutually exclusive.
650
207
  */
651
- oneOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
208
+ oneOf<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
652
209
  items: [...B],
653
- ): JsonSchemaAnyBuilder<OUT, false> {
210
+ ): JBuilder<OUT, false> {
654
211
  const schemas = items.map(b => b.build())
655
212
  _assert(
656
213
  schemas.every(hasNoObjectSchemas),
657
214
  'Do not use `oneOf` validation with non-primitive types!',
658
215
  )
659
216
 
660
- return new JsonSchemaAnyBuilder<OUT, false>({
217
+ return new JBuilder<OUT, false>({
661
218
  oneOf: schemas,
662
219
  })
663
220
  },
@@ -673,16 +230,16 @@ export const j = {
673
230
  * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
674
231
  * Use `oneOf` when schemas are mutually exclusive.
675
232
  */
676
- anyOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
233
+ anyOf<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
677
234
  items: [...B],
678
- ): JsonSchemaAnyBuilder<OUT, false> {
235
+ ): JBuilder<OUT, false> {
679
236
  const schemas = items.map(b => b.build())
680
237
  _assert(
681
238
  schemas.every(hasNoObjectSchemas),
682
239
  'Do not use `anyOf` validation with non-primitive types!',
683
240
  )
684
241
 
685
- return new JsonSchemaAnyBuilder<OUT, false>({
242
+ return new JBuilder<OUT, false>({
686
243
  anyOf: schemas,
687
244
  })
688
245
  },
@@ -699,11 +256,23 @@ export const j = {
699
256
  * const schema = j.anyOfBy('success', schemaMap)
700
257
  * ```
701
258
  */
702
- anyOfBy<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>, OUT = AnyOfByOut<D>>(
259
+ anyOfBy<D extends Record<PropertyKey, JSchema<any, any>>, OUT = AnyOfByOut<D>>(
703
260
  propertyName: string,
704
261
  schemaDictionary: D,
705
- ): JsonSchemaAnyOfByBuilder<OUT> {
706
- return new JsonSchemaAnyOfByBuilder<OUT>(propertyName, schemaDictionary)
262
+ ): JBuilder<OUT, false> {
263
+ const builtSchemaDictionary: Record<string, JsonSchema> = {}
264
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
265
+ builtSchemaDictionary[key] = schema.build()
266
+ }
267
+
268
+ return new JBuilder<OUT, false>({
269
+ type: 'object',
270
+ hasIsOfTypeCheck: true,
271
+ anyOfBy: {
272
+ propertyName,
273
+ schemaDictionary: builtSchemaDictionary,
274
+ },
275
+ })
707
276
  },
708
277
 
709
278
  /**
@@ -714,10 +283,10 @@ export const j = {
714
283
  * const schema = j.anyOfThese([successSchema, errorSchema])
715
284
  * ```
716
285
  */
717
- anyOfThese<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
286
+ anyOfThese<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
718
287
  items: [...B],
719
- ): JsonSchemaAnyBuilder<OUT, false> {
720
- return new JsonSchemaAnyBuilder<OUT, false>({
288
+ ): JBuilder<OUT, false> {
289
+ return new JBuilder<OUT, false>({
721
290
  anyOfThese: items.map(b => b.build()),
722
291
  })
723
292
  },
@@ -734,14 +303,32 @@ export const j = {
734
303
  let baseType: EnumBaseType = 'other'
735
304
  if (typeof v === 'string') baseType = 'string'
736
305
  if (typeof v === 'number') baseType = 'number'
737
- return new JsonSchemaEnumBuilder<V>([v], baseType)
306
+ return new JEnum<V>([v], baseType)
307
+ },
308
+
309
+ /**
310
+ * Create a JSchema from a plain JsonSchema object.
311
+ * Useful when the schema is loaded from a JSON file or generated externally.
312
+ *
313
+ * Optionally accepts a custom Ajv instance and/or inputName for error messages.
314
+ */
315
+ fromSchema<OUT>(
316
+ schema: JsonSchema<OUT>,
317
+ cfg?: { ajv?: Ajv; inputName?: string },
318
+ ): JSchema<OUT, false> {
319
+ return new JSchema<OUT, false>(schema, cfg)
738
320
  },
739
321
  }
740
322
 
741
- const TS_2500 = 16725225600 // 2500-01-01
742
- const TS_2500_MILLIS = TS_2500 * 1000
743
- const TS_2000 = 946684800 // 2000-01-01
744
- const TS_2000_MILLIS = TS_2000 * 1000
323
+ // ==== Symbol for caching compiled AjvSchema ====
324
+
325
+ export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
326
+
327
+ export type WithCachedAjvSchema<Base, OUT> = Base & {
328
+ [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
329
+ }
330
+
331
+ // ==== JSchema (locked base) ====
745
332
 
746
333
  /*
747
334
  Notes for future reference
@@ -752,20 +339,54 @@ const TS_2000_MILLIS = TS_2000 * 1000
752
339
  With `Opt`, we can infer it as `{ foo?: string | undefined }`.
753
340
  */
754
341
 
755
- export class JsonSchemaTerminal<OUT, Opt> {
756
- protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
757
- protected schema: JsonSchema
342
+ export class JSchema<OUT, Opt> {
343
+ protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
344
+ protected schema: JsonSchema
345
+ private _cfg?: { ajv?: Ajv; inputName?: string }
346
+
347
+ constructor(schema: JsonSchema, cfg?: { ajv?: Ajv; inputName?: string }) {
348
+ this.schema = schema
349
+ this._cfg = cfg
350
+ }
351
+
352
+ private _builtSchema?: JsonSchema
353
+ private _compiledFns?: WeakMap<Ajv, any>
354
+
355
+ private _getBuiltSchema(): JsonSchema {
356
+ if (!this._builtSchema) {
357
+ const builtSchema = this.build()
358
+
359
+ if (this instanceof JBuilder) {
360
+ _assert(
361
+ builtSchema.type !== 'object' || builtSchema.hasIsOfTypeCheck,
362
+ 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
363
+ )
364
+ }
365
+
366
+ delete builtSchema.optionalField
367
+ this._builtSchema = builtSchema
368
+ }
758
369
 
759
- constructor(schema: JsonSchema) {
760
- this.schema = schema
370
+ return this._builtSchema
761
371
  }
762
372
 
763
- get ajvSchema(): AjvSchema<any> {
764
- if (!this[HIDDEN_AJV_SCHEMA]) {
765
- this[HIDDEN_AJV_SCHEMA] = AjvSchema.create<OUT>(this)
373
+ private _getCompiled(overrideAjv?: Ajv): { fn: any; builtSchema: JsonSchema } {
374
+ const builtSchema = this._getBuiltSchema()
375
+ const ajv = overrideAjv ?? this._cfg?.ajv ?? getAjv()
376
+
377
+ this._compiledFns ??= new WeakMap()
378
+ let fn = this._compiledFns.get(ajv)
379
+ if (!fn) {
380
+ fn = ajv.compile(builtSchema as any)
381
+ this._compiledFns.set(ajv, fn)
382
+
383
+ // Cache AjvSchema wrapper for HIDDEN_AJV_SCHEMA backward compat (default ajv only)
384
+ if (!overrideAjv) {
385
+ this[HIDDEN_AJV_SCHEMA] = AjvSchema._wrap<any>(builtSchema, fn)
386
+ }
766
387
  }
767
388
 
768
- return this[HIDDEN_AJV_SCHEMA]
389
+ return { fn, builtSchema }
769
390
  }
770
391
 
771
392
  getSchema(): JsonSchema {
@@ -795,6 +416,7 @@ export class JsonSchemaTerminal<OUT, Opt> {
795
416
  clone(): this {
796
417
  const cloned = Object.create(Object.getPrototypeOf(this))
797
418
  cloned.schema = deepCopyPreservingFunctions(this.schema)
419
+ cloned._cfg = this._cfg
798
420
  return cloned
799
421
  }
800
422
 
@@ -805,22 +427,37 @@ export class JsonSchemaTerminal<OUT, Opt> {
805
427
  }
806
428
 
807
429
  validate(input: unknown, opt?: AjvValidationOptions): OUT {
808
- return this.ajvSchema.validate(input, opt)
430
+ const [err, output] = this.getValidationResult(input, opt)
431
+ if (err) throw err
432
+ return output
809
433
  }
810
434
 
811
435
  isValid(input: unknown, opt?: AjvValidationOptions): boolean {
812
- return this.ajvSchema.isValid(input, opt)
436
+ const [err] = this.getValidationResult(input, opt)
437
+ return !err
813
438
  }
814
439
 
815
440
  getValidationResult(
816
441
  input: unknown,
817
442
  opt: AjvValidationOptions = {},
818
443
  ): ValidationFunctionResult<OUT, AjvValidationError> {
819
- return this.ajvSchema.getValidationResult(input, opt)
444
+ const { fn, builtSchema } = this._getCompiled(opt.ajv)
445
+ const inputName =
446
+ this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined)
447
+ return executeValidation<OUT>(fn, builtSchema, input, opt, inputName)
820
448
  }
821
449
 
822
- getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
823
- return this.ajvSchema.getValidationFunction()
450
+ getValidationFunction(
451
+ opt: AjvValidationOptions = {},
452
+ ): ValidationFunction<OUT, AjvValidationError> {
453
+ return (input, opt2) => {
454
+ return this.getValidationResult(input, {
455
+ ajv: opt.ajv,
456
+ mutateInput: opt2?.mutateInput ?? opt.mutateInput,
457
+ inputName: opt2?.inputName ?? opt.inputName,
458
+ inputId: opt2?.inputId ?? opt.inputId,
459
+ })
460
+ }
824
461
  }
825
462
 
826
463
  /**
@@ -832,11 +469,11 @@ export class JsonSchemaTerminal<OUT, Opt> {
832
469
  *
833
470
  * If you throw an error from this function, it will show up as an error in the validation.
834
471
  */
835
- postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JsonSchemaTerminal<OUT2, Opt> {
472
+ postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JSchema<OUT2, Opt> {
836
473
  const clone = this.cloneAndUpdateSchema({
837
474
  postValidation: fn,
838
475
  })
839
- return clone as unknown as JsonSchemaTerminal<OUT2, Opt>
476
+ return clone as unknown as JSchema<OUT2, Opt>
840
477
  }
841
478
 
842
479
  /**
@@ -846,7 +483,9 @@ export class JsonSchemaTerminal<OUT, Opt> {
846
483
  opt!: Opt
847
484
  }
848
485
 
849
- export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt> {
486
+ // ==== JBuilder (chainable base) ====
487
+
488
+ export class JBuilder<OUT, Opt> extends JSchema<OUT, Opt> {
850
489
  protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
851
490
  if (_isUndefined(errorMessage)) return
852
491
 
@@ -859,15 +498,6 @@ export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt>
859
498
  *
860
499
  * When the type inferred from the schema differs from the passed-in type,
861
500
  * the schema becomes unusable, by turning its type into `never`.
862
- *
863
- * ```ts
864
- * const schemaGood = j.string().isOfType<string>() // ✅
865
- *
866
- * const schemaBad = j.string().isOfType<number>() // ❌
867
- * schemaBad.build() // TypeError: property "build" does not exist on type "never"
868
- *
869
- * const result = ajvValidateRequest.body(req, schemaBad) // result will have `unknown` type
870
- * ```
871
501
  */
872
502
  isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
873
503
  return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any
@@ -901,78 +531,364 @@ export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt>
901
531
  return this.cloneAndUpdateSchema({ type })
902
532
  }
903
533
 
904
- default(v: any): this {
905
- return this.cloneAndUpdateSchema({ default: v })
534
+ default(v: any): this {
535
+ return this.cloneAndUpdateSchema({ default: v })
536
+ }
537
+
538
+ instanceof(of: string): this {
539
+ return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
540
+ }
541
+
542
+ optional(): JBuilder<OUT | undefined, true> {
543
+ const clone = this.cloneAndUpdateSchema({ optionalField: true })
544
+ return clone as unknown as JBuilder<OUT | undefined, true>
545
+ }
546
+
547
+ nullable(): JBuilder<OUT | null, Opt> {
548
+ return new JBuilder({
549
+ anyOf: [this.build(), { type: 'null' }],
550
+ })
551
+ }
552
+
553
+ /**
554
+ * @deprecated
555
+ * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
556
+ */
557
+ castAs<T>(): JBuilder<T, Opt> {
558
+ return this as unknown as JBuilder<T, Opt>
559
+ }
560
+
561
+ /**
562
+ * Locks the given schema chain and no other modification can be done to it.
563
+ */
564
+ final(): JSchema<OUT, Opt> {
565
+ return new JSchema<OUT, Opt>(this.schema)
566
+ }
567
+
568
+ /**
569
+ *
570
+ * @param validator A validator function that returns an error message or undefined.
571
+ *
572
+ * You may add multiple custom validators and they will be executed in the order you added them.
573
+ */
574
+ custom<OUT2 = OUT>(validator: CustomValidatorFn): JBuilder<OUT2, Opt> {
575
+ const { customValidations = [] } = this.schema
576
+ return this.cloneAndUpdateSchema({
577
+ customValidations: [...customValidations, validator],
578
+ }) as unknown as JBuilder<OUT2, Opt>
579
+ }
580
+
581
+ /**
582
+ *
583
+ * @param converter A converter function that returns a new value.
584
+ *
585
+ * You may add multiple converters and they will be executed in the order you added them,
586
+ * each converter receiving the result from the previous one.
587
+ *
588
+ * This feature only works when the current schema is nested in an object or array schema,
589
+ * due to how mutability works in Ajv.
590
+ */
591
+ convert<OUT2>(converter: CustomConverterFn<OUT2>): JBuilder<OUT2, Opt> {
592
+ const { customConversions = [] } = this.schema
593
+ return this.cloneAndUpdateSchema({
594
+ customConversions: [...customConversions, converter],
595
+ }) as unknown as JBuilder<OUT2, Opt>
596
+ }
597
+ }
598
+
599
+ // ==== Consts
600
+
601
+ const TS_2500 = 16725225600 // 2500-01-01
602
+ const TS_2500_MILLIS = TS_2500 * 1000
603
+ const TS_2000 = 946684800 // 2000-01-01
604
+ const TS_2000_MILLIS = TS_2000 * 1000
605
+
606
+ // ==== Type-specific builders ====
607
+
608
+ export class JString<
609
+ OUT extends string | undefined = string,
610
+ Opt extends boolean = false,
611
+ > extends JBuilder<OUT, Opt> {
612
+ constructor() {
613
+ super({
614
+ type: 'string',
615
+ })
616
+ }
617
+
618
+ /**
619
+ * @param optionalValues List of values that should be considered/converted as `undefined`.
620
+ *
621
+ * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
622
+ * due to how mutability works in Ajv.
623
+ *
624
+ * Make sure this `optional()` call is at the end of your call chain.
625
+ *
626
+ * When `null` is included in optionalValues, the return type becomes `JSchema`
627
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
628
+ */
629
+ override optional<T extends readonly (string | null)[] | undefined = undefined>(
630
+ optionalValues?: T,
631
+ ): T extends readonly (infer U)[]
632
+ ? null extends U
633
+ ? JSchema<OUT | undefined, true>
634
+ : JString<OUT | undefined, true>
635
+ : JString<OUT | undefined, true> {
636
+ if (!optionalValues) {
637
+ return super.optional() as any
638
+ }
639
+
640
+ _typeCast<(string | null)[]>(optionalValues)
641
+
642
+ let newBuilder: JSchema<OUT | undefined, true> = new JString<OUT, Opt>().optional()
643
+ const alternativesSchema = j.enum(optionalValues)
644
+ Object.assign(newBuilder.getSchema(), {
645
+ anyOf: [this.build(), alternativesSchema.build()],
646
+ optionalValues,
647
+ })
648
+
649
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
650
+ // so we must allow `null` values to be parsed by Ajv,
651
+ // but the typing should not reflect that.
652
+ // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
653
+ if (optionalValues.includes(null)) {
654
+ newBuilder = new JSchema({
655
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
656
+ optionalField: true,
657
+ })
658
+ }
659
+
660
+ return newBuilder as any
661
+ }
662
+
663
+ regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
664
+ _assert(
665
+ !pattern.flags,
666
+ `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
667
+ )
668
+ return this.pattern(pattern.source, opt)
669
+ }
670
+
671
+ pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
672
+ const clone = this.cloneAndUpdateSchema({ pattern })
673
+ if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
674
+ if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
675
+ return clone
676
+ }
677
+
678
+ minLength(minLength: number): this {
679
+ return this.cloneAndUpdateSchema({ minLength })
680
+ }
681
+
682
+ maxLength(maxLength: number): this {
683
+ return this.cloneAndUpdateSchema({ maxLength })
684
+ }
685
+
686
+ length(exactLength: number): this
687
+ length(minLength: number, maxLength: number): this
688
+ length(minLengthOrExactLength: number, maxLength?: number): this {
689
+ const maxLengthActual = maxLength ?? minLengthOrExactLength
690
+ return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
691
+ }
692
+
693
+ email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
694
+ const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
695
+ return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
696
+ .trim()
697
+ .toLowerCase()
698
+ }
699
+
700
+ trim(): this {
701
+ return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
702
+ }
703
+
704
+ toLowerCase(): this {
705
+ return this.cloneAndUpdateSchema({
706
+ transform: { ...this.schema.transform, toLowerCase: true },
707
+ })
708
+ }
709
+
710
+ toUpperCase(): this {
711
+ return this.cloneAndUpdateSchema({
712
+ transform: { ...this.schema.transform, toUpperCase: true },
713
+ })
714
+ }
715
+
716
+ truncate(toLength: number): this {
717
+ return this.cloneAndUpdateSchema({
718
+ transform: { ...this.schema.transform, truncate: toLength },
719
+ })
720
+ }
721
+
722
+ branded<B extends string>(): JString<B, Opt> {
723
+ return this as unknown as JString<B, Opt>
724
+ }
725
+
726
+ /**
727
+ * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
728
+ *
729
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
730
+ * because this call effectively starts a new schema chain.
731
+ */
732
+ isoDate(): JIsoDate {
733
+ return new JIsoDate()
734
+ }
735
+
736
+ isoDateTime(): JString<IsoDateTime, Opt> {
737
+ return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
738
+ }
739
+
740
+ isoMonth(): JBuilder<IsoMonth, false> {
741
+ return new JBuilder<IsoMonth, false>({
742
+ type: 'string',
743
+ IsoMonth: {},
744
+ })
745
+ }
746
+
747
+ /**
748
+ * Validates the string format to be JWT.
749
+ * Expects the JWT to be signed!
750
+ */
751
+ jwt(): this {
752
+ return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
753
+ }
754
+
755
+ url(): this {
756
+ return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
757
+ }
758
+
759
+ ipv4(): this {
760
+ return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
761
+ }
762
+
763
+ ipv6(): this {
764
+ return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
765
+ }
766
+
767
+ slug(): this {
768
+ return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
769
+ }
770
+
771
+ semVer(): this {
772
+ return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
773
+ }
774
+
775
+ languageTag(): this {
776
+ return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
777
+ }
778
+
779
+ countryCode(): this {
780
+ return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
781
+ }
782
+
783
+ currency(): this {
784
+ return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
785
+ }
786
+
787
+ /**
788
+ * Validates that the input is a valid IANATimzone value.
789
+ *
790
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
791
+ * because this call effectively starts a new schema chain as an `enum` validation.
792
+ */
793
+ ianaTimezone(): JEnum<IANATimezone, false> {
794
+ // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
795
+ return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
796
+ }
797
+
798
+ base64Url(): this {
799
+ return this.regex(BASE64URL_REGEX, {
800
+ msg: 'contains characters not allowed in Base64 URL characterset',
801
+ })
906
802
  }
907
803
 
908
- instanceof(of: string): this {
909
- return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
804
+ uuid(): this {
805
+ return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
910
806
  }
807
+ }
911
808
 
912
- optional(): JsonSchemaAnyBuilder<OUT | undefined, true> {
913
- const clone = this.cloneAndUpdateSchema({ optionalField: true })
914
- return clone as unknown as JsonSchemaAnyBuilder<OUT | undefined, true>
915
- }
809
+ export interface JsonSchemaStringEmailOptions {
810
+ checkTLD: boolean
811
+ }
916
812
 
917
- nullable(): JsonSchemaAnyBuilder<OUT | null, Opt> {
918
- return new JsonSchemaAnyBuilder({
919
- anyOf: [this.build(), { type: 'null' }],
813
+ export class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoDate, Opt> {
814
+ constructor() {
815
+ super({
816
+ type: 'string',
817
+ IsoDate: {},
920
818
  })
921
819
  }
922
820
 
923
821
  /**
924
- * @deprecated
925
- * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
822
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
823
+ *
824
+ * This `null` feature only works when the current schema is nested in an object or array schema,
825
+ * due to how mutability works in Ajv.
826
+ *
827
+ * When `null` is passed, the return type becomes `JSchema`
828
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
926
829
  */
927
- castAs<T>(): JsonSchemaAnyBuilder<T, Opt> {
928
- return this as unknown as JsonSchemaAnyBuilder<T, Opt>
830
+ override optional<N extends null | undefined = undefined>(
831
+ nullValue?: N,
832
+ ): N extends null ? JSchema<IsoDate | undefined, true> : JBuilder<IsoDate | undefined, true> {
833
+ if (nullValue === undefined) {
834
+ return super.optional() as any
835
+ }
836
+
837
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
838
+ // so we must allow `null` values to be parsed by Ajv,
839
+ // but the typing should not reflect that.
840
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
841
+ return new JSchema({
842
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
843
+ optionalField: true,
844
+ }) as any
929
845
  }
930
846
 
931
- /**
932
- * Locks the given schema chain and no other modification can be done to it.
933
- */
934
- final(): JsonSchemaTerminal<OUT, Opt> {
935
- return new JsonSchemaTerminal<OUT, Opt>(this.schema)
847
+ before(date: string): this {
848
+ return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
936
849
  }
937
850
 
938
- /**
939
- *
940
- * @param validator A validator function that returns an error message or undefined.
941
- *
942
- * You may add multiple custom validators and they will be executed in the order you added them.
943
- */
944
- custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<OUT2, Opt> {
945
- const { customValidations = [] } = this.schema
946
- return this.cloneAndUpdateSchema({
947
- customValidations: [...customValidations, validator],
948
- }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
851
+ sameOrBefore(date: string): this {
852
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
949
853
  }
950
854
 
951
- /**
952
- *
953
- * @param converter A converter function that returns a new value.
954
- *
955
- * You may add multiple converters and they will be executed in the order you added them,
956
- * each converter receiving the result from the previous one.
957
- *
958
- * This feature only works when the current schema is nested in an object or array schema,
959
- * due to how mutability works in Ajv.
960
- */
961
- convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<OUT2, Opt> {
962
- const { customConversions = [] } = this.schema
963
- return this.cloneAndUpdateSchema({
964
- customConversions: [...customConversions, converter],
965
- }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
855
+ after(date: string): this {
856
+ return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
857
+ }
858
+
859
+ sameOrAfter(date: string): this {
860
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
861
+ }
862
+
863
+ between(fromDate: string, toDate: string, incl: Inclusiveness): this {
864
+ let schemaPatch: Partial<JsonSchema> = {}
865
+
866
+ if (incl === '[)') {
867
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
868
+ } else if (incl === '[]') {
869
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
870
+ }
871
+
872
+ return this.cloneAndUpdateSchema(schemaPatch)
966
873
  }
967
874
  }
968
875
 
969
- export class JsonSchemaStringBuilder<
970
- OUT extends string | undefined = string,
876
+ export interface JsonSchemaIsoDateOptions {
877
+ before?: string
878
+ sameOrBefore?: string
879
+ after?: string
880
+ sameOrAfter?: string
881
+ }
882
+
883
+ export interface JsonSchemaIsoMonthOptions {}
884
+
885
+ export class JNumber<
886
+ OUT extends number | undefined = number,
971
887
  Opt extends boolean = false,
972
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
888
+ > extends JBuilder<OUT, Opt> {
973
889
  constructor() {
974
890
  super({
975
- type: 'string',
891
+ type: 'number',
976
892
  })
977
893
  }
978
894
 
@@ -984,26 +900,23 @@ export class JsonSchemaStringBuilder<
984
900
  *
985
901
  * Make sure this `optional()` call is at the end of your call chain.
986
902
  *
987
- * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
903
+ * When `null` is included in optionalValues, the return type becomes `JSchema`
988
904
  * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
989
905
  */
990
- override optional<T extends readonly (string | null)[] | undefined = undefined>(
906
+ override optional<T extends readonly (number | null)[] | undefined = undefined>(
991
907
  optionalValues?: T,
992
908
  ): T extends readonly (infer U)[]
993
909
  ? null extends U
994
- ? JsonSchemaTerminal<OUT | undefined, true>
995
- : JsonSchemaStringBuilder<OUT | undefined, true>
996
- : JsonSchemaStringBuilder<OUT | undefined, true> {
910
+ ? JSchema<OUT | undefined, true>
911
+ : JNumber<OUT | undefined, true>
912
+ : JNumber<OUT | undefined, true> {
997
913
  if (!optionalValues) {
998
914
  return super.optional() as any
999
915
  }
1000
916
 
1001
- _typeCast<(string | null)[]>(optionalValues)
917
+ _typeCast<(number | null)[]>(optionalValues)
1002
918
 
1003
- let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaStringBuilder<
1004
- OUT,
1005
- Opt
1006
- >().optional()
919
+ let newBuilder: JSchema<OUT | undefined, true> = new JNumber<OUT, Opt>().optional()
1007
920
  const alternativesSchema = j.enum(optionalValues)
1008
921
  Object.assign(newBuilder.getSchema(), {
1009
922
  anyOf: [this.build(), alternativesSchema.build()],
@@ -1013,9 +926,9 @@ export class JsonSchemaStringBuilder<
1013
926
  // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1014
927
  // so we must allow `null` values to be parsed by Ajv,
1015
928
  // but the typing should not reflect that.
1016
- // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
929
+ // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
1017
930
  if (optionalValues.includes(null)) {
1018
- newBuilder = new JsonSchemaTerminal({
931
+ newBuilder = new JSchema({
1019
932
  anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
1020
933
  optionalField: true,
1021
934
  })
@@ -1024,162 +937,318 @@ export class JsonSchemaStringBuilder<
1024
937
  return newBuilder as any
1025
938
  }
1026
939
 
1027
- regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
1028
- _assert(
1029
- !pattern.flags,
1030
- `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
1031
- )
1032
- return this.pattern(pattern.source, opt)
940
+ integer(): this {
941
+ return this.cloneAndUpdateSchema({ type: 'integer' })
1033
942
  }
1034
943
 
1035
- pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
1036
- const clone = this.cloneAndUpdateSchema({ pattern })
1037
- if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1038
- if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
1039
- return clone
944
+ branded<B extends number>(): JNumber<B, Opt> {
945
+ return this as unknown as JNumber<B, Opt>
1040
946
  }
1041
947
 
1042
- minLength(minLength: number): this {
1043
- return this.cloneAndUpdateSchema({ minLength })
948
+ multipleOf(multipleOf: number): this {
949
+ return this.cloneAndUpdateSchema({ multipleOf })
1044
950
  }
1045
951
 
1046
- maxLength(maxLength: number): this {
1047
- return this.cloneAndUpdateSchema({ maxLength })
952
+ min(minimum: number): this {
953
+ return this.cloneAndUpdateSchema({ minimum })
1048
954
  }
1049
955
 
1050
- length(exactLength: number): this
1051
- length(minLength: number, maxLength: number): this
1052
- length(minLengthOrExactLength: number, maxLength?: number): this {
1053
- const maxLengthActual = maxLength ?? minLengthOrExactLength
1054
- return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
956
+ exclusiveMin(exclusiveMinimum: number): this {
957
+ return this.cloneAndUpdateSchema({ exclusiveMinimum })
1055
958
  }
1056
959
 
1057
- email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
1058
- const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
1059
- return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
1060
- .trim()
1061
- .toLowerCase()
960
+ max(maximum: number): this {
961
+ return this.cloneAndUpdateSchema({ maximum })
1062
962
  }
1063
963
 
1064
- trim(): this {
1065
- return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
964
+ exclusiveMax(exclusiveMaximum: number): this {
965
+ return this.cloneAndUpdateSchema({ exclusiveMaximum })
1066
966
  }
1067
967
 
1068
- toLowerCase(): this {
1069
- return this.cloneAndUpdateSchema({
1070
- transform: { ...this.schema.transform, toLowerCase: true },
968
+ lessThan(value: number): this {
969
+ return this.exclusiveMax(value)
970
+ }
971
+
972
+ lessThanOrEqual(value: number): this {
973
+ return this.max(value)
974
+ }
975
+
976
+ moreThan(value: number): this {
977
+ return this.exclusiveMin(value)
978
+ }
979
+
980
+ moreThanOrEqual(value: number): this {
981
+ return this.min(value)
982
+ }
983
+
984
+ equal(value: number): this {
985
+ return this.min(value).max(value)
986
+ }
987
+
988
+ range(minimum: number, maximum: number, incl: Inclusiveness): this {
989
+ if (incl === '[)') {
990
+ return this.moreThanOrEqual(minimum).lessThan(maximum)
991
+ }
992
+ return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
993
+ }
994
+
995
+ int32(): this {
996
+ const MIN_INT32 = -(2 ** 31)
997
+ const MAX_INT32 = 2 ** 31 - 1
998
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
999
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1000
+ const newMin = Math.max(MIN_INT32, currentMin)
1001
+ const newMax = Math.min(MAX_INT32, currentMax)
1002
+ return this.integer().min(newMin).max(newMax)
1003
+ }
1004
+
1005
+ int64(): this {
1006
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1007
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1008
+ const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
1009
+ const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
1010
+ return this.integer().min(newMin).max(newMax)
1011
+ }
1012
+
1013
+ float(): this {
1014
+ return this
1015
+ }
1016
+
1017
+ double(): this {
1018
+ return this
1019
+ }
1020
+
1021
+ unixTimestamp(): JNumber<UnixTimestamp, Opt> {
1022
+ return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
1023
+ }
1024
+
1025
+ unixTimestamp2000(): JNumber<UnixTimestamp, Opt> {
1026
+ return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
1027
+ }
1028
+
1029
+ unixTimestampMillis(): JNumber<UnixTimestampMillis, Opt> {
1030
+ return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1031
+ }
1032
+
1033
+ unixTimestamp2000Millis(): JNumber<UnixTimestampMillis, Opt> {
1034
+ return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1035
+ }
1036
+
1037
+ utcOffset(): this {
1038
+ return this.integer()
1039
+ .multipleOf(15)
1040
+ .min(-12 * 60)
1041
+ .max(14 * 60)
1042
+ }
1043
+
1044
+ utcOffsetHour(): this {
1045
+ return this.integer().min(-12).max(14)
1046
+ }
1047
+
1048
+ /**
1049
+ * Specify the precision of the floating point numbers by the number of digits after the ".".
1050
+ * Excess digits will be cut-off when the current schema is nested in an object or array schema,
1051
+ * due to how mutability works in Ajv.
1052
+ */
1053
+ precision(numberOfDigits: number): this {
1054
+ return this.cloneAndUpdateSchema({ precision: numberOfDigits })
1055
+ }
1056
+ }
1057
+
1058
+ export class JBoolean<
1059
+ OUT extends boolean | undefined = boolean,
1060
+ Opt extends boolean = false,
1061
+ > extends JBuilder<OUT, Opt> {
1062
+ constructor() {
1063
+ super({
1064
+ type: 'boolean',
1071
1065
  })
1072
1066
  }
1073
1067
 
1074
- toUpperCase(): this {
1075
- return this.cloneAndUpdateSchema({
1076
- transform: { ...this.schema.transform, toUpperCase: true },
1068
+ /**
1069
+ * @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
1070
+ *
1071
+ * This `optionalValue` feature only works when the current schema is nested in an object or array schema,
1072
+ * due to how mutability works in Ajv.
1073
+ */
1074
+ override optional(optionalValue?: boolean): JBoolean<OUT | undefined, true> {
1075
+ if (typeof optionalValue === 'undefined') {
1076
+ return super.optional() as unknown as JBoolean<OUT | undefined, true>
1077
+ }
1078
+
1079
+ const newBuilder = new JBoolean<OUT, Opt>().optional()
1080
+ const alternativesSchema = j.enum([optionalValue])
1081
+ Object.assign(newBuilder.getSchema(), {
1082
+ anyOf: [this.build(), alternativesSchema.build()],
1083
+ optionalValues: [optionalValue],
1077
1084
  })
1085
+
1086
+ return newBuilder
1078
1087
  }
1088
+ }
1079
1089
 
1080
- truncate(toLength: number): this {
1081
- return this.cloneAndUpdateSchema({
1082
- transform: { ...this.schema.transform, truncate: toLength },
1090
+ export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends JBuilder<
1091
+ OUT,
1092
+ Opt
1093
+ > {
1094
+ constructor(props?: AnyObject, opt?: JObjectOpts) {
1095
+ super({
1096
+ type: 'object',
1097
+ properties: {},
1098
+ required: [],
1099
+ additionalProperties: false,
1100
+ hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1101
+ patternProperties: opt?.patternProperties ?? undefined,
1102
+ keySchema: opt?.keySchema ?? undefined,
1083
1103
  })
1084
- }
1085
1104
 
1086
- branded<B extends string>(): JsonSchemaStringBuilder<B, Opt> {
1087
- return this as unknown as JsonSchemaStringBuilder<B, Opt>
1105
+ if (props) addPropertiesToSchema(this.schema, props)
1088
1106
  }
1089
1107
 
1090
1108
  /**
1091
- * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
1109
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1092
1110
  *
1093
- * All previous expectations in the schema chain are dropped - including `.optional()` -
1094
- * because this call effectively starts a new schema chain.
1111
+ * This `null` feature only works when the current schema is nested in an object or array schema,
1112
+ * due to how mutability works in Ajv.
1113
+ *
1114
+ * When `null` is passed, the return type becomes `JSchema`
1115
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1095
1116
  */
1096
- isoDate(): JsonSchemaIsoDateBuilder {
1097
- return new JsonSchemaIsoDateBuilder()
1098
- }
1099
-
1100
- isoDateTime(): JsonSchemaStringBuilder<IsoDateTime, Opt> {
1101
- return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
1102
- }
1117
+ override optional<N extends null | undefined = undefined>(
1118
+ nullValue?: N,
1119
+ ): N extends null ? JSchema<OUT | undefined, true> : JBuilder<OUT | undefined, true> {
1120
+ if (nullValue === undefined) {
1121
+ return super.optional() as any
1122
+ }
1103
1123
 
1104
- isoMonth(): JsonSchemaIsoMonthBuilder {
1105
- return new JsonSchemaIsoMonthBuilder()
1124
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1125
+ // so we must allow `null` values to be parsed by Ajv,
1126
+ // but the typing should not reflect that.
1127
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1128
+ return new JSchema({
1129
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1130
+ optionalField: true,
1131
+ }) as any
1106
1132
  }
1107
1133
 
1108
1134
  /**
1109
- * Validates the string format to be JWT.
1110
- * Expects the JWT to be signed!
1135
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1111
1136
  */
1112
- jwt(): this {
1113
- return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
1114
- }
1115
-
1116
- url(): this {
1117
- return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
1118
- }
1119
-
1120
- ipv4(): this {
1121
- return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
1122
- }
1123
-
1124
- ipv6(): this {
1125
- return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
1126
- }
1127
1137
 
1128
- slug(): this {
1129
- return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
1138
+ allowAdditionalProperties(): this {
1139
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1130
1140
  }
1131
1141
 
1132
- semVer(): this {
1133
- return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
1134
- }
1142
+ extend<P extends Record<string, JBuilder<any, any>>>(
1143
+ props: P,
1144
+ ): JObject<
1145
+ Override<
1146
+ OUT,
1147
+ {
1148
+ // required keys
1149
+ [K in keyof P as P[K] extends JBuilder<any, infer IsOpt>
1150
+ ? IsOpt extends true
1151
+ ? never
1152
+ : K
1153
+ : never]: P[K] extends JBuilder<infer OUT2, any> ? OUT2 : never
1154
+ } & {
1155
+ // optional keys
1156
+ [K in keyof P as P[K] extends JBuilder<any, infer IsOpt>
1157
+ ? IsOpt extends true
1158
+ ? K
1159
+ : never
1160
+ : never]?: P[K] extends JBuilder<infer OUT2, any> ? OUT2 : never
1161
+ }
1162
+ >,
1163
+ false
1164
+ > {
1165
+ const newBuilder = new JObject()
1166
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1135
1167
 
1136
- languageTag(): this {
1137
- return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
1138
- }
1168
+ const incomingSchemaBuilder = new JObject(props)
1169
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1139
1170
 
1140
- countryCode(): this {
1141
- return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
1142
- }
1171
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1143
1172
 
1144
- currency(): this {
1145
- return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
1173
+ return newBuilder as any
1146
1174
  }
1147
1175
 
1148
1176
  /**
1149
- * Validates that the input is a valid IANATimzone value.
1177
+ * Concatenates another schema to the current schema.
1150
1178
  *
1151
- * All previous expectations in the schema chain are dropped - including `.optional()` -
1152
- * because this call effectively starts a new schema chain as an `enum` validation.
1179
+ * It expects you to use `isOfType<T>()` in the chain,
1180
+ * otherwise the validation will throw. This is to ensure
1181
+ * that the schemas you concatenated match the intended final type.
1153
1182
  */
1154
- ianaTimezone(): JsonSchemaEnumBuilder<IANATimezone, false> {
1155
- // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
1156
- return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
1183
+ concat<OUT2 extends AnyObject>(other: JObject<OUT2, any>): JObject<OUT & OUT2, false> {
1184
+ const clone = this.clone()
1185
+ mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
1186
+ _objectAssign(clone.schema, { hasIsOfTypeCheck: false })
1187
+ return clone as unknown as JObject<OUT & OUT2, false>
1157
1188
  }
1158
1189
 
1159
- base64Url(): this {
1160
- return this.regex(BASE64URL_REGEX, {
1161
- msg: 'contains characters not allowed in Base64 URL characterset',
1190
+ /**
1191
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1192
+ */
1193
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1194
+ dbEntity() {
1195
+ return this.extend({
1196
+ id: j.string(),
1197
+ created: j.number().unixTimestamp2000(),
1198
+ updated: j.number().unixTimestamp2000(),
1162
1199
  })
1163
1200
  }
1164
1201
 
1165
- uuid(): this {
1166
- return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
1202
+ minProperties(minProperties: number): this {
1203
+ return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
1204
+ }
1205
+
1206
+ maxProperties(maxProperties: number): this {
1207
+ return this.cloneAndUpdateSchema({ maxProperties })
1208
+ }
1209
+
1210
+ exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
1211
+ const exclusiveProperties = this.schema.exclusiveProperties ?? []
1212
+ return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
1167
1213
  }
1168
1214
  }
1169
1215
 
1170
- export interface JsonSchemaStringEmailOptions {
1171
- checkTLD: boolean
1216
+ interface JObjectOpts {
1217
+ hasIsOfTypeCheck?: false
1218
+ patternProperties?: StringMap<JsonSchema<any>>
1219
+ keySchema?: JsonSchema
1172
1220
  }
1173
1221
 
1174
- export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1175
- IsoDate,
1222
+ export class JObjectInfer<
1223
+ PROPS extends Record<string, JBuilder<any, any>>,
1224
+ Opt extends boolean = false,
1225
+ > extends JBuilder<
1226
+ Expand<
1227
+ {
1228
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1229
+ ? IsOpt extends true
1230
+ ? never
1231
+ : K
1232
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1233
+ } & {
1234
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1235
+ ? IsOpt extends true
1236
+ ? K
1237
+ : never
1238
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1239
+ }
1240
+ >,
1176
1241
  Opt
1177
1242
  > {
1178
- constructor() {
1243
+ constructor(props?: PROPS) {
1179
1244
  super({
1180
- type: 'string',
1181
- IsoDate: {},
1245
+ type: 'object',
1246
+ properties: {},
1247
+ required: [],
1248
+ additionalProperties: false,
1182
1249
  })
1250
+
1251
+ if (props) addPropertiesToSchema(this.schema, props)
1183
1252
  }
1184
1253
 
1185
1254
  /**
@@ -1188,14 +1257,51 @@ export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonS
1188
1257
  * This `null` feature only works when the current schema is nested in an object or array schema,
1189
1258
  * due to how mutability works in Ajv.
1190
1259
  *
1191
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1260
+ * When `null` is passed, the return type becomes `JSchema`
1192
1261
  * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1193
1262
  */
1263
+ // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1194
1264
  override optional<N extends null | undefined = undefined>(
1195
1265
  nullValue?: N,
1196
1266
  ): N extends null
1197
- ? JsonSchemaTerminal<IsoDate | undefined, true>
1198
- : JsonSchemaAnyBuilder<IsoDate | undefined, true> {
1267
+ ? JSchema<
1268
+ | Expand<
1269
+ {
1270
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1271
+ ? IsOpt extends true
1272
+ ? never
1273
+ : K
1274
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1275
+ } & {
1276
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1277
+ ? IsOpt extends true
1278
+ ? K
1279
+ : never
1280
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1281
+ }
1282
+ >
1283
+ | undefined,
1284
+ true
1285
+ >
1286
+ : JBuilder<
1287
+ | Expand<
1288
+ {
1289
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1290
+ ? IsOpt extends true
1291
+ ? never
1292
+ : K
1293
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1294
+ } & {
1295
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1296
+ ? IsOpt extends true
1297
+ ? K
1298
+ : never
1299
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1300
+ }
1301
+ >
1302
+ | undefined,
1303
+ true
1304
+ > {
1199
1305
  if (nullValue === undefined) {
1200
1306
  return super.optional() as any
1201
1307
  }
@@ -1204,745 +1310,811 @@ export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonS
1204
1310
  // so we must allow `null` values to be parsed by Ajv,
1205
1311
  // but the typing should not reflect that.
1206
1312
  // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1207
- return new JsonSchemaTerminal({
1313
+ return new JSchema({
1208
1314
  anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1209
1315
  optionalField: true,
1210
1316
  }) as any
1211
1317
  }
1212
1318
 
1213
- before(date: string): this {
1214
- return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
1319
+ /**
1320
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1321
+ */
1322
+
1323
+ allowAdditionalProperties(): this {
1324
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1325
+ }
1326
+
1327
+ extend<NEW_PROPS extends Record<string, JBuilder<any, any>>>(
1328
+ props: NEW_PROPS,
1329
+ ): JObjectInfer<
1330
+ {
1331
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1332
+ ? NEW_PROPS[K]
1333
+ : K extends keyof PROPS
1334
+ ? PROPS[K]
1335
+ : never
1336
+ },
1337
+ Opt
1338
+ > {
1339
+ const newBuilder = new JObjectInfer<PROPS, Opt>()
1340
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1341
+
1342
+ const incomingSchemaBuilder = new JObjectInfer<NEW_PROPS, false>(props)
1343
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1344
+
1345
+ // This extend function is not type-safe as it is inferring,
1346
+ // so even if the base schema was already type-checked,
1347
+ // the new schema loses that quality.
1348
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1349
+
1350
+ return newBuilder as unknown as JObjectInfer<
1351
+ {
1352
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1353
+ ? NEW_PROPS[K]
1354
+ : K extends keyof PROPS
1355
+ ? PROPS[K]
1356
+ : never
1357
+ },
1358
+ Opt
1359
+ >
1360
+ }
1361
+
1362
+ /**
1363
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1364
+ */
1365
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1366
+ dbEntity() {
1367
+ return this.extend({
1368
+ id: j.string(),
1369
+ created: j.number().unixTimestamp2000(),
1370
+ updated: j.number().unixTimestamp2000(),
1371
+ })
1372
+ }
1373
+ }
1374
+
1375
+ export class JArray<OUT, Opt> extends JBuilder<OUT[], Opt> {
1376
+ constructor(itemsSchema: JBuilder<OUT, Opt>) {
1377
+ super({
1378
+ type: 'array',
1379
+ items: itemsSchema.build(),
1380
+ })
1381
+ }
1382
+
1383
+ minLength(minItems: number): this {
1384
+ return this.cloneAndUpdateSchema({ minItems })
1385
+ }
1386
+
1387
+ maxLength(maxItems: number): this {
1388
+ return this.cloneAndUpdateSchema({ maxItems })
1389
+ }
1390
+
1391
+ length(exactLength: number): this
1392
+ length(minItems: number, maxItems: number): this
1393
+ length(minItemsOrExact: number, maxItems?: number): this {
1394
+ const maxItemsActual = maxItems ?? minItemsOrExact
1395
+ return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
1215
1396
  }
1216
1397
 
1217
- sameOrBefore(date: string): this {
1218
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
1398
+ exactLength(length: number): this {
1399
+ return this.minLength(length).maxLength(length)
1219
1400
  }
1220
1401
 
1221
- after(date: string): this {
1222
- return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
1402
+ unique(): this {
1403
+ return this.cloneAndUpdateSchema({ uniqueItems: true })
1223
1404
  }
1405
+ }
1224
1406
 
1225
- sameOrAfter(date: string): this {
1226
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
1407
+ class JSet2Builder<OUT, Opt> extends JBuilder<Set2<OUT>, Opt> {
1408
+ constructor(itemsSchema: JBuilder<OUT, Opt>) {
1409
+ super({
1410
+ type: ['array', 'object'],
1411
+ Set2: itemsSchema.build(),
1412
+ })
1227
1413
  }
1228
1414
 
1229
- between(fromDate: string, toDate: string, incl: Inclusiveness): this {
1230
- let schemaPatch: Partial<JsonSchema> = {}
1231
-
1232
- if (incl === '[)') {
1233
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
1234
- } else if (incl === '[]') {
1235
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
1236
- }
1415
+ min(minItems: number): this {
1416
+ return this.cloneAndUpdateSchema({ minItems })
1417
+ }
1237
1418
 
1238
- return this.cloneAndUpdateSchema(schemaPatch)
1419
+ max(maxItems: number): this {
1420
+ return this.cloneAndUpdateSchema({ maxItems })
1239
1421
  }
1240
1422
  }
1241
1423
 
1242
- export interface JsonSchemaIsoDateOptions {
1243
- before?: string
1244
- sameOrBefore?: string
1245
- after?: string
1246
- sameOrAfter?: string
1247
- }
1424
+ export class JEnum<
1425
+ OUT extends string | number | boolean | null,
1426
+ Opt extends boolean = false,
1427
+ > extends JBuilder<OUT, Opt> {
1428
+ constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
1429
+ const jsonSchema: JsonSchema = { enum: enumValues }
1430
+ // Specifying the base type helps in cases when we ask Ajv to coerce the types.
1431
+ // Having only the `enum` in the schema does not trigger a coercion in Ajv.
1432
+ if (baseType === 'string') jsonSchema.type = 'string'
1433
+ if (baseType === 'number') jsonSchema.type = 'number'
1248
1434
 
1249
- export class JsonSchemaIsoMonthBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1250
- IsoMonth,
1251
- Opt
1252
- > {
1253
- constructor() {
1254
- super({
1255
- type: 'string',
1256
- IsoMonth: {},
1257
- })
1435
+ super(jsonSchema)
1436
+
1437
+ if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1438
+ if (opt?.msg) this.setErrorMessage('enum', opt.msg)
1258
1439
  }
1259
- }
1260
1440
 
1261
- export interface JsonSchemaIsoMonthOptions {}
1441
+ branded<B extends OUT>(): JEnum<B, Opt> {
1442
+ return this as unknown as JEnum<B, Opt>
1443
+ }
1444
+ }
1262
1445
 
1263
- export class JsonSchemaNumberBuilder<
1264
- OUT extends number | undefined = number,
1265
- Opt extends boolean = false,
1266
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1267
- constructor() {
1446
+ export class JTuple<ITEMS extends JBuilder<any, any>[]> extends JBuilder<TupleOut<ITEMS>, false> {
1447
+ constructor(items: ITEMS) {
1268
1448
  super({
1269
- type: 'number',
1449
+ type: 'array',
1450
+ prefixItems: items.map(i => i.build()),
1451
+ minItems: items.length,
1452
+ maxItems: items.length,
1270
1453
  })
1271
1454
  }
1455
+ }
1272
1456
 
1273
- /**
1274
- * @param optionalValues List of values that should be considered/converted as `undefined`.
1275
- *
1276
- * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
1277
- * due to how mutability works in Ajv.
1278
- *
1279
- * Make sure this `optional()` call is at the end of your call chain.
1280
- *
1281
- * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
1282
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1283
- */
1284
- override optional<T extends readonly (number | null)[] | undefined = undefined>(
1285
- optionalValues?: T,
1286
- ): T extends readonly (infer U)[]
1287
- ? null extends U
1288
- ? JsonSchemaTerminal<OUT | undefined, true>
1289
- : JsonSchemaNumberBuilder<OUT | undefined, true>
1290
- : JsonSchemaNumberBuilder<OUT | undefined, true> {
1291
- if (!optionalValues) {
1292
- return super.optional() as any
1293
- }
1294
-
1295
- _typeCast<(number | null)[]>(optionalValues)
1457
+ // ==== Standalone functions for j.object ====
1296
1458
 
1297
- let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaNumberBuilder<
1298
- OUT,
1299
- Opt
1300
- >().optional()
1301
- const alternativesSchema = j.enum(optionalValues)
1302
- Object.assign(newBuilder.getSchema(), {
1303
- anyOf: [this.build(), alternativesSchema.build()],
1304
- optionalValues,
1305
- })
1459
+ function object(props: AnyObject): never
1460
+ function object<OUT extends AnyObject>(props: {
1461
+ [K in keyof Required<OUT>]-?: JSchema<OUT[K], any>
1462
+ }): JObject<OUT, false>
1306
1463
 
1307
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1308
- // so we must allow `null` values to be parsed by Ajv,
1309
- // but the typing should not reflect that.
1310
- // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
1311
- if (optionalValues.includes(null)) {
1312
- newBuilder = new JsonSchemaTerminal({
1313
- anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
1314
- optionalField: true,
1315
- })
1316
- }
1464
+ function object<OUT extends AnyObject>(props: {
1465
+ [key in keyof OUT]: JSchema<OUT[key], any>
1466
+ }): JObject<OUT, false> {
1467
+ return new JObject<OUT, false>(props)
1468
+ }
1317
1469
 
1318
- return newBuilder as any
1319
- }
1470
+ function objectInfer<P extends Record<string, JBuilder<any, any>>>(
1471
+ props: P,
1472
+ ): JObjectInfer<P, false> {
1473
+ return new JObjectInfer<P, false>(props)
1474
+ }
1320
1475
 
1321
- integer(): this {
1322
- return this.cloneAndUpdateSchema({ type: 'integer' })
1323
- }
1476
+ function objectDbEntity(props: AnyObject): never
1477
+ function objectDbEntity<
1478
+ OUT extends BaseDBEntity,
1479
+ EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
1480
+ keyof OUT,
1481
+ keyof BaseDBEntity
1482
+ >,
1483
+ >(
1484
+ props: {
1485
+ // ✅ all non-system fields must be explicitly provided
1486
+ [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
1487
+ } &
1488
+ // ✅ if `id` differs, it's required
1489
+ (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
1490
+ ? { id?: BuilderFor<BaseDBEntity['id']> }
1491
+ : { id: BuilderFor<OUT['id']> }) &
1492
+ (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
1493
+ ? { created?: BuilderFor<BaseDBEntity['created']> }
1494
+ : { created: BuilderFor<OUT['created']> }) &
1495
+ (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
1496
+ ? { updated?: BuilderFor<BaseDBEntity['updated']> }
1497
+ : { updated: BuilderFor<OUT['updated']> }),
1498
+ ): JObject<OUT, false>
1324
1499
 
1325
- branded<B extends number>(): JsonSchemaNumberBuilder<B, Opt> {
1326
- return this as unknown as JsonSchemaNumberBuilder<B, Opt>
1327
- }
1500
+ function objectDbEntity(props: AnyObject): any {
1501
+ return j.object({
1502
+ id: j.string(),
1503
+ created: j.number().unixTimestamp2000(),
1504
+ updated: j.number().unixTimestamp2000(),
1505
+ ...props,
1506
+ })
1507
+ }
1328
1508
 
1329
- multipleOf(multipleOf: number): this {
1330
- return this.cloneAndUpdateSchema({ multipleOf })
1331
- }
1509
+ function record<
1510
+ KS extends JBuilder<any, any>,
1511
+ VS extends JBuilder<any, any>,
1512
+ Opt extends boolean = SchemaOpt<VS>,
1513
+ >(
1514
+ keySchema: KS,
1515
+ valueSchema: VS,
1516
+ ): JObject<
1517
+ Opt extends true
1518
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
1519
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
1520
+ false
1521
+ > {
1522
+ const keyJsonSchema = keySchema.build()
1523
+ // Check if value schema is optional before build() strips the optionalField flag
1524
+ const isValueOptional = (valueSchema as JSchema<any, any>).getSchema().optionalField
1525
+ const valueJsonSchema = valueSchema.build()
1332
1526
 
1333
- min(minimum: number): this {
1334
- return this.cloneAndUpdateSchema({ minimum })
1335
- }
1527
+ // When value schema is optional, wrap in anyOf to allow undefined values
1528
+ const finalValueSchema: JsonSchema = isValueOptional
1529
+ ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1530
+ : valueJsonSchema
1336
1531
 
1337
- exclusiveMin(exclusiveMinimum: number): this {
1338
- return this.cloneAndUpdateSchema({ exclusiveMinimum })
1339
- }
1532
+ return new JObject<
1533
+ Opt extends true
1534
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
1535
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
1536
+ false
1537
+ >([], {
1538
+ hasIsOfTypeCheck: false,
1539
+ keySchema: keyJsonSchema,
1540
+ patternProperties: {
1541
+ ['^.*$']: finalValueSchema,
1542
+ },
1543
+ })
1544
+ }
1340
1545
 
1341
- max(maximum: number): this {
1342
- return this.cloneAndUpdateSchema({ maximum })
1546
+ function withRegexKeys<S extends JBuilder<any, any>>(
1547
+ keyRegex: RegExp | string,
1548
+ schema: S,
1549
+ ): JObject<StringMap<SchemaOut<S>>, false> {
1550
+ if (keyRegex instanceof RegExp) {
1551
+ _assert(
1552
+ !keyRegex.flags,
1553
+ `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
1554
+ )
1343
1555
  }
1556
+ const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
1557
+ const jsonSchema = schema.build()
1344
1558
 
1345
- exclusiveMax(exclusiveMaximum: number): this {
1346
- return this.cloneAndUpdateSchema({ exclusiveMaximum })
1347
- }
1559
+ return new JObject<StringMap<SchemaOut<S>>, false>([], {
1560
+ hasIsOfTypeCheck: false,
1561
+ patternProperties: {
1562
+ [pattern]: jsonSchema,
1563
+ },
1564
+ })
1565
+ }
1348
1566
 
1349
- lessThan(value: number): this {
1350
- return this.exclusiveMax(value)
1567
+ /**
1568
+ * Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
1569
+ */
1570
+ function withEnumKeys<
1571
+ const T extends readonly (string | number)[] | StringEnum | NumberEnum,
1572
+ S extends JBuilder<any, any>,
1573
+ K extends string | number = EnumKeyUnion<T>,
1574
+ Opt extends boolean = SchemaOpt<S>,
1575
+ >(
1576
+ keys: T,
1577
+ schema: S,
1578
+ ): JObject<Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> }, false> {
1579
+ let enumValues: readonly (string | number)[] | undefined
1580
+ if (Array.isArray(keys)) {
1581
+ _assert(
1582
+ isEveryItemPrimitive(keys),
1583
+ 'Every item in the key list should be string, number or symbol',
1584
+ )
1585
+ enumValues = keys
1586
+ } else if (typeof keys === 'object') {
1587
+ const enumType = getEnumType(keys)
1588
+ _assert(
1589
+ enumType === 'NumberEnum' || enumType === 'StringEnum',
1590
+ 'The key list should be StringEnum or NumberEnum',
1591
+ )
1592
+ if (enumType === 'NumberEnum') {
1593
+ enumValues = _numberEnumValues(keys as NumberEnum)
1594
+ } else if (enumType === 'StringEnum') {
1595
+ enumValues = _stringEnumValues(keys as StringEnum)
1596
+ }
1351
1597
  }
1352
1598
 
1353
- lessThanOrEqual(value: number): this {
1354
- return this.max(value)
1355
- }
1599
+ _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
1356
1600
 
1357
- moreThan(value: number): this {
1358
- return this.exclusiveMin(value)
1359
- }
1601
+ const typedValues = enumValues as readonly K[]
1602
+ const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
1360
1603
 
1361
- moreThanOrEqual(value: number): this {
1362
- return this.min(value)
1363
- }
1604
+ return new JObject<
1605
+ Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
1606
+ false
1607
+ >(props, { hasIsOfTypeCheck: false })
1608
+ }
1364
1609
 
1365
- equal(value: number): this {
1366
- return this.min(value).max(value)
1367
- }
1610
+ // ==== AjvSchema compat wrapper ====
1368
1611
 
1369
- range(minimum: number, maximum: number, incl: Inclusiveness): this {
1370
- if (incl === '[)') {
1371
- return this.moreThanOrEqual(minimum).lessThan(maximum)
1612
+ /**
1613
+ * On creation - compiles ajv validation function.
1614
+ * Provides convenient methods, error reporting, etc.
1615
+ */
1616
+ export class AjvSchema<OUT> {
1617
+ private constructor(
1618
+ public schema: JsonSchema<OUT>,
1619
+ cfg: Partial<AjvSchemaCfg> = {},
1620
+ preCompiledFn?: any,
1621
+ ) {
1622
+ this.cfg = {
1623
+ lazy: false,
1624
+ ...cfg,
1625
+ ajv: cfg.ajv || getAjv(),
1626
+ // Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json")
1627
+ inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
1372
1628
  }
1373
- return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
1374
- }
1375
-
1376
- int32(): this {
1377
- const MIN_INT32 = -(2 ** 31)
1378
- const MAX_INT32 = 2 ** 31 - 1
1379
- const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1380
- const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1381
- const newMin = Math.max(MIN_INT32, currentMin)
1382
- const newMax = Math.min(MAX_INT32, currentMax)
1383
- return this.integer().min(newMin).max(newMax)
1384
- }
1385
1629
 
1386
- int64(): this {
1387
- const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1388
- const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1389
- const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
1390
- const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
1391
- return this.integer().min(newMin).max(newMax)
1630
+ if (preCompiledFn) {
1631
+ this._compiledFn = preCompiledFn
1632
+ } else if (!cfg.lazy) {
1633
+ this._getValidateFn() // compile eagerly
1634
+ }
1392
1635
  }
1393
1636
 
1394
- float(): this {
1395
- return this
1637
+ /**
1638
+ * Shortcut for AjvSchema.create(schema, { lazy: true })
1639
+ */
1640
+ static createLazy<OUT>(
1641
+ schema: SchemaHandledByAjv<OUT>,
1642
+ cfg?: Partial<AjvSchemaCfg>,
1643
+ ): AjvSchema<OUT> {
1644
+ return AjvSchema.create(schema, {
1645
+ lazy: true,
1646
+ ...cfg,
1647
+ })
1396
1648
  }
1397
1649
 
1398
- double(): this {
1399
- return this
1400
- }
1650
+ /**
1651
+ * Conveniently allows to pass either JsonSchema or JSchema builder, or existing AjvSchema.
1652
+ * If it's already an AjvSchema - it'll just return it without any processing.
1653
+ * If it's a Builder - will call `build` before proceeding.
1654
+ * Otherwise - will construct AjvSchema instance ready to be used.
1655
+ */
1656
+ static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
1657
+ if (schema instanceof AjvSchema) return schema
1401
1658
 
1402
- unixTimestamp(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1403
- return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
1404
- }
1659
+ if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
1660
+ return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
1661
+ }
1405
1662
 
1406
- unixTimestamp2000(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1407
- return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
1408
- }
1663
+ let jsonSchema: JsonSchema<OUT>
1409
1664
 
1410
- unixTimestampMillis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1411
- return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1412
- }
1665
+ if (schema instanceof JSchema) {
1666
+ // oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
1667
+ jsonSchema = (schema as JSchema<OUT, any>).build()
1668
+ AjvSchema.requireValidJsonSchema(jsonSchema)
1669
+ } else {
1670
+ jsonSchema = schema
1671
+ }
1413
1672
 
1414
- unixTimestamp2000Millis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1415
- return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1416
- }
1673
+ // This is our own helper which marks a schema as optional
1674
+ // in case it is going to be used in an object schema,
1675
+ // where we need to mark the given property as not-required.
1676
+ // But once all compilation is done, the presence of this field
1677
+ // really upsets Ajv.
1678
+ delete jsonSchema.optionalField
1417
1679
 
1418
- utcOffset(): this {
1419
- return this.integer()
1420
- .multipleOf(15)
1421
- .min(-12 * 60)
1422
- .max(14 * 60)
1423
- }
1680
+ const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
1681
+ AjvSchema.cacheAjvSchema(schema, ajvSchema)
1424
1682
 
1425
- utcOffsetHour(): this {
1426
- return this.integer().min(-12).max(14)
1683
+ return ajvSchema
1427
1684
  }
1428
1685
 
1429
1686
  /**
1430
- * Specify the precision of the floating point numbers by the number of digits after the ".".
1431
- * Excess digits will be cut-off when the current schema is nested in an object or array schema,
1432
- * due to how mutability works in Ajv.
1687
+ * Creates a minimal AjvSchema wrapper from a pre-compiled validate function.
1688
+ * Used internally by JSchema to cache a compatible AjvSchema instance.
1433
1689
  */
1434
- precision(numberOfDigits: number): this {
1435
- return this.cloneAndUpdateSchema({ precision: numberOfDigits })
1690
+ static _wrap<OUT>(schema: JsonSchema<OUT>, compiledFn: any): AjvSchema<OUT> {
1691
+ return new AjvSchema<OUT>(schema, {}, compiledFn)
1436
1692
  }
1437
- }
1438
1693
 
1439
- export class JsonSchemaBooleanBuilder<
1440
- OUT extends boolean | undefined = boolean,
1441
- Opt extends boolean = false,
1442
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1443
- constructor() {
1444
- super({
1445
- type: 'boolean',
1446
- })
1694
+ static isSchemaWithCachedAjvSchema<Base, OUT>(
1695
+ schema: Base,
1696
+ ): schema is WithCachedAjvSchema<Base, OUT> {
1697
+ return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
1447
1698
  }
1448
1699
 
1449
- /**
1450
- * @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
1451
- *
1452
- * This `optionalValue` feature only works when the current schema is nested in an object or array schema,
1453
- * due to how mutability works in Ajv.
1454
- */
1455
- override optional(optionalValue?: boolean): JsonSchemaBooleanBuilder<OUT | undefined, true> {
1456
- if (typeof optionalValue === 'undefined') {
1457
- return super.optional() as unknown as JsonSchemaBooleanBuilder<OUT | undefined, true>
1458
- }
1459
-
1460
- const newBuilder = new JsonSchemaBooleanBuilder<OUT, Opt>().optional()
1461
- const alternativesSchema = j.enum([optionalValue])
1462
- Object.assign(newBuilder.getSchema(), {
1463
- anyOf: [this.build(), alternativesSchema.build()],
1464
- optionalValues: [optionalValue],
1465
- })
1466
-
1467
- return newBuilder
1700
+ static cacheAjvSchema<Base extends AnyObject, OUT>(
1701
+ schema: Base,
1702
+ ajvSchema: AjvSchema<OUT>,
1703
+ ): WithCachedAjvSchema<Base, OUT> {
1704
+ return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
1468
1705
  }
1469
- }
1470
-
1471
- export class JsonSchemaObjectBuilder<
1472
- OUT extends AnyObject,
1473
- Opt extends boolean = false,
1474
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1475
- constructor(props?: AnyObject, opt?: JsonSchemaObjectBuilderOpts) {
1476
- super({
1477
- type: 'object',
1478
- properties: {},
1479
- required: [],
1480
- additionalProperties: false,
1481
- hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1482
- patternProperties: opt?.patternProperties ?? undefined,
1483
- keySchema: opt?.keySchema ?? undefined,
1484
- })
1485
1706
 
1486
- if (props) this.addProperties(props)
1707
+ static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
1708
+ return schema[HIDDEN_AJV_SCHEMA]
1487
1709
  }
1488
1710
 
1489
- addProperties(props: AnyObject): this {
1490
- const properties: Record<string, JsonSchema> = {}
1491
- const required: string[] = []
1711
+ readonly cfg: AjvSchemaCfg
1492
1712
 
1493
- for (const [key, builder] of Object.entries(props)) {
1494
- const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1495
- if (!isOptional) {
1496
- required.push(key)
1497
- }
1713
+ private _compiledFn: any
1498
1714
 
1499
- const schema = builder.build()
1500
- properties[key] = schema
1715
+ private _getValidateFn(): any {
1716
+ if (!this._compiledFn) {
1717
+ this._compiledFn = this.cfg.ajv.compile(this.schema as any)
1501
1718
  }
1502
-
1503
- this.schema.properties = properties
1504
- this.schema.required = _uniq(required).sort()
1505
-
1506
- return this
1719
+ return this._compiledFn
1507
1720
  }
1508
1721
 
1509
1722
  /**
1510
- * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1511
- *
1512
- * This `null` feature only works when the current schema is nested in an object or array schema,
1513
- * due to how mutability works in Ajv.
1514
- *
1515
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1516
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1723
+ * It returns the original object just for convenience.
1517
1724
  */
1518
- override optional<N extends null | undefined = undefined>(
1519
- nullValue?: N,
1520
- ): N extends null
1521
- ? JsonSchemaTerminal<OUT | undefined, true>
1522
- : JsonSchemaAnyBuilder<OUT | undefined, true> {
1523
- if (nullValue === undefined) {
1524
- return super.optional() as any
1525
- }
1526
-
1527
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1528
- // so we must allow `null` values to be parsed by Ajv,
1529
- // but the typing should not reflect that.
1530
- // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1531
- return new JsonSchemaTerminal({
1532
- anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1533
- optionalField: true,
1534
- }) as any
1725
+ validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
1726
+ const [err, output] = this.getValidationResult(input, opt)
1727
+ if (err) throw err
1728
+ return output
1535
1729
  }
1536
1730
 
1537
- /**
1538
- * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1539
- */
1540
-
1541
- allowAdditionalProperties(): this {
1542
- return this.cloneAndUpdateSchema({ additionalProperties: true })
1731
+ isValid(input: unknown, opt?: AjvValidationOptions): boolean {
1732
+ const [err] = this.getValidationResult(input, opt)
1733
+ return !err
1543
1734
  }
1544
1735
 
1545
- extend<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1546
- props: P,
1547
- ): JsonSchemaObjectBuilder<
1548
- Override<
1549
- OUT,
1550
- {
1551
- // required keys
1552
- [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1553
- ? IsOpt extends true
1554
- ? never
1555
- : K
1556
- : never]: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1557
- } & {
1558
- // optional keys
1559
- [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1560
- ? IsOpt extends true
1561
- ? K
1562
- : never
1563
- : never]?: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1564
- }
1565
- >,
1566
- false
1567
- > {
1568
- const newBuilder = new JsonSchemaObjectBuilder()
1569
- _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1570
-
1571
- const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props)
1572
- mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1573
-
1574
- _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1575
-
1576
- return newBuilder as any
1736
+ getValidationResult(
1737
+ input: unknown,
1738
+ opt: AjvValidationOptions = {},
1739
+ ): ValidationFunctionResult<OUT, AjvValidationError> {
1740
+ const fn = this._getValidateFn()
1741
+ return executeValidation<OUT>(fn, this.schema, input, opt, this.cfg.inputName)
1577
1742
  }
1578
1743
 
1579
- /**
1580
- * Concatenates another schema to the current schema.
1581
- *
1582
- * It expects you to use `isOfType<T>()` in the chain,
1583
- * otherwise the validation will throw. This is to ensure
1584
- * that the schemas you concatenated match the intended final type.
1585
- *
1586
- * ```ts
1587
- * interface Foo { foo: string }
1588
- * const fooSchema = j.object<Foo>({ foo: j.string() })
1589
- *
1590
- * interface Bar { bar: number }
1591
- * const barSchema = j.object<Bar>({ bar: j.number() })
1592
- *
1593
- * interface Shu { foo: string, bar: number }
1594
- * const shuSchema = fooSchema.concat(barSchema).isOfType<Shu>() // important
1595
- * ```
1596
- */
1597
- concat<OUT2 extends AnyObject>(
1598
- other: JsonSchemaObjectBuilder<OUT2, any>,
1599
- ): JsonSchemaObjectBuilder<OUT & OUT2, false> {
1600
- const clone = this.clone()
1601
- mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
1602
- _objectAssign(clone.schema, { hasIsOfTypeCheck: false })
1603
- return clone as unknown as JsonSchemaObjectBuilder<OUT & OUT2, false>
1744
+ getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
1745
+ return (input, opt) => {
1746
+ return this.getValidationResult(input, {
1747
+ mutateInput: opt?.mutateInput,
1748
+ inputName: opt?.inputName,
1749
+ inputId: opt?.inputId,
1750
+ })
1751
+ }
1604
1752
  }
1605
1753
 
1606
- /**
1607
- * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1608
- */
1609
- // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1610
- dbEntity() {
1611
- return this.extend({
1612
- id: j.string(),
1613
- created: j.number().unixTimestamp2000(),
1614
- updated: j.number().unixTimestamp2000(),
1615
- })
1754
+ private static requireValidJsonSchema(schema: JsonSchema): void {
1755
+ // For object schemas we require that it is type checked against an external type, e.g.:
1756
+ // interface Foo { name: string }
1757
+ // const schema = j.object({ name: j.string() }).ofType<Foo>()
1758
+ _assert(
1759
+ schema.type !== 'object' || schema.hasIsOfTypeCheck,
1760
+ 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
1761
+ )
1616
1762
  }
1763
+ }
1617
1764
 
1618
- minProperties(minProperties: number): this {
1619
- return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
1620
- }
1765
+ // ==== Shared validation logic ====
1621
1766
 
1622
- maxProperties(maxProperties: number): this {
1623
- return this.cloneAndUpdateSchema({ maxProperties })
1624
- }
1767
+ const separator = '\n'
1625
1768
 
1626
- exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
1627
- const exclusiveProperties = this.schema.exclusiveProperties ?? []
1628
- return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
1769
+ function executeValidation<OUT>(
1770
+ fn: any,
1771
+ builtSchema: JsonSchema,
1772
+ input: unknown,
1773
+ opt: AjvValidationOptions = {},
1774
+ defaultInputName?: string,
1775
+ ): ValidationFunctionResult<OUT, AjvValidationError> {
1776
+ const item =
1777
+ opt.mutateInput !== false || typeof input !== 'object'
1778
+ ? input // mutate
1779
+ : _deepCopy(input) // not mutate
1780
+
1781
+ let valid = fn(item) // mutates item, but not input
1782
+ _typeCast<OUT>(item)
1783
+
1784
+ let output: OUT = item
1785
+ if (valid && builtSchema.postValidation) {
1786
+ const [err, result] = _try(() => builtSchema.postValidation!(output))
1787
+ if (err) {
1788
+ valid = false
1789
+ ;(fn as any).errors = [
1790
+ {
1791
+ instancePath: '',
1792
+ message: err.message,
1793
+ },
1794
+ ]
1795
+ } else {
1796
+ output = result as OUT
1797
+ }
1629
1798
  }
1630
- }
1631
1799
 
1632
- interface JsonSchemaObjectBuilderOpts {
1633
- hasIsOfTypeCheck?: false
1634
- patternProperties?: StringMap<JsonSchema<any>>
1635
- keySchema?: JsonSchema
1800
+ if (valid) return [null, output]
1801
+
1802
+ const errors = fn.errors!
1803
+
1804
+ const {
1805
+ inputId = _isObject(input) ? (input as any)['id'] : undefined,
1806
+ inputName = defaultInputName || 'Object',
1807
+ } = opt
1808
+ const dataVar = [inputName, inputId].filter(Boolean).join('.')
1809
+
1810
+ applyImprovementsOnErrorMessages(errors, builtSchema)
1811
+
1812
+ let message = getAjv().errorsText(errors, {
1813
+ dataVar,
1814
+ separator,
1815
+ })
1816
+
1817
+ // Note: if we mutated the input already, e.g stripped unknown properties,
1818
+ // the error message Input would contain already mutated object print, such as Input: {}
1819
+ // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness.
1820
+ const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 })
1821
+ message = [message, 'Input: ' + inputStringified].join(separator)
1822
+
1823
+ const err = new AjvValidationError(
1824
+ message,
1825
+ _filterNullishValues({
1826
+ errors,
1827
+ inputName,
1828
+ inputId,
1829
+ }),
1830
+ )
1831
+ return [err, output]
1636
1832
  }
1637
1833
 
1638
- export class JsonSchemaObjectInferringBuilder<
1639
- PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>,
1640
- Opt extends boolean = false,
1641
- > extends JsonSchemaAnyBuilder<
1642
- Expand<
1643
- {
1644
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1645
- ? IsOpt extends true
1646
- ? never
1647
- : K
1648
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1649
- } & {
1650
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1651
- ? IsOpt extends true
1652
- ? K
1653
- : never
1654
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1834
+ // ==== Error formatting helpers ====
1835
+
1836
+ function applyImprovementsOnErrorMessages(
1837
+ errors: ErrorObject<string, Record<string, any>, unknown>[] | null | undefined,
1838
+ schema: JsonSchema,
1839
+ ): void {
1840
+ if (!errors) return
1841
+
1842
+ filterNullableAnyOfErrors(errors, schema)
1843
+
1844
+ const { errorMessages } = schema
1845
+
1846
+ for (const error of errors) {
1847
+ const errorMessage = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword)
1848
+
1849
+ if (errorMessage) {
1850
+ error.message = errorMessage
1851
+ } else if (errorMessages?.[error.keyword]) {
1852
+ error.message = errorMessages[error.keyword]
1853
+ } else {
1854
+ const unwrapped = unwrapNullableAnyOf(schema)
1855
+ if (unwrapped?.errorMessages?.[error.keyword]) {
1856
+ error.message = unwrapped.errorMessages[error.keyword]
1857
+ }
1655
1858
  }
1656
- >,
1657
- Opt
1658
- > {
1659
- constructor(props?: PROPS) {
1660
- super({
1661
- type: 'object',
1662
- properties: {},
1663
- required: [],
1664
- additionalProperties: false,
1665
- })
1666
1859
 
1667
- if (props) this.addProperties(props)
1860
+ error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
1668
1861
  }
1862
+ }
1669
1863
 
1670
- addProperties(props: PROPS): this {
1671
- const properties: Record<string, JsonSchema> = {}
1672
- const required: string[] = []
1864
+ /**
1865
+ * Filters out noisy errors produced by nullable anyOf patterns.
1866
+ * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
1867
+ * AJV produces "must be null" and "must match a schema in anyOf" errors
1868
+ * that are confusing. This method splices them out, keeping only the real errors.
1869
+ */
1870
+ function filterNullableAnyOfErrors(
1871
+ errors: ErrorObject<string, Record<string, any>, unknown>[],
1872
+ schema: JsonSchema,
1873
+ ): void {
1874
+ // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches)
1875
+ const exactPaths: string[] = []
1876
+ const nullBranchPrefixes: string[] = []
1673
1877
 
1674
- for (const [key, builder] of Object.entries(props)) {
1675
- const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1676
- if (!isOptional) {
1677
- required.push(key)
1678
- }
1878
+ for (const error of errors) {
1879
+ if (error.keyword !== 'anyOf') continue
1679
1880
 
1680
- const schema = builder.build()
1681
- properties[key] = schema
1682
- }
1881
+ const parentSchema = resolveSchemaPath(schema, error.schemaPath)
1882
+ if (!parentSchema) continue
1683
1883
 
1684
- this.schema.properties = properties
1685
- this.schema.required = _uniq(required).sort()
1884
+ const nullIndex = unwrapNullableAnyOfIndex(parentSchema)
1885
+ if (nullIndex === -1) continue
1686
1886
 
1687
- return this
1887
+ exactPaths.push(error.schemaPath) // e.g. "#/anyOf"
1888
+ const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length)
1889
+ nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`) // e.g. "#/anyOf/1/"
1688
1890
  }
1689
1891
 
1690
- /**
1691
- * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1692
- *
1693
- * This `null` feature only works when the current schema is nested in an object or array schema,
1694
- * due to how mutability works in Ajv.
1695
- *
1696
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1697
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1698
- */
1699
- // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1700
- override optional<N extends null | undefined = undefined>(
1701
- nullValue?: N,
1702
- ): N extends null
1703
- ? JsonSchemaTerminal<
1704
- | Expand<
1705
- {
1706
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1707
- ? IsOpt extends true
1708
- ? never
1709
- : K
1710
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1711
- } & {
1712
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1713
- ? IsOpt extends true
1714
- ? K
1715
- : never
1716
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1717
- }
1718
- >
1719
- | undefined,
1720
- true
1721
- >
1722
- : JsonSchemaAnyBuilder<
1723
- | Expand<
1724
- {
1725
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1726
- ? IsOpt extends true
1727
- ? never
1728
- : K
1729
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1730
- } & {
1731
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1732
- ? IsOpt extends true
1733
- ? K
1734
- : never
1735
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1736
- }
1737
- >
1738
- | undefined,
1739
- true
1740
- > {
1741
- if (nullValue === undefined) {
1742
- return super.optional() as any
1743
- }
1892
+ if (!exactPaths.length) return
1744
1893
 
1745
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1746
- // so we must allow `null` values to be parsed by Ajv,
1747
- // but the typing should not reflect that.
1748
- // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1749
- return new JsonSchemaTerminal({
1750
- anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1751
- optionalField: true,
1752
- }) as any
1894
+ for (let i = errors.length - 1; i >= 0; i--) {
1895
+ const sp = errors[i]!.schemaPath
1896
+ if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) {
1897
+ errors.splice(i, 1)
1898
+ }
1753
1899
  }
1900
+ }
1754
1901
 
1755
- /**
1756
- * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1757
- */
1902
+ /**
1903
+ * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
1904
+ * and returns the parent schema containing the last keyword.
1905
+ */
1906
+ function resolveSchemaPath(schema: JsonSchema, schemaPath: string): JsonSchema | undefined {
1907
+ // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf"
1908
+ // We want the schema that contains the final keyword (e.g. "anyOf")
1909
+ const segments = schemaPath.replace(/^#\//, '').split('/')
1910
+ // Remove the last segment (the keyword itself, e.g. "anyOf")
1911
+ segments.pop()
1912
+
1913
+ let current: any = schema
1914
+ for (const segment of segments) {
1915
+ if (!current || typeof current !== 'object') return undefined
1916
+ current = current[segment]
1917
+ }
1918
+ return current as JsonSchema | undefined
1919
+ }
1758
1920
 
1759
- allowAdditionalProperties(): this {
1760
- return this.cloneAndUpdateSchema({ additionalProperties: true })
1761
- }
1921
+ function getErrorMessageForInstancePath(
1922
+ schema: JsonSchema | undefined,
1923
+ instancePath: string,
1924
+ keyword: string,
1925
+ ): string | undefined {
1926
+ if (!schema || !instancePath) return undefined
1762
1927
 
1763
- extend<NEW_PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1764
- props: NEW_PROPS,
1765
- ): JsonSchemaObjectInferringBuilder<
1766
- {
1767
- [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1768
- ? NEW_PROPS[K]
1769
- : K extends keyof PROPS
1770
- ? PROPS[K]
1771
- : never
1772
- },
1773
- Opt
1774
- > {
1775
- const newBuilder = new JsonSchemaObjectInferringBuilder<PROPS, Opt>()
1776
- _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1928
+ const segments = instancePath.split('/').filter(Boolean)
1929
+ return traverseSchemaPath(schema, segments, keyword)
1930
+ }
1777
1931
 
1778
- const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder<NEW_PROPS, false>(props)
1779
- mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1932
+ function traverseSchemaPath(
1933
+ schema: JsonSchema,
1934
+ segments: string[],
1935
+ keyword: string,
1936
+ ): string | undefined {
1937
+ if (!segments.length) return undefined
1780
1938
 
1781
- // This extend function is not type-safe as it is inferring,
1782
- // so even if the base schema was already type-checked,
1783
- // the new schema loses that quality.
1784
- _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1939
+ const [currentSegment, ...remainingSegments] = segments
1785
1940
 
1786
- return newBuilder as unknown as JsonSchemaObjectInferringBuilder<
1787
- {
1788
- [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1789
- ? NEW_PROPS[K]
1790
- : K extends keyof PROPS
1791
- ? PROPS[K]
1792
- : never
1793
- },
1794
- Opt
1795
- >
1796
- }
1941
+ const nextSchema = getChildSchema(schema, currentSegment)
1942
+ if (!nextSchema) return undefined
1797
1943
 
1798
- /**
1799
- * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1800
- */
1801
- // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1802
- dbEntity() {
1803
- return this.extend({
1804
- id: j.string(),
1805
- created: j.number().unixTimestamp2000(),
1806
- updated: j.number().unixTimestamp2000(),
1807
- })
1944
+ if (nextSchema.errorMessages?.[keyword]) {
1945
+ return nextSchema.errorMessages[keyword]
1808
1946
  }
1809
- }
1810
1947
 
1811
- export class JsonSchemaArrayBuilder<OUT, Opt> extends JsonSchemaAnyBuilder<OUT[], Opt> {
1812
- constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1813
- super({
1814
- type: 'array',
1815
- items: itemsSchema.build(),
1816
- })
1948
+ // Check through nullable wrapper
1949
+ const unwrapped = unwrapNullableAnyOf(nextSchema)
1950
+ if (unwrapped?.errorMessages?.[keyword]) {
1951
+ return unwrapped.errorMessages[keyword]
1817
1952
  }
1818
1953
 
1819
- minLength(minItems: number): this {
1820
- return this.cloneAndUpdateSchema({ minItems })
1954
+ if (remainingSegments.length) {
1955
+ return traverseSchemaPath(nextSchema, remainingSegments, keyword)
1821
1956
  }
1822
1957
 
1823
- maxLength(maxItems: number): this {
1824
- return this.cloneAndUpdateSchema({ maxItems })
1825
- }
1958
+ return undefined
1959
+ }
1826
1960
 
1827
- length(exactLength: number): this
1828
- length(minItems: number, maxItems: number): this
1829
- length(minItemsOrExact: number, maxItems?: number): this {
1830
- const maxItemsActual = maxItems ?? minItemsOrExact
1831
- return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
1832
- }
1961
+ function getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined {
1962
+ if (!segment) return undefined
1833
1963
 
1834
- exactLength(length: number): this {
1835
- return this.minLength(length).maxLength(length)
1836
- }
1964
+ // Unwrap nullable anyOf to find properties/items through nullable wrappers
1965
+ const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema
1837
1966
 
1838
- unique(): this {
1839
- return this.cloneAndUpdateSchema({ uniqueItems: true })
1967
+ if (/^\d+$/.test(segment) && effectiveSchema.items) {
1968
+ return getArrayItemSchema(effectiveSchema, segment)
1840
1969
  }
1970
+
1971
+ return getObjectPropertySchema(effectiveSchema, segment)
1841
1972
  }
1842
1973
 
1843
- export class JsonSchemaSet2Builder<OUT, Opt> extends JsonSchemaAnyBuilder<Set2<OUT>, Opt> {
1844
- constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1845
- super({
1846
- type: ['array', 'object'],
1847
- Set2: itemsSchema.build(),
1848
- })
1849
- }
1974
+ function getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined {
1975
+ if (!schema.items) return undefined
1850
1976
 
1851
- min(minItems: number): this {
1852
- return this.cloneAndUpdateSchema({ minItems })
1977
+ if (Array.isArray(schema.items)) {
1978
+ return schema.items[Number(indexSegment)]
1853
1979
  }
1854
1980
 
1855
- max(maxItems: number): this {
1856
- return this.cloneAndUpdateSchema({ maxItems })
1857
- }
1981
+ return schema.items
1858
1982
  }
1859
1983
 
1860
- export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder<Buffer, false> {
1861
- constructor() {
1862
- super({
1863
- Buffer: true,
1864
- })
1865
- }
1984
+ function getObjectPropertySchema(schema: JsonSchema, segment: string): JsonSchema | undefined {
1985
+ return schema.properties?.[segment as keyof typeof schema.properties]
1866
1986
  }
1867
1987
 
1868
- export class JsonSchemaEnumBuilder<
1869
- OUT extends string | number | boolean | null,
1870
- Opt extends boolean = false,
1871
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1872
- constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
1873
- const jsonSchema: JsonSchema = { enum: enumValues }
1874
- // Specifying the base type helps in cases when we ask Ajv to coerce the types.
1875
- // Having only the `enum` in the schema does not trigger a coercion in Ajv.
1876
- if (baseType === 'string') jsonSchema.type = 'string'
1877
- if (baseType === 'number') jsonSchema.type = 'number'
1988
+ function unwrapNullableAnyOf(schema: JsonSchema): JsonSchema | undefined {
1989
+ const nullIndex = unwrapNullableAnyOfIndex(schema)
1990
+ if (nullIndex === -1) return undefined
1991
+ return schema.anyOf![1 - nullIndex]!
1992
+ }
1878
1993
 
1879
- super(jsonSchema)
1994
+ function unwrapNullableAnyOfIndex(schema: JsonSchema): number {
1995
+ if (schema.anyOf?.length !== 2) return -1
1996
+ const nullIndex = schema.anyOf.findIndex(s => s.type === 'null')
1997
+ return nullIndex
1998
+ }
1880
1999
 
1881
- if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1882
- if (opt?.msg) this.setErrorMessage('enum', opt.msg)
2000
+ // ==== Utility helpers ====
2001
+
2002
+ function addPropertiesToSchema(schema: JsonSchema, props: AnyObject): void {
2003
+ const properties: Record<string, JsonSchema> = {}
2004
+ const required: string[] = []
2005
+
2006
+ for (const [key, builder] of Object.entries(props)) {
2007
+ const isOptional = (builder as JSchema<any, any>).getSchema().optionalField
2008
+ if (!isOptional) {
2009
+ required.push(key)
2010
+ }
2011
+
2012
+ const builtSchema = builder.build()
2013
+ properties[key] = builtSchema
1883
2014
  }
1884
2015
 
1885
- branded<B extends OUT>(): JsonSchemaEnumBuilder<B, Opt> {
1886
- return this as unknown as JsonSchemaEnumBuilder<B, Opt>
2016
+ schema.properties = properties
2017
+ schema.required = _uniq(required).sort()
2018
+ }
2019
+
2020
+ function hasNoObjectSchemas(schema: JsonSchema): boolean {
2021
+ if (Array.isArray(schema.type)) {
2022
+ return schema.type.every(type =>
2023
+ ['string', 'number', 'integer', 'boolean', 'null'].includes(type),
2024
+ )
2025
+ } else if (schema.anyOf) {
2026
+ return schema.anyOf.every(hasNoObjectSchemas)
2027
+ } else if (schema.oneOf) {
2028
+ return schema.oneOf.every(hasNoObjectSchemas)
2029
+ } else if (schema.enum) {
2030
+ return true
2031
+ } else if (schema.type === 'array') {
2032
+ return !schema.items || hasNoObjectSchemas(schema.items)
2033
+ } else {
2034
+ return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
1887
2035
  }
2036
+
2037
+ return false
1888
2038
  }
1889
2039
 
1890
- export class JsonSchemaTupleBuilder<
1891
- ITEMS extends JsonSchemaAnyBuilder<any, any>[],
1892
- > extends JsonSchemaAnyBuilder<TupleOut<ITEMS>, false> {
1893
- constructor(items: ITEMS) {
1894
- super({
1895
- type: 'array',
1896
- prefixItems: items.map(i => i.build()),
1897
- minItems: items.length,
1898
- maxItems: items.length,
1899
- })
2040
+ type EnumBaseType = 'string' | 'number' | 'other'
2041
+
2042
+ /**
2043
+ * Deep copy that preserves functions in customValidations/customConversions.
2044
+ * Unlike structuredClone, this handles function references (which only exist in those two properties).
2045
+ */
2046
+ function deepCopyPreservingFunctions<T>(obj: T): T {
2047
+ if (obj === null || typeof obj !== 'object') return obj
2048
+ if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
2049
+ const copy = {} as T
2050
+ for (const key of Object.keys(obj)) {
2051
+ const value = (obj as any)[key]
2052
+ // customValidations/customConversions are arrays of functions - shallow copy the array
2053
+ ;(copy as any)[key] =
2054
+ (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
2055
+ ? [...value]
2056
+ : deepCopyPreservingFunctions(value)
1900
2057
  }
2058
+ return copy
1901
2059
  }
1902
2060
 
1903
- export class JsonSchemaAnyOfByBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1904
- constructor(
1905
- propertyName: string,
1906
- schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1907
- ) {
1908
- const builtSchemaDictionary: Record<string, JsonSchema> = {}
1909
- for (const [key, schema] of Object.entries(schemaDictionary)) {
1910
- builtSchemaDictionary[key] = schema.build()
1911
- }
2061
+ // ==== Types & Interfaces ====
1912
2062
 
1913
- super({
1914
- type: 'object',
1915
- hasIsOfTypeCheck: true,
1916
- anyOfBy: {
1917
- propertyName,
1918
- schemaDictionary: builtSchemaDictionary,
1919
- },
1920
- })
1921
- }
2063
+ export interface AjvValidationOptions {
2064
+ /**
2065
+ * Custom Ajv instance to use for this validation.
2066
+ * Overrides the default Ajv or any Ajv set at construction time.
2067
+ * Compiled functions are cached per Ajv instance.
2068
+ */
2069
+ ajv?: Ajv
2070
+
2071
+ /**
2072
+ * Defaults to true,
2073
+ * because that's how AJV works by default,
2074
+ * and what gives it performance advantage.
2075
+ * (Because we have found that deep-clone is surprisingly slow,
2076
+ * nearly as slow as Joi validation).
2077
+ *
2078
+ * If set to true - AJV will mutate the input in case it needs to apply transformations
2079
+ * (strip unknown properties, convert types, etc).
2080
+ *
2081
+ * If false - it will deep-clone (using JSON.stringify+parse) the input to prevent its mutation.
2082
+ * Will return the cloned/mutated object.
2083
+ * Please note that JSON.stringify+parse has side-effects,
2084
+ * e.g it will transform Buffer into a weird object.
2085
+ */
2086
+ mutateInput?: boolean
2087
+ inputName?: string
2088
+ inputId?: string
2089
+ /**
2090
+ * Function that returns "original input".
2091
+ * What is original input?
2092
+ * It's an input in its original non-mutated form.
2093
+ * Why is it needed?
2094
+ * Because we mutates the Input here. And after its been mutated - we no longer
2095
+ * can include it "how it was" in an error message. So, for that reason we'll use
2096
+ * `getOriginalInput()`, if it's provided.
2097
+ */
2098
+ getOriginalInput?: () => unknown
1922
2099
  }
1923
2100
 
1924
- export class JsonSchemaAnyOfTheseBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1925
- constructor(
1926
- propertyName: string,
1927
- schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1928
- ) {
1929
- const builtSchemaDictionary: Record<string, JsonSchema> = {}
1930
- for (const [key, schema] of Object.entries(schemaDictionary)) {
1931
- builtSchemaDictionary[key] = schema.build()
1932
- }
2101
+ export interface AjvSchemaCfg {
2102
+ /**
2103
+ * Pass Ajv instance, otherwise Ajv will be created with
2104
+ * AjvSchema default (not the same as Ajv defaults) parameters
2105
+ */
2106
+ ajv: Ajv
1933
2107
 
1934
- super({
1935
- type: 'object',
1936
- hasIsOfTypeCheck: true,
1937
- anyOfBy: {
1938
- propertyName,
1939
- schemaDictionary: builtSchemaDictionary,
1940
- },
1941
- })
1942
- }
2108
+ inputName?: string
2109
+
2110
+ /**
2111
+ * If true - schema will be compiled on-demand (lazily).
2112
+ * Default: false.
2113
+ */
2114
+ lazy?: boolean
1943
2115
  }
1944
2116
 
1945
- type EnumBaseType = 'string' | 'number' | 'other'
2117
+ export type SchemaHandledByAjv<OUT> = JSchema<OUT, any> | JsonSchema<OUT> | AjvSchema<OUT>
1946
2118
 
1947
2119
  export interface JsonSchema<OUT = unknown> {
1948
2120
  readonly out?: OUT
@@ -1951,7 +2123,6 @@ export interface JsonSchema<OUT = unknown> {
1951
2123
  $id?: string
1952
2124
  title?: string
1953
2125
  description?: string
1954
- // $comment?: string
1955
2126
  deprecated?: boolean
1956
2127
  readOnly?: boolean
1957
2128
  writeOnly?: boolean
@@ -2032,177 +2203,9 @@ export interface JsonSchema<OUT = unknown> {
2032
2203
  postValidation?: PostValidatonFn<any, OUT>
2033
2204
  }
2034
2205
 
2035
- function object(props: AnyObject): never
2036
- function object<OUT extends AnyObject>(props: {
2037
- [K in keyof Required<OUT>]-?: JsonSchemaTerminal<OUT[K], any>
2038
- }): JsonSchemaObjectBuilder<OUT, false>
2039
-
2040
- function object<OUT extends AnyObject>(props: {
2041
- [key in keyof OUT]: JsonSchemaTerminal<OUT[key], any>
2042
- }): JsonSchemaObjectBuilder<OUT, false> {
2043
- return new JsonSchemaObjectBuilder<OUT, false>(props)
2044
- }
2045
-
2046
- function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
2047
- props: P,
2048
- ): JsonSchemaObjectInferringBuilder<P, false> {
2049
- return new JsonSchemaObjectInferringBuilder<P, false>(props)
2050
- }
2051
-
2052
- function objectDbEntity(props: AnyObject): never
2053
- function objectDbEntity<
2054
- OUT extends BaseDBEntity,
2055
- EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
2056
- keyof OUT,
2057
- keyof BaseDBEntity
2058
- >,
2059
- >(
2060
- props: {
2061
- // ✅ all non-system fields must be explicitly provided
2062
- [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
2063
- } &
2064
- // ✅ if `id` differs, it's required
2065
- (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
2066
- ? { id?: BuilderFor<BaseDBEntity['id']> }
2067
- : { id: BuilderFor<OUT['id']> }) &
2068
- (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
2069
- ? { created?: BuilderFor<BaseDBEntity['created']> }
2070
- : { created: BuilderFor<OUT['created']> }) &
2071
- (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
2072
- ? { updated?: BuilderFor<BaseDBEntity['updated']> }
2073
- : { updated: BuilderFor<OUT['updated']> }),
2074
- ): JsonSchemaObjectBuilder<OUT, false>
2075
-
2076
- function objectDbEntity(props: AnyObject): any {
2077
- return j.object({
2078
- id: j.string(),
2079
- created: j.number().unixTimestamp2000(),
2080
- updated: j.number().unixTimestamp2000(),
2081
- ...props,
2082
- })
2083
- }
2084
-
2085
- function record<
2086
- KS extends JsonSchemaAnyBuilder<any, any>,
2087
- VS extends JsonSchemaAnyBuilder<any, any>,
2088
- Opt extends boolean = SchemaOpt<VS>,
2089
- >(
2090
- keySchema: KS,
2091
- valueSchema: VS,
2092
- ): JsonSchemaObjectBuilder<
2093
- Opt extends true
2094
- ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2095
- : Record<SchemaOut<KS>, SchemaOut<VS>>,
2096
- false
2097
- > {
2098
- const keyJsonSchema = keySchema.build()
2099
- // Check if value schema is optional before build() strips the optionalField flag
2100
- const isValueOptional = (valueSchema as JsonSchemaTerminal<any, any>).getSchema().optionalField
2101
- const valueJsonSchema = valueSchema.build()
2102
-
2103
- // When value schema is optional, wrap in anyOf to allow undefined values
2104
- const finalValueSchema: JsonSchema = isValueOptional
2105
- ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
2106
- : valueJsonSchema
2107
-
2108
- return new JsonSchemaObjectBuilder<
2109
- Opt extends true
2110
- ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2111
- : Record<SchemaOut<KS>, SchemaOut<VS>>,
2112
- false
2113
- >([], {
2114
- hasIsOfTypeCheck: false,
2115
- keySchema: keyJsonSchema,
2116
- patternProperties: {
2117
- ['^.*$']: finalValueSchema,
2118
- },
2119
- })
2120
- }
2121
-
2122
- function withRegexKeys<S extends JsonSchemaAnyBuilder<any, any>>(
2123
- keyRegex: RegExp | string,
2124
- schema: S,
2125
- ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false> {
2126
- if (keyRegex instanceof RegExp) {
2127
- _assert(
2128
- !keyRegex.flags,
2129
- `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
2130
- )
2131
- }
2132
- const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
2133
- const jsonSchema = schema.build()
2134
-
2135
- return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false>([], {
2136
- hasIsOfTypeCheck: false,
2137
- patternProperties: {
2138
- [pattern]: jsonSchema,
2139
- },
2140
- })
2141
- }
2142
-
2143
- /**
2144
- * Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
2145
- */
2146
- function withEnumKeys<
2147
- const T extends readonly (string | number)[] | StringEnum | NumberEnum,
2148
- S extends JsonSchemaAnyBuilder<any, any>,
2149
- K extends string | number = EnumKeyUnion<T>,
2150
- Opt extends boolean = SchemaOpt<S>,
2151
- >(
2152
- keys: T,
2153
- schema: S,
2154
- ): JsonSchemaObjectBuilder<
2155
- Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2156
- false
2157
- > {
2158
- let enumValues: readonly (string | number)[] | undefined
2159
- if (Array.isArray(keys)) {
2160
- _assert(
2161
- isEveryItemPrimitive(keys),
2162
- 'Every item in the key list should be string, number or symbol',
2163
- )
2164
- enumValues = keys
2165
- } else if (typeof keys === 'object') {
2166
- const enumType = getEnumType(keys)
2167
- _assert(
2168
- enumType === 'NumberEnum' || enumType === 'StringEnum',
2169
- 'The key list should be StringEnum or NumberEnum',
2170
- )
2171
- if (enumType === 'NumberEnum') {
2172
- enumValues = _numberEnumValues(keys as NumberEnum)
2173
- } else if (enumType === 'StringEnum') {
2174
- enumValues = _stringEnumValues(keys as StringEnum)
2175
- }
2176
- }
2177
-
2178
- _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
2179
-
2180
- const typedValues = enumValues as readonly K[]
2181
- const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
2182
-
2183
- return new JsonSchemaObjectBuilder<
2184
- Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2185
- false
2186
- >(props, { hasIsOfTypeCheck: false })
2187
- }
2188
-
2189
- function hasNoObjectSchemas(schema: JsonSchema): boolean {
2190
- if (Array.isArray(schema.type)) {
2191
- schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type))
2192
- } else if (schema.anyOf) {
2193
- return schema.anyOf.every(hasNoObjectSchemas)
2194
- } else if (schema.oneOf) {
2195
- return schema.oneOf.every(hasNoObjectSchemas)
2196
- } else if (schema.enum) {
2197
- return true
2198
- } else if (schema.type === 'array') {
2199
- return !schema.items || hasNoObjectSchemas(schema.items)
2200
- } else {
2201
- return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
2202
- }
2203
-
2204
- return false
2205
- }
2206
+ export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
2207
+ export type CustomValidatorFn = (v: any) => string | undefined
2208
+ export type CustomConverterFn<OUT> = (v: any) => OUT
2206
2209
 
2207
2210
  type Expand<T> = { [K in keyof T]: T[K] }
2208
2211
 
@@ -2255,17 +2258,17 @@ type ExactMatch<A, B> =
2255
2258
  ? true
2256
2259
  : ExactMatchBase<Expand<StripIndexSignatureDeep<A>>, Expand<StripIndexSignatureDeep<B>>>
2257
2260
 
2258
- type BuilderOutUnion<B extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2259
- [K in keyof B]: B[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2261
+ type BuilderOutUnion<B extends readonly JBuilder<any, any>[]> = {
2262
+ [K in keyof B]: B[K] extends JBuilder<infer O, any> ? O : never
2260
2263
  }[number]
2261
2264
 
2262
- type AnyOfByOut<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>> = {
2263
- [K in keyof D]: D[K] extends JsonSchemaTerminal<infer O, any> ? O : never
2265
+ type AnyOfByOut<D extends Record<PropertyKey, JSchema<any, any>>> = {
2266
+ [K in keyof D]: D[K] extends JSchema<infer O, any> ? O : never
2264
2267
  }[keyof D]
2265
2268
 
2266
- type BuilderFor<T> = JsonSchemaAnyBuilder<T, any>
2269
+ type BuilderFor<T> = JBuilder<T, any>
2267
2270
 
2268
- interface JsonBuilderRuleOpt {
2271
+ export interface JsonBuilderRuleOpt {
2269
2272
  /**
2270
2273
  * Text of error message to return when the validation fails for the given rule:
2271
2274
  *
@@ -2289,33 +2292,9 @@ type EnumKeyUnion<T> =
2289
2292
  ? T[keyof T]
2290
2293
  : never
2291
2294
 
2292
- type SchemaOut<S> = S extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
2293
- type SchemaOpt<S> =
2294
- S extends JsonSchemaAnyBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
2295
-
2296
- type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2297
- [K in keyof T]: T[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2298
- }
2299
-
2300
- export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
2301
- export type CustomValidatorFn = (v: any) => string | undefined
2302
- export type CustomConverterFn<OUT> = (v: any) => OUT
2295
+ type SchemaOut<S> = S extends JBuilder<infer OUT, any> ? OUT : never
2296
+ type SchemaOpt<S> = S extends JBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
2303
2297
 
2304
- /**
2305
- * Deep copy that preserves functions in customValidations/customConversions.
2306
- * Unlike structuredClone, this handles function references (which only exist in those two properties).
2307
- */
2308
- function deepCopyPreservingFunctions<T>(obj: T): T {
2309
- if (obj === null || typeof obj !== 'object') return obj
2310
- if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
2311
- const copy = {} as T
2312
- for (const key of Object.keys(obj)) {
2313
- const value = (obj as any)[key]
2314
- // customValidations/customConversions are arrays of functions - shallow copy the array
2315
- ;(copy as any)[key] =
2316
- (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
2317
- ? [...value]
2318
- : deepCopyPreservingFunctions(value)
2319
- }
2320
- return copy
2298
+ type TupleOut<T extends readonly JBuilder<any, any>[]> = {
2299
+ [K in keyof T]: T[K] extends JBuilder<infer O, any> ? O : never
2321
2300
  }