@digitalsamba/validate 2.0.0-canary.1b923c9db3e2
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.
- package/CHANGELOG.md +209 -0
- package/LICENSE +190 -0
- package/README.md +5 -0
- package/dist-cjs/index.d.ts +341 -0
- package/dist-cjs/index.js +41 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/lib/validation.js +418 -0
- package/dist-cjs/lib/validation.js.map +7 -0
- package/dist-esm/index.d.mts +341 -0
- package/dist-esm/index.mjs +17 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/lib/validation.mjs +402 -0
- package/dist-esm/lib/validation.mjs.map +7 -0
- package/package.json +66 -0
- package/src/index.ts +10 -0
- package/src/lib/validation.ts +609 -0
- package/src/test/validation.test.ts +124 -0
|
@@ -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
|
+
}
|