@digitalsamba/validate 2.0.0-canary.04d7155d2b9d

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.
@@ -0,0 +1,609 @@
1
+ import {
2
+ JsonValue,
3
+ exhaustiveSwitchError,
4
+ getOwnProperty,
5
+ hasOwnProperty,
6
+ } from '@digitalsamba/utils'
7
+
8
+ /** @public */
9
+ export type ValidatorFn<T> = (value: unknown) => T
10
+
11
+ /** @public */
12
+ export type Validatable<T> = { validate: (value: unknown) => T }
13
+
14
+ function formatPath(path: ReadonlyArray<number | string>): string | null {
15
+ if (!path.length) {
16
+ return null
17
+ }
18
+ let formattedPath = ''
19
+ for (const item of path) {
20
+ if (typeof item === 'number') {
21
+ formattedPath += `.${item}`
22
+ } else if (item.startsWith('(')) {
23
+ if (formattedPath.endsWith(')')) {
24
+ formattedPath = `${formattedPath.slice(0, -1)}, ${item.slice(1)}`
25
+ } else {
26
+ formattedPath += item
27
+ }
28
+ } else {
29
+ formattedPath += `.${item}`
30
+ }
31
+ }
32
+ if (formattedPath.startsWith('.')) {
33
+ return formattedPath.slice(1)
34
+ }
35
+ return formattedPath
36
+ }
37
+
38
+ /** @public */
39
+ export class ValidationError extends Error {
40
+ override name = 'ValidationError'
41
+
42
+ constructor(
43
+ public readonly rawMessage: string,
44
+ public readonly path: ReadonlyArray<number | string> = []
45
+ ) {
46
+ const formattedPath = formatPath(path)
47
+ const indentedMessage = rawMessage
48
+ .split('\n')
49
+ .map((line, i) => (i === 0 ? line : ` ${line}`))
50
+ .join('\n')
51
+ super(path ? `At ${formattedPath}: ${indentedMessage}` : indentedMessage)
52
+ }
53
+ }
54
+
55
+ function prefixError<T>(path: string | number, fn: () => T): T {
56
+ try {
57
+ return fn()
58
+ } catch (err) {
59
+ if (err instanceof ValidationError) {
60
+ throw new ValidationError(err.rawMessage, [path, ...err.path])
61
+ }
62
+ throw new ValidationError((err as Error).toString(), [path])
63
+ }
64
+ }
65
+
66
+ function typeToString(value: unknown): string {
67
+ if (value === null) return 'null'
68
+ if (Array.isArray(value)) return 'an array'
69
+ const type = typeof value
70
+ switch (type) {
71
+ case 'bigint':
72
+ case 'boolean':
73
+ case 'function':
74
+ case 'number':
75
+ case 'string':
76
+ case 'symbol':
77
+ return `a ${type}`
78
+ case 'object':
79
+ return `an ${type}`
80
+ case 'undefined':
81
+ return 'undefined'
82
+ default:
83
+ exhaustiveSwitchError(type)
84
+ }
85
+ }
86
+
87
+ /** @public */
88
+ export type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never
89
+
90
+ /** @public */
91
+ export class Validator<T> implements Validatable<T> {
92
+ constructor(readonly validationFn: ValidatorFn<T>) {}
93
+
94
+ /**
95
+ * Asserts that the passed value is of the correct type and returns it. The returned value is
96
+ * guaranteed to be referentially equal to the passed value.
97
+ */
98
+ validate(value: unknown): T {
99
+ const validated = this.validationFn(value)
100
+ if (process.env.NODE_ENV !== 'production' && !Object.is(value, validated)) {
101
+ throw new ValidationError('Validator functions must return the same value they were passed')
102
+ }
103
+ return validated
104
+ }
105
+
106
+ /**
107
+ * Returns a new validator that also accepts null or undefined. The resulting value will always be
108
+ * null.
109
+ */
110
+ nullable(): Validator<T | null> {
111
+ return nullable(this)
112
+ }
113
+
114
+ /**
115
+ * Returns a new validator that also accepts null or undefined. The resulting value will always be
116
+ * null.
117
+ */
118
+ optional(): Validator<T | undefined> {
119
+ return optional(this)
120
+ }
121
+
122
+ /**
123
+ * Refine this validation to a new type. The passed-in validation function should throw an error
124
+ * if the value can't be converted to the new type, or return the new type otherwise.
125
+ */
126
+ refine<U>(otherValidationFn: (value: T) => U): Validator<U> {
127
+ return new Validator((value) => {
128
+ return otherValidationFn(this.validate(value))
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Refine this validation with an additional check that doesn't change the resulting value.
134
+ *
135
+ * @example
136
+ *
137
+ * ```ts
138
+ * const numberLessThan10Validator = T.number.check((value) => {
139
+ * if (value >= 10) {
140
+ * throw new ValidationError(`Expected number less than 10, got ${value}`)
141
+ * }
142
+ * })
143
+ * ```
144
+ */
145
+ check(name: string, checkFn: (value: T) => void): Validator<T>
146
+ check(checkFn: (value: T) => void): Validator<T>
147
+ check(nameOrCheckFn: string | ((value: T) => void), checkFn?: (value: T) => void): Validator<T> {
148
+ if (typeof nameOrCheckFn === 'string') {
149
+ return this.refine((value) => {
150
+ prefixError(`(check ${nameOrCheckFn})`, () => checkFn!(value))
151
+ return value
152
+ })
153
+ } else {
154
+ return this.refine((value) => {
155
+ nameOrCheckFn(value)
156
+ return value
157
+ })
158
+ }
159
+ }
160
+ }
161
+
162
+ /** @public */
163
+ export class ArrayOfValidator<T> extends Validator<T[]> {
164
+ constructor(readonly itemValidator: Validatable<T>) {
165
+ super((value) => {
166
+ const arr = array.validate(value)
167
+ for (let i = 0; i < arr.length; i++) {
168
+ prefixError(i, () => itemValidator.validate(arr[i]))
169
+ }
170
+ return arr as T[]
171
+ })
172
+ }
173
+
174
+ nonEmpty() {
175
+ return this.check((value) => {
176
+ if (value.length === 0) {
177
+ throw new ValidationError('Expected a non-empty array')
178
+ }
179
+ })
180
+ }
181
+
182
+ lengthGreaterThan1() {
183
+ return this.check((value) => {
184
+ if (value.length <= 1) {
185
+ throw new ValidationError('Expected an array with length greater than 1')
186
+ }
187
+ })
188
+ }
189
+ }
190
+
191
+ /** @public */
192
+ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
193
+ constructor(
194
+ public readonly config: {
195
+ readonly [K in keyof Shape]: Validatable<Shape[K]>
196
+ },
197
+ private readonly shouldAllowUnknownProperties = false
198
+ ) {
199
+ super((object) => {
200
+ if (typeof object !== 'object' || object === null) {
201
+ throw new ValidationError(`Expected object, got ${typeToString(object)}`)
202
+ }
203
+
204
+ for (const [key, validator] of Object.entries(config)) {
205
+ prefixError(key, () => {
206
+ ;(validator as Validator<unknown>).validate(getOwnProperty(object, key))
207
+ })
208
+ }
209
+
210
+ if (!shouldAllowUnknownProperties) {
211
+ for (const key of Object.keys(object)) {
212
+ if (!hasOwnProperty(config, key)) {
213
+ throw new ValidationError(`Unexpected property`, [key])
214
+ }
215
+ }
216
+ }
217
+
218
+ return object as Shape
219
+ })
220
+ }
221
+
222
+ allowUnknownProperties() {
223
+ return new ObjectValidator(this.config, true)
224
+ }
225
+
226
+ /**
227
+ * Extend an object validator by adding additional properties.
228
+ *
229
+ * @example
230
+ *
231
+ * ```ts
232
+ * const animalValidator = T.object({
233
+ * name: T.string,
234
+ * })
235
+ * const catValidator = animalValidator.extend({
236
+ * meowVolume: T.number,
237
+ * })
238
+ * ```
239
+ */
240
+ extend<Extension extends Record<string, unknown>>(extension: {
241
+ readonly [K in keyof Extension]: Validatable<Extension[K]>
242
+ }): ObjectValidator<Shape & Extension> {
243
+ return new ObjectValidator({ ...this.config, ...extension }) as ObjectValidator<
244
+ Shape & Extension
245
+ >
246
+ }
247
+ }
248
+
249
+ // pass this into itself e.g. Config extends UnionObjectSchemaConfig<Key, Config>
250
+ type UnionValidatorConfig<Key extends string, Config> = {
251
+ readonly [Variant in keyof Config]: Validatable<any> & {
252
+ validate: (input: any) => { readonly [K in Key]: Variant }
253
+ }
254
+ }
255
+ /** @public */
256
+ export class UnionValidator<
257
+ Key extends string,
258
+ Config extends UnionValidatorConfig<Key, Config>,
259
+ UnknownValue = never
260
+ > extends Validator<TypeOf<Config[keyof Config]> | UnknownValue> {
261
+ constructor(
262
+ private readonly key: Key,
263
+ private readonly config: Config,
264
+ private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue
265
+ ) {
266
+ super((input) => {
267
+ if (typeof input !== 'object' || input === null) {
268
+ throw new ValidationError(`Expected an object, got ${typeToString(input)}`, [])
269
+ }
270
+
271
+ const variant = getOwnProperty(input, key) as keyof Config | undefined
272
+ if (typeof variant !== 'string') {
273
+ throw new ValidationError(
274
+ `Expected a string for key "${key}", got ${typeToString(variant)}`
275
+ )
276
+ }
277
+
278
+ const matchingSchema = hasOwnProperty(config, variant) ? config[variant] : undefined
279
+ if (matchingSchema === undefined) {
280
+ return this.unknownValueValidation(input, variant)
281
+ }
282
+
283
+ return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
284
+ })
285
+ }
286
+
287
+ validateUnknownVariants<Unknown>(
288
+ unknownValueValidation: (value: object, variant: string) => Unknown
289
+ ): UnionValidator<Key, Config, Unknown> {
290
+ return new UnionValidator(this.key, this.config, unknownValueValidation)
291
+ }
292
+ }
293
+
294
+ /** @public */
295
+ export class DictValidator<Key extends string, Value> extends Validator<Record<Key, Value>> {
296
+ constructor(
297
+ public readonly keyValidator: Validatable<Key>,
298
+ public readonly valueValidator: Validatable<Value>
299
+ ) {
300
+ super((object) => {
301
+ if (typeof object !== 'object' || object === null) {
302
+ throw new ValidationError(`Expected object, got ${typeToString(object)}`)
303
+ }
304
+
305
+ for (const [key, value] of Object.entries(object)) {
306
+ prefixError(key, () => {
307
+ keyValidator.validate(key)
308
+ valueValidator.validate(value)
309
+ })
310
+ }
311
+
312
+ return object as Record<Key, Value>
313
+ })
314
+ }
315
+ }
316
+
317
+ function typeofValidator<T>(type: string): Validator<T> {
318
+ return new Validator((value) => {
319
+ if (typeof value !== type) {
320
+ throw new ValidationError(`Expected ${type}, got ${typeToString(value)}`)
321
+ }
322
+ return value as T
323
+ })
324
+ }
325
+
326
+ /**
327
+ * Validation that accepts any value. Useful as a starting point for building your own custom
328
+ * validations.
329
+ *
330
+ * @public
331
+ */
332
+ export const unknown = new Validator((value) => value)
333
+ /**
334
+ * Validation that accepts any value. Generally this should be avoided, but you can use it as an
335
+ * escape hatch if you want to work without validations for e.g. a prototype.
336
+ *
337
+ * @public
338
+ */
339
+ export const any = new Validator((value): any => value)
340
+
341
+ /**
342
+ * Validates that a value is a string.
343
+ *
344
+ * @public
345
+ */
346
+ export const string = typeofValidator<string>('string')
347
+
348
+ /**
349
+ * Validates that a value is a finite non-NaN number.
350
+ *
351
+ * @public
352
+ */
353
+ export const number = typeofValidator<number>('number').check((number) => {
354
+ if (Number.isNaN(number)) {
355
+ throw new ValidationError('Expected a number, got NaN')
356
+ }
357
+ if (!Number.isFinite(number)) {
358
+ throw new ValidationError(`Expected a finite number, got ${number}`)
359
+ }
360
+ })
361
+ /**
362
+ * Fails if value \< 0
363
+ *
364
+ * @public
365
+ */
366
+ export const positiveNumber = number.check((value) => {
367
+ if (value < 0) throw new ValidationError(`Expected a positive number, got ${value}`)
368
+ })
369
+ /**
370
+ * Fails if value \<= 0
371
+ *
372
+ * @public
373
+ */
374
+ export const nonZeroNumber = number.check((value) => {
375
+ if (value <= 0) throw new ValidationError(`Expected a non-zero positive number, got ${value}`)
376
+ })
377
+ /**
378
+ * Fails if number is not an integer
379
+ *
380
+ * @public
381
+ */
382
+ export const integer = number.check((value) => {
383
+ if (!Number.isInteger(value)) throw new ValidationError(`Expected an integer, got ${value}`)
384
+ })
385
+ /**
386
+ * Fails if value \< 0 and is not an integer
387
+ *
388
+ * @public
389
+ */
390
+ export const positiveInteger = integer.check((value) => {
391
+ if (value < 0) throw new ValidationError(`Expected a positive integer, got ${value}`)
392
+ })
393
+ /**
394
+ * Fails if value \<= 0 and is not an integer
395
+ *
396
+ * @public
397
+ */
398
+ export const nonZeroInteger = integer.check((value) => {
399
+ if (value <= 0) throw new ValidationError(`Expected a non-zero positive integer, got ${value}`)
400
+ })
401
+
402
+ /**
403
+ * Validates that a value is boolean.
404
+ *
405
+ * @public
406
+ */
407
+ export const boolean = typeofValidator<boolean>('boolean')
408
+ /**
409
+ * Validates that a value is a bigint.
410
+ *
411
+ * @public
412
+ */
413
+ export const bigint = typeofValidator<bigint>('bigint')
414
+ /**
415
+ * Validates that a value matches another that was passed in.
416
+ *
417
+ * @example
418
+ *
419
+ * ```ts
420
+ * const trueValidator = T.literal(true)
421
+ * ```
422
+ *
423
+ * @public
424
+ */
425
+ export function literal<T extends string | number | boolean>(expectedValue: T): Validator<T> {
426
+ return new Validator((actualValue) => {
427
+ if (actualValue !== expectedValue) {
428
+ throw new ValidationError(`Expected ${expectedValue}, got ${JSON.stringify(actualValue)}`)
429
+ }
430
+ return expectedValue
431
+ })
432
+ }
433
+
434
+ /**
435
+ * Validates that a value is an array. To check the contents of the array, use T.arrayOf.
436
+ *
437
+ * @public
438
+ */
439
+ export const array = new Validator<unknown[]>((value) => {
440
+ if (!Array.isArray(value)) {
441
+ throw new ValidationError(`Expected an array, got ${typeToString(value)}`)
442
+ }
443
+ return value
444
+ })
445
+
446
+ /**
447
+ * Validates that a value is an array whose contents matches the passed-in validator.
448
+ *
449
+ * @public
450
+ */
451
+ export function arrayOf<T>(itemValidator: Validatable<T>): ArrayOfValidator<T> {
452
+ return new ArrayOfValidator(itemValidator)
453
+ }
454
+
455
+ /** @public */
456
+ export const unknownObject = new Validator<Record<string, unknown>>((value) => {
457
+ if (typeof value !== 'object' || value === null) {
458
+ throw new ValidationError(`Expected object, got ${typeToString(value)}`)
459
+ }
460
+ return value as Record<string, unknown>
461
+ })
462
+
463
+ /**
464
+ * Validate an object has a particular shape.
465
+ *
466
+ * @public
467
+ */
468
+ export function object<Shape extends object>(config: {
469
+ readonly [K in keyof Shape]: Validatable<Shape[K]>
470
+ }): ObjectValidator<Shape> {
471
+ return new ObjectValidator(config)
472
+ }
473
+
474
+ function isValidJson(value: any): value is JsonValue {
475
+ if (
476
+ value === null ||
477
+ typeof value === 'number' ||
478
+ typeof value === 'string' ||
479
+ typeof value === 'boolean'
480
+ ) {
481
+ return true
482
+ }
483
+
484
+ if (Array.isArray(value)) {
485
+ return value.every(isValidJson)
486
+ }
487
+
488
+ if (typeof value === 'object') {
489
+ return Object.values(value).every(isValidJson)
490
+ }
491
+
492
+ return false
493
+ }
494
+
495
+ /**
496
+ * Validate that a value is valid JSON.
497
+ *
498
+ * @public
499
+ */
500
+ export const jsonValue = new Validator<JsonValue>((value): JsonValue => {
501
+ if (isValidJson(value)) {
502
+ return value as JsonValue
503
+ }
504
+
505
+ throw new ValidationError(`Expected json serializable value, got ${typeof value}`)
506
+ })
507
+
508
+ /**
509
+ * Validate an object has a particular shape.
510
+ *
511
+ * @public
512
+ */
513
+ export function jsonDict(): DictValidator<string, JsonValue> {
514
+ return dict(string, jsonValue)
515
+ }
516
+
517
+ /**
518
+ * Validation that an option is a dict with particular keys and values.
519
+ *
520
+ * @public
521
+ */
522
+ export function dict<Key extends string, Value>(
523
+ keyValidator: Validatable<Key>,
524
+ valueValidator: Validatable<Value>
525
+ ): DictValidator<Key, Value> {
526
+ return new DictValidator(keyValidator, valueValidator)
527
+ }
528
+
529
+ /**
530
+ * Validate a union of several object types. Each object must have a property matching `key` which
531
+ * should be a unique string.
532
+ *
533
+ * @example
534
+ *
535
+ * ```ts
536
+ * const catValidator = T.object({ kind: T.value('cat'), meow: T.boolean })
537
+ * const dogValidator = T.object({ kind: T.value('dog'), bark: T.boolean })
538
+ * const animalValidator = T.union('kind', { cat: catValidator, dog: dogValidator })
539
+ * ```
540
+ *
541
+ * @public
542
+ */
543
+ export function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(
544
+ key: Key,
545
+ config: Config
546
+ ): UnionValidator<Key, Config> {
547
+ return new UnionValidator(key, config, (unknownValue, unknownVariant) => {
548
+ throw new ValidationError(
549
+ `Expected one of ${Object.keys(config)
550
+ .map((key) => JSON.stringify(key))
551
+ .join(' or ')}, got ${JSON.stringify(unknownVariant)}`,
552
+ [key]
553
+ )
554
+ })
555
+ }
556
+
557
+ /**
558
+ * A named object with an ID. Errors will be reported as being part of the object with the given
559
+ * name.
560
+ *
561
+ * @public
562
+ */
563
+ export function model<T extends { readonly id: string }>(
564
+ name: string,
565
+ validator: Validatable<T>
566
+ ): Validator<T> {
567
+ return new Validator((value) => {
568
+ const prefix =
569
+ value && typeof value === 'object' && 'id' in value && typeof value.id === 'string'
570
+ ? `${name}(id = ${value.id})`
571
+ : name
572
+
573
+ return prefixError(prefix, () => validator.validate(value))
574
+ })
575
+ }
576
+
577
+ /** @public */
578
+ export function setEnum<T>(values: ReadonlySet<T>): Validator<T> {
579
+ return new Validator((value) => {
580
+ if (!values.has(value as T)) {
581
+ const valuesString = Array.from(values, (value) => JSON.stringify(value)).join(' or ')
582
+ throw new ValidationError(`Expected ${valuesString}, got ${value}`)
583
+ }
584
+ return value as T
585
+ })
586
+ }
587
+
588
+ /** @public */
589
+ export function optional<T>(validator: Validatable<T>): Validator<T | undefined> {
590
+ return new Validator((value) => {
591
+ if (value === undefined) return undefined
592
+ return validator.validate(value)
593
+ })
594
+ }
595
+
596
+ /** @public */
597
+ export function nullable<T>(validator: Validatable<T>): Validator<T | null> {
598
+ return new Validator((value) => {
599
+ if (value === null) return null
600
+ return validator.validate(value)
601
+ })
602
+ }
603
+
604
+ /** @public */
605
+ export function literalEnum<const Values extends readonly unknown[]>(
606
+ ...values: Values
607
+ ): Validator<Values[number]> {
608
+ return setEnum(new Set(values))
609
+ }