ascertain 2.0.87 → 2.1.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.
- package/README.md +186 -154
- package/build/index.cjs +65 -19
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +67 -4
- package/build/index.js +62 -19
- package/build/index.js.map +1 -1
- package/package.json +21 -19
- package/src/index.ts +766 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for schema operators.
|
|
3
|
+
*
|
|
4
|
+
* Provides a common constructor that enforces having at least one schema.
|
|
5
|
+
*
|
|
6
|
+
* @template T - The type of data the operator validates.
|
|
7
|
+
* @abstract
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
abstract class Operator<T> {
|
|
11
|
+
constructor(public readonly schemas: Schema<T>[]) {
|
|
12
|
+
if (schemas.length === 0) {
|
|
13
|
+
throw new TypeError(`Operation schema ${this.constructor.name} must have at least one element`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// https://standardschema.dev/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Symbol for validating object keys against a schema.
|
|
22
|
+
*/
|
|
23
|
+
export const $keys = Symbol.for('@@keys');
|
|
24
|
+
/**
|
|
25
|
+
* Symbol for validating object values against a schema.
|
|
26
|
+
*/
|
|
27
|
+
export const $values = Symbol.for('@@values');
|
|
28
|
+
/**
|
|
29
|
+
* Symbol for enforcing strict object validation (no extra properties allowed).
|
|
30
|
+
*/
|
|
31
|
+
export const $strict = Symbol.for('@@strict');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Represents a schema for validating data.
|
|
35
|
+
*
|
|
36
|
+
* Schemas can be defined for various data types, including objects, arrays, and primitives.
|
|
37
|
+
*
|
|
38
|
+
* @template T - The type of data the schema validates.
|
|
39
|
+
*/
|
|
40
|
+
export type Schema<T> =
|
|
41
|
+
T extends Record<string | number | symbol, unknown>
|
|
42
|
+
? { [K in keyof T]?: Schema<T[K]> | unknown } & { [$keys]?: Schema<keyof T> } & { [$values]?: Schema<T[keyof T]> } & { [$strict]?: boolean }
|
|
43
|
+
: T extends Array<infer A>
|
|
44
|
+
? Schema<A>[] | unknown
|
|
45
|
+
: unknown;
|
|
46
|
+
|
|
47
|
+
class Or<T> extends Operator<T> {}
|
|
48
|
+
/**
|
|
49
|
+
* Operator for validating data against any of the provided schemas (logical OR).
|
|
50
|
+
*
|
|
51
|
+
* Creates a schema that accepts data matching any one of the provided schemas.
|
|
52
|
+
* This is useful for creating union types or alternative validation paths.
|
|
53
|
+
*
|
|
54
|
+
* @template T - The type of data the operator validates.
|
|
55
|
+
* @param schemas - Multiple schemas where at least one must match the data.
|
|
56
|
+
* @returns A schema that validates data against any of the provided schemas.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import { or, ascertain } from 'ascertain';
|
|
61
|
+
*
|
|
62
|
+
* // Create a schema that accepts either a string or number
|
|
63
|
+
* const stringOrNumber = or(String, Number);
|
|
64
|
+
*
|
|
65
|
+
* ascertain(stringOrNumber, "hello", "value"); // ✓ Valid
|
|
66
|
+
* ascertain(stringOrNumber, 42, "value"); // ✓ Valid
|
|
67
|
+
* ascertain(stringOrNumber, true, "value"); // ✗ Throws error
|
|
68
|
+
*
|
|
69
|
+
* // Union of literal values
|
|
70
|
+
* const statusSchema = or('pending', 'completed', 'failed');
|
|
71
|
+
* ascertain(statusSchema, 'pending', "status"); // ✓ Valid
|
|
72
|
+
*
|
|
73
|
+
* // Complex schema combinations
|
|
74
|
+
* const userIdSchema = or(Number, { id: Number, temp: Boolean });
|
|
75
|
+
* ascertain(userIdSchema, 123, "userId"); // ✓ Valid
|
|
76
|
+
* ascertain(userIdSchema, { id: 456, temp: true }, "userId"); // ✓ Valid
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export const or = <T>(...schemas: Schema<T>[]) => new Or(schemas);
|
|
80
|
+
|
|
81
|
+
class And<T> extends Operator<T> {}
|
|
82
|
+
/**
|
|
83
|
+
* Operator for validating data against all provided schemas (logical AND).
|
|
84
|
+
*
|
|
85
|
+
* Creates a schema that requires data to match every one of the provided schemas.
|
|
86
|
+
* This is useful for combining multiple validation requirements or adding constraints.
|
|
87
|
+
*
|
|
88
|
+
* @template T - The type of data the operator validates.
|
|
89
|
+
* @param schemas - Multiple schemas that all must match the data.
|
|
90
|
+
* @returns A schema that validates data against all of the provided schemas.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* import { and, ascertain } from 'ascertain';
|
|
95
|
+
*
|
|
96
|
+
* // Combine object schema with additional constraints
|
|
97
|
+
* const userSchema = and(
|
|
98
|
+
* { name: String, age: Number },
|
|
99
|
+
* { age: Number } // Additional constraint
|
|
100
|
+
* );
|
|
101
|
+
*
|
|
102
|
+
* ascertain(userSchema, { name: "John", age: 25 }, "user"); // ✓ Valid
|
|
103
|
+
*
|
|
104
|
+
* // Ensure an object is both a Date and has specific methods
|
|
105
|
+
* const validDateSchema = and(Date, { toISOString: Function });
|
|
106
|
+
* ascertain(validDateSchema, new Date(), "date"); // ✓ Valid
|
|
107
|
+
*
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export const and = <T>(...schemas: Schema<T>[]) => new And(schemas);
|
|
111
|
+
|
|
112
|
+
class Optional<T> extends Operator<T> {
|
|
113
|
+
constructor(schema: Schema<T>) {
|
|
114
|
+
super([schema]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Operator for making a schema optional (nullable).
|
|
119
|
+
*
|
|
120
|
+
* Creates a schema that accepts the provided schema or null/undefined values.
|
|
121
|
+
* This is useful for optional object properties or nullable fields.
|
|
122
|
+
*
|
|
123
|
+
* @template T - The type of data the operator validates.
|
|
124
|
+
* @param schema - The schema to make optional.
|
|
125
|
+
* @returns A schema that validates data against the provided schema or accepts null/undefined.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* import { optional, ascertain } from 'ascertain';
|
|
130
|
+
*
|
|
131
|
+
* // Optional string field
|
|
132
|
+
* const userSchema = {
|
|
133
|
+
* name: String,
|
|
134
|
+
* nickname: optional(String),
|
|
135
|
+
* age: Number
|
|
136
|
+
* };
|
|
137
|
+
*
|
|
138
|
+
* // All of these are valid
|
|
139
|
+
* ascertain(userSchema, {
|
|
140
|
+
* name: "John",
|
|
141
|
+
* nickname: "Johnny",
|
|
142
|
+
* age: 30
|
|
143
|
+
* }, "user"); // ✓ Valid
|
|
144
|
+
*
|
|
145
|
+
* ascertain(userSchema, {
|
|
146
|
+
* name: "Jane",
|
|
147
|
+
* nickname: null,
|
|
148
|
+
* age: 25
|
|
149
|
+
* }, "user"); // ✓ Valid
|
|
150
|
+
*
|
|
151
|
+
* ascertain(userSchema, {
|
|
152
|
+
* name: "Bob",
|
|
153
|
+
* age: 35
|
|
154
|
+
* // nickname is undefined
|
|
155
|
+
* }, "user"); // ✓ Valid
|
|
156
|
+
*
|
|
157
|
+
* // Optional complex objects
|
|
158
|
+
* const profileSchema = {
|
|
159
|
+
* id: Number,
|
|
160
|
+
* settings: optional({
|
|
161
|
+
* theme: String,
|
|
162
|
+
* notifications: Boolean
|
|
163
|
+
* })
|
|
164
|
+
* };
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export const optional = <T>(schema: Schema<T>) => new Optional(schema);
|
|
168
|
+
|
|
169
|
+
class Tuple<T> extends Operator<T> {}
|
|
170
|
+
/**
|
|
171
|
+
* Operator for validating data against a fixed-length tuple of schemas.
|
|
172
|
+
*
|
|
173
|
+
* Creates a schema that validates arrays with a specific length and type for each position.
|
|
174
|
+
* This is useful for coordinate pairs, RGB values, or any fixed-structure data.
|
|
175
|
+
*
|
|
176
|
+
* @template T - The type of data the operator validates (a tuple of types).
|
|
177
|
+
* @param schemas - Schemas for each position in the tuple, in order.
|
|
178
|
+
* @returns A schema that validates data as a tuple with the specified structure.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```typescript
|
|
182
|
+
* import { tuple, ascertain } from 'ascertain';
|
|
183
|
+
*
|
|
184
|
+
* // 2D coordinate tuple
|
|
185
|
+
* const pointSchema = tuple(Number, Number);
|
|
186
|
+
* ascertain(pointSchema, [10, 20], "point"); // ✓ Valid
|
|
187
|
+
* ascertain(pointSchema, [1.5, 2.7], "point"); // ✓ Valid
|
|
188
|
+
* ascertain(pointSchema, [10], "point"); // ✗ Throws error (too short)
|
|
189
|
+
* ascertain(pointSchema, [10, 20, 30], "point"); // ✗ Throws error (too long)
|
|
190
|
+
*
|
|
191
|
+
* // RGB color tuple
|
|
192
|
+
* const colorSchema = tuple(Number, Number, Number);
|
|
193
|
+
* ascertain(colorSchema, [255, 128, 0], "color"); // ✓ Valid
|
|
194
|
+
*
|
|
195
|
+
* // Mixed type tuple
|
|
196
|
+
* const userInfoSchema = tuple(String, Number, Boolean);
|
|
197
|
+
* ascertain(userInfoSchema, ["Alice", 25, true], "userInfo"); // ✓ Valid
|
|
198
|
+
*
|
|
199
|
+
* // Nested tuple
|
|
200
|
+
* const lineSchema = tuple(
|
|
201
|
+
* tuple(Number, Number), // start point
|
|
202
|
+
* tuple(Number, Number) // end point
|
|
203
|
+
* );
|
|
204
|
+
* ascertain(lineSchema, [[0, 0], [10, 10]], "line"); // ✓ Valid
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export const tuple = <T>(...schemas: Schema<T>[]) => new Tuple(schemas);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Decodes a base64-encoded string to UTF-8.
|
|
211
|
+
*
|
|
212
|
+
* Uses `Buffer` in Node.js environments and `atob` in browsers.
|
|
213
|
+
*
|
|
214
|
+
* @param value - The base64-encoded string to decode.
|
|
215
|
+
* @returns The decoded UTF-8 string.
|
|
216
|
+
*/
|
|
217
|
+
export const fromBase64 = typeof Buffer === 'undefined' ? (value: string) => atob(value) : (value: string) => Buffer.from(value, 'base64').toString('utf-8');
|
|
218
|
+
|
|
219
|
+
const MULTIPLIERS = {
|
|
220
|
+
ms: 1,
|
|
221
|
+
s: 1000,
|
|
222
|
+
m: 60000,
|
|
223
|
+
h: 3600000,
|
|
224
|
+
d: 86400000,
|
|
225
|
+
w: 604800000,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const TIME_REGEX = /^(\d*\.?\d*)(ms|s|m|h|d|w)?$/;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Creates a TypeError with the given message, typed as T for deferred error handling.
|
|
232
|
+
*
|
|
233
|
+
* Used by `as.*` conversion utilities to return errors that can be caught
|
|
234
|
+
* during schema validation rather than throwing immediately.
|
|
235
|
+
*
|
|
236
|
+
* @template T - The expected return type (for type compatibility with conversion functions).
|
|
237
|
+
* @param message - The error message.
|
|
238
|
+
* @returns A TypeError instance typed as T.
|
|
239
|
+
*/
|
|
240
|
+
export const asError = <T>(message: string) => new TypeError(message) as unknown as T;
|
|
241
|
+
|
|
242
|
+
export const as = {
|
|
243
|
+
/**
|
|
244
|
+
* Attempts to convert a value to a string.
|
|
245
|
+
*
|
|
246
|
+
* @param value - The value to convert.
|
|
247
|
+
* @returns The value as a string, or a TypeError if not a string.
|
|
248
|
+
*/
|
|
249
|
+
string: (value: string | undefined): string => {
|
|
250
|
+
return typeof value === 'string' ? value : asError(`Invalid value "${value}", expected a string`);
|
|
251
|
+
},
|
|
252
|
+
/**
|
|
253
|
+
* Attempts to convert a value to a number.
|
|
254
|
+
*
|
|
255
|
+
* Supports integers, floats, scientific notation (1e10), and prefixed formats:
|
|
256
|
+
* - Hexadecimal: `0x` or `0X` (e.g., `'0xFF'` → 255)
|
|
257
|
+
* - Octal: `0o` or `0O` (e.g., `'0o77'` → 63)
|
|
258
|
+
* - Binary: `0b` or `0B` (e.g., `'0b1010'` → 10)
|
|
259
|
+
*
|
|
260
|
+
* All formats support optional leading sign (`+` or `-`).
|
|
261
|
+
*
|
|
262
|
+
* @param value - The value to convert (expected to be a string representation of a number).
|
|
263
|
+
* @returns The value as a number, or a TypeError if not a valid number.
|
|
264
|
+
*/
|
|
265
|
+
number: (value: string | undefined): number => {
|
|
266
|
+
if (typeof value !== 'string') {
|
|
267
|
+
return asError(`Invalid value ${value}, expected a valid number`);
|
|
268
|
+
}
|
|
269
|
+
const start = value[0] === '-' || value[0] === '+' ? 1 : 0;
|
|
270
|
+
const c0 = value.charCodeAt(start);
|
|
271
|
+
const c1 = value.charCodeAt(start + 1) | 32;
|
|
272
|
+
|
|
273
|
+
if (c0 === 48 && (c1 === 120 || c1 === 111 || c1 === 98)) {
|
|
274
|
+
// '0' followed by 'x', 'o', or 'b'
|
|
275
|
+
const result = Number(start ? value.slice(1) : value);
|
|
276
|
+
if (Number.isNaN(result)) return asError(`Invalid value ${value}, expected a valid number`);
|
|
277
|
+
return value[0] === '-' ? -result : result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const result = value.includes('.') || value.includes('e') || value.includes('E') ? parseFloat(value) : parseInt(value, 10);
|
|
281
|
+
return Number.isNaN(result) ? asError(`Invalid value ${value}, expected a valid number`) : result;
|
|
282
|
+
},
|
|
283
|
+
/**
|
|
284
|
+
* Attempts to convert a value to a Date object.
|
|
285
|
+
*
|
|
286
|
+
* @param value - The value to convert (expected to be a string representation of a date).
|
|
287
|
+
* @returns The value as a Date object, or a TypeError if not a valid date.
|
|
288
|
+
*/
|
|
289
|
+
date: (value: string | undefined): Date => {
|
|
290
|
+
const result = Date.parse(value as string);
|
|
291
|
+
const date = new Date(result);
|
|
292
|
+
return Number.isNaN(date.valueOf()) ? asError(`Invalid value "${value}", expected a valid date format`) : date;
|
|
293
|
+
},
|
|
294
|
+
/**
|
|
295
|
+
* Attempts to convert a value to a time duration in milliseconds.
|
|
296
|
+
*
|
|
297
|
+
* @param value - The value to convert (e.g., "5s" for 5 seconds).
|
|
298
|
+
* @param conversionFactor - Optional factor to divide the result by (default is 1).
|
|
299
|
+
* @returns The time duration in milliseconds, or a TypeError if the format is invalid.
|
|
300
|
+
*/
|
|
301
|
+
time: (value: string | undefined, conversionFactor = 1): number => {
|
|
302
|
+
if (!value) return asError(`Invalid value ${value}, expected a valid time format`);
|
|
303
|
+
|
|
304
|
+
const matches = value.match(TIME_REGEX);
|
|
305
|
+
if (!matches) return asError(`Invalid value ${value}, expected a valid time format`);
|
|
306
|
+
|
|
307
|
+
const [, amount, unit = 'ms'] = matches;
|
|
308
|
+
const multiplier = MULTIPLIERS[unit as keyof typeof MULTIPLIERS];
|
|
309
|
+
const parsed = parseFloat(amount);
|
|
310
|
+
|
|
311
|
+
if (!multiplier || Number.isNaN(parsed)) {
|
|
312
|
+
return asError(`Invalid value ${value}, expected a valid time format`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return Math.floor((parsed * multiplier) / conversionFactor);
|
|
316
|
+
},
|
|
317
|
+
/**
|
|
318
|
+
* Attempts to convert a value to a boolean.
|
|
319
|
+
*
|
|
320
|
+
* @param value - The boolean like value to convert (e.g., "true", "1", "enabled").
|
|
321
|
+
* @returns The value as a boolean, or a TypeError if it could not be converted to a boolean.
|
|
322
|
+
*/
|
|
323
|
+
boolean: (value: string | undefined): boolean =>
|
|
324
|
+
/^(0|1|true|false|enabled|disabled)$/i.test(value as string)
|
|
325
|
+
? /^(1|true|enabled)$/i.test(value as string)
|
|
326
|
+
: asError(`Invalid value ${value}, expected a boolean like`),
|
|
327
|
+
/**
|
|
328
|
+
* Attempts to convert a string into an array of strings by splitting it using the given delimiter.
|
|
329
|
+
*
|
|
330
|
+
* @param value - The string value to attempt to split into an array.
|
|
331
|
+
* @param delimiter - The character or string used to separate elements in the input string.
|
|
332
|
+
* @returns An array of strings if the conversion is successful, or a TypeError if the value is not a string.
|
|
333
|
+
*/
|
|
334
|
+
array: (value: string | undefined, delimiter: string): string[] => value?.split?.(delimiter) ?? asError(`Invalid value ${value}, expected an array`),
|
|
335
|
+
/**
|
|
336
|
+
* Attempts to parse a JSON string into a JavaScript object.
|
|
337
|
+
*
|
|
338
|
+
* @template T - The expected type of the parsed JSON object.
|
|
339
|
+
* @param value - The JSON string to attempt to parse.
|
|
340
|
+
* @returns The parsed JSON object if successful, or a TypeError if the value is not valid JSON.
|
|
341
|
+
*/
|
|
342
|
+
json: <T = object>(value: string | undefined): T => {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(value as string);
|
|
345
|
+
} catch {
|
|
346
|
+
return asError(`Invalid value ${value}, expected a valid JSON string`);
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
/**
|
|
350
|
+
* Attempts to decode a base64-encoded string.
|
|
351
|
+
*
|
|
352
|
+
* @param value - The base64-encoded string to attempt to decode.
|
|
353
|
+
* @returns The decoded string if successful, or a TypeError if the value is not valid base64.
|
|
354
|
+
*/
|
|
355
|
+
base64: (value: string | undefined): string => {
|
|
356
|
+
try {
|
|
357
|
+
return fromBase64(value as string);
|
|
358
|
+
} catch {
|
|
359
|
+
return asError(`Invalid value ${value}, expected a valid base64 string`);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A class representing the context for schema validation.
|
|
366
|
+
*
|
|
367
|
+
* Stores a registry of values encountered during validation and provides methods for managing it.
|
|
368
|
+
* @internal
|
|
369
|
+
*/
|
|
370
|
+
class Context {
|
|
371
|
+
public readonly registry: unknown[] = [];
|
|
372
|
+
private readonly lookupMap: Map<unknown, number> = new Map();
|
|
373
|
+
private varIndex = 0;
|
|
374
|
+
|
|
375
|
+
register(value: unknown): number {
|
|
376
|
+
const index = this.lookupMap.get(value);
|
|
377
|
+
if (index !== undefined) {
|
|
378
|
+
return index;
|
|
379
|
+
}
|
|
380
|
+
{
|
|
381
|
+
const index = this.registry.push(value) - 1;
|
|
382
|
+
this.lookupMap.set(value, index);
|
|
383
|
+
return index;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
unique(prefix: string) {
|
|
388
|
+
return `${prefix}$$${this.varIndex++}`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const codeGenCollectErrors = (errorsAlias: string, code: string, extra: string = '') => `try {${code}} catch (e) {${errorsAlias}.push(e.message);${extra}}`;
|
|
393
|
+
const codeGenExpectNoErrors = (errorsAlias: string) => `if (${errorsAlias}.length !== 0) { throw new TypeError(${errorsAlias}.join('\\n')); }`;
|
|
394
|
+
const codeGenExpectNonError = (valueAlias: string, path: string) =>
|
|
395
|
+
`if (${valueAlias} instanceof Error) { throw new TypeError(\`\${${valueAlias}.message} for path "${path}".\`); }`;
|
|
396
|
+
const codeGenExpectNonNullable = (valueAlias: string, path: string) =>
|
|
397
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined) { throw new TypeError(\`Invalid value \${${valueAlias}} for path "${path}", expected non-nullable.\`); }`;
|
|
398
|
+
const codeGenExpectObject = (valueAlias: string, path: string, instanceOf: string) =>
|
|
399
|
+
`if (typeof ${valueAlias} !== 'object') { throw new TypeError(\`Invalid type \${typeof ${valueAlias}} for path "${path}", expected an instance of ${instanceOf}\`); }`;
|
|
400
|
+
const codeGenExpectArray = (valueAlias: string, path: string) =>
|
|
401
|
+
`if (!Array.isArray(${valueAlias})) { throw new TypeError(\`Invalid instance of \${${valueAlias}.constructor?.name} for path "${path}", expected an instance of Array.\`); }`;
|
|
402
|
+
|
|
403
|
+
const codeGen = <T>(schema: Schema<T>, context: Context, valuePath: string, path: string): string => {
|
|
404
|
+
if (schema instanceof And) {
|
|
405
|
+
const valueAlias = context.unique('v');
|
|
406
|
+
const errorsAlias = context.unique('err');
|
|
407
|
+
const code = schema.schemas.map((s) => `try { ${codeGen(s, context, valueAlias, path)} } catch (e) { ${errorsAlias}.push(e.message); }`).join('\n');
|
|
408
|
+
return `// And
|
|
409
|
+
const ${errorsAlias} = [];
|
|
410
|
+
const ${valueAlias} = ${valuePath};
|
|
411
|
+
${code}
|
|
412
|
+
${codeGenExpectNoErrors(errorsAlias)}
|
|
413
|
+
`;
|
|
414
|
+
} else if (schema instanceof Or) {
|
|
415
|
+
const valueAlias = context.unique('v');
|
|
416
|
+
const errorsAlias = context.unique('err');
|
|
417
|
+
const code = schema.schemas
|
|
418
|
+
.map((s) => codeGen(s, context, valueAlias, path))
|
|
419
|
+
.reduceRight((result, code) => codeGenCollectErrors(errorsAlias, code, result), codeGenExpectNoErrors(errorsAlias));
|
|
420
|
+
return `// Or
|
|
421
|
+
const ${errorsAlias} = [];
|
|
422
|
+
const ${valueAlias} = ${valuePath};
|
|
423
|
+
${code}
|
|
424
|
+
`;
|
|
425
|
+
} else if (schema instanceof Optional) {
|
|
426
|
+
const valueAlias = context.unique('v');
|
|
427
|
+
return `// Optional
|
|
428
|
+
const ${valueAlias} = ${valuePath};
|
|
429
|
+
if (${valueAlias} !== undefined && ${valueAlias} !== null) { ${codeGen(schema.schemas[0], context, valueAlias, path)} }
|
|
430
|
+
`;
|
|
431
|
+
} else if (schema instanceof Tuple) {
|
|
432
|
+
const valueAlias = context.unique('v');
|
|
433
|
+
const errorsAlias = context.unique('err');
|
|
434
|
+
const code: string[] = [
|
|
435
|
+
'// Tuple',
|
|
436
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
437
|
+
`const ${errorsAlias} = [];`,
|
|
438
|
+
codeGenExpectNonNullable(valueAlias, path),
|
|
439
|
+
codeGenExpectObject(valueAlias, path, 'Array'),
|
|
440
|
+
codeGenExpectArray(valueAlias, path),
|
|
441
|
+
`if (${valueAlias}.length !== ${schema.schemas.length}) { throw new TypeError(\`Invalid tuple length \${${valueAlias}.length} for path "${path}", expected ${schema.schemas.length}.\`); }`,
|
|
442
|
+
...schema.schemas.map((s, idx) => codeGenCollectErrors(errorsAlias, codeGen(s, context, `${valueAlias}[${idx}]`, `${path}[${idx}]`))),
|
|
443
|
+
codeGenExpectNoErrors(errorsAlias),
|
|
444
|
+
];
|
|
445
|
+
return code.join('\n');
|
|
446
|
+
} else if (typeof schema === 'function') {
|
|
447
|
+
const valueAlias = context.unique('v');
|
|
448
|
+
const code: string[] = [`const ${valueAlias} = ${valuePath};`, codeGenExpectNonNullable(valueAlias, path)];
|
|
449
|
+
if ((schema as unknown) !== Error && !(schema?.prototype instanceof Error)) {
|
|
450
|
+
code.push(codeGenExpectNonError(valueAlias, path));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const name = (schema as { name?: string })?.name;
|
|
454
|
+
const primitiveType =
|
|
455
|
+
name === 'String'
|
|
456
|
+
? 'string'
|
|
457
|
+
: name === 'Number'
|
|
458
|
+
? 'number'
|
|
459
|
+
: name === 'Boolean'
|
|
460
|
+
? 'boolean'
|
|
461
|
+
: name === 'BigInt'
|
|
462
|
+
? 'bigint'
|
|
463
|
+
: name === 'Symbol'
|
|
464
|
+
? 'symbol'
|
|
465
|
+
: null;
|
|
466
|
+
|
|
467
|
+
if (primitiveType) {
|
|
468
|
+
code.push(
|
|
469
|
+
`if (typeof ${valueAlias} !== '${primitiveType}') { throw new TypeError(\`Invalid type \${typeof ${valueAlias}} for path "${path}", expected type ${schema?.name}\`); }`,
|
|
470
|
+
);
|
|
471
|
+
if (primitiveType === 'number') {
|
|
472
|
+
code.push(
|
|
473
|
+
`if (Number.isNaN(${valueAlias})) { throw new TypeError(\`Invalid value \${${valueAlias}} for path "${path}", expected a valid ${schema?.name}\`); }`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
} else if (name === 'Function') {
|
|
477
|
+
code.push(
|
|
478
|
+
`if (typeof ${valueAlias} !== 'function') { throw new TypeError(\`Invalid type \${typeof ${valueAlias}} for path "${path}", expected type Function\`); }`,
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
const index = context.register(schema);
|
|
482
|
+
const registryAlias = context.unique('r');
|
|
483
|
+
code.push(
|
|
484
|
+
`const ${registryAlias} = ctx.registry[${index}];`,
|
|
485
|
+
`if (typeof ${valueAlias} === 'object' && !(${valueAlias} instanceof ${registryAlias})) { throw new TypeError(\`Invalid instance of \${${valueAlias}?.constructor?.name} for path "${path}", expected an instance of ${schema?.name}\`); }`,
|
|
486
|
+
`if (typeof ${valueAlias} !== 'object' && ${valueAlias}?.constructor !== ${registryAlias}) { throw new TypeError(\`Invalid type \${${valueAlias}?.constructor?.name} for path "${path}", expected type ${schema?.name}\`); }`,
|
|
487
|
+
`if (Number.isNaN(${valueAlias}?.valueOf?.())) { throw new TypeError(\`Invalid value \${${valueAlias}} for path "${path}", expected a valid ${schema?.name}\`); }`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return code.join('\n');
|
|
491
|
+
} else if (Array.isArray(schema)) {
|
|
492
|
+
const valueAlias = context.unique('v');
|
|
493
|
+
const code: string[] = [
|
|
494
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
495
|
+
codeGenExpectNonNullable(valueAlias, path),
|
|
496
|
+
codeGenExpectNonError(valueAlias, path),
|
|
497
|
+
codeGenExpectObject(valueAlias, path, 'Array'),
|
|
498
|
+
codeGenExpectArray(valueAlias, path),
|
|
499
|
+
];
|
|
500
|
+
if (schema.length > 0) {
|
|
501
|
+
const value = context.unique('val');
|
|
502
|
+
const key = context.unique('key');
|
|
503
|
+
const errorsAlias = context.unique('err');
|
|
504
|
+
code.push(`const ${errorsAlias} = [];`);
|
|
505
|
+
|
|
506
|
+
if (schema.length === 1) {
|
|
507
|
+
code.push(
|
|
508
|
+
...schema.map(
|
|
509
|
+
(s) =>
|
|
510
|
+
`for (let ${key} = 0; ${key} < ${valueAlias}.length; ${key}++) { const ${value} = ${valueAlias}[${key}]; ${codeGenCollectErrors(errorsAlias, codeGen(s, context, value, `${path}[\${${key}}]`))} }`,
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
} else {
|
|
514
|
+
code.push(
|
|
515
|
+
`if (${valueAlias}.length > ${schema.length}) { throw new TypeError(\`Invalid tuple length \${${valueAlias}.length} for path "${path}", expected ${schema.length}.\`); }`,
|
|
516
|
+
);
|
|
517
|
+
code.push(...schema.map((s, idx) => codeGenCollectErrors(errorsAlias, codeGen(s, context, `${valueAlias}[${idx}]`, `${path}[${idx}]`))));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
code.push(codeGenExpectNoErrors(errorsAlias));
|
|
521
|
+
}
|
|
522
|
+
return code.join('\n');
|
|
523
|
+
} else if (typeof schema === 'object' && schema !== null) {
|
|
524
|
+
if (schema instanceof RegExp) {
|
|
525
|
+
const valueAlias = context.unique('v');
|
|
526
|
+
return `
|
|
527
|
+
const ${valueAlias} = ${valuePath};
|
|
528
|
+
${codeGenExpectNonNullable(valueAlias, path)}
|
|
529
|
+
${codeGenExpectNonError(valueAlias, path)}
|
|
530
|
+
if (!${schema.toString()}.test(String(${valueAlias}))) { throw new TypeError(\`Invalid value \${${valueAlias}} for path "${path}", expected to match ${schema.toString()}\`); }
|
|
531
|
+
`;
|
|
532
|
+
} else {
|
|
533
|
+
const valueAlias = context.unique('v');
|
|
534
|
+
const code: string[] = [
|
|
535
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
536
|
+
codeGenExpectNonNullable(valueAlias, path),
|
|
537
|
+
codeGenExpectObject(valueAlias, path, 'Object'),
|
|
538
|
+
codeGenExpectNonError(valueAlias, path),
|
|
539
|
+
];
|
|
540
|
+
if ($keys in schema) {
|
|
541
|
+
const keysAlias = context.unique('k');
|
|
542
|
+
const errorsAlias = context.unique('err');
|
|
543
|
+
const kAlias = context.unique('k');
|
|
544
|
+
code.push(`
|
|
545
|
+
const ${keysAlias} = Object.keys(${valueAlias});
|
|
546
|
+
const ${errorsAlias} = [];
|
|
547
|
+
for (const ${kAlias} of ${keysAlias}) { ${codeGenCollectErrors(errorsAlias, codeGen(schema[$keys], context, kAlias, `${path}[\${${kAlias}}]`))} }
|
|
548
|
+
${codeGenExpectNoErrors(errorsAlias)}
|
|
549
|
+
`);
|
|
550
|
+
}
|
|
551
|
+
if ($values in schema) {
|
|
552
|
+
const vAlias = context.unique('val');
|
|
553
|
+
const kAlias = context.unique('k');
|
|
554
|
+
const entriesAlias = context.unique('en');
|
|
555
|
+
const errorsAlias = context.unique('err');
|
|
556
|
+
code.push(`
|
|
557
|
+
const ${entriesAlias} = Object.entries(${valueAlias});
|
|
558
|
+
const ${errorsAlias} = [];
|
|
559
|
+
for (const [${kAlias}, ${vAlias}] of ${entriesAlias}) { ${codeGenCollectErrors(errorsAlias, codeGen(schema[$values], context, vAlias, `${path}[\${${kAlias}}]`))} }
|
|
560
|
+
${codeGenExpectNoErrors(errorsAlias)}
|
|
561
|
+
`);
|
|
562
|
+
}
|
|
563
|
+
if ($strict in schema && schema[$strict]) {
|
|
564
|
+
const keysAlias = context.unique('k');
|
|
565
|
+
const kAlias = context.unique('k');
|
|
566
|
+
const extraAlias = context.unique('ex');
|
|
567
|
+
code.push(`const ${keysAlias} = new Set(${JSON.stringify(Object.keys(schema))});`);
|
|
568
|
+
code.push(`const ${extraAlias} = Object.keys(${valueAlias}).filter(${kAlias} => !${keysAlias}.has(${kAlias}));`);
|
|
569
|
+
code.push(`if (${extraAlias}.length !== 0) { throw new TypeError(\`Extra properties: \${${extraAlias}}, are not allowed for path "${path}"\`); }`);
|
|
570
|
+
}
|
|
571
|
+
code.push(...Object.entries(schema).map(([key, s]) => codeGen(s, context, `${valueAlias}[${JSON.stringify(key)}]`, `${path}.${key}`)));
|
|
572
|
+
return `${code.join('\n')}`;
|
|
573
|
+
}
|
|
574
|
+
} else if (typeof schema === 'symbol') {
|
|
575
|
+
const index = context.register(schema);
|
|
576
|
+
const valueAlias = context.unique('v');
|
|
577
|
+
const registryAlias = context.unique('r');
|
|
578
|
+
|
|
579
|
+
return `
|
|
580
|
+
const ${valueAlias} = ${valuePath};
|
|
581
|
+
const ${registryAlias} = ctx.registry[${index}];
|
|
582
|
+
if (typeof ${valueAlias} !== 'symbol') { throw new TypeError(\`Invalid type \${typeof ${valueAlias}} for path "${path}", expected symbol\`); }
|
|
583
|
+
if (${valueAlias} !== ${registryAlias}) { throw new TypeError(\`Invalid value \${${valueAlias}.toString()} for path "${path}", expected ${schema.toString()}\`); }
|
|
584
|
+
`;
|
|
585
|
+
} else if (schema === null || schema === undefined) {
|
|
586
|
+
const valueAlias = context.unique('v');
|
|
587
|
+
return `
|
|
588
|
+
const ${valueAlias} = ${valuePath};
|
|
589
|
+
if (${valueAlias} !== null && ${valueAlias} !== undefined ) { throw new TypeError(\`Invalid value \${JSON.stringify(${valueAlias})} for path "${path}", expected nullable\`); }
|
|
590
|
+
`;
|
|
591
|
+
} else {
|
|
592
|
+
const valueAlias = context.unique('v');
|
|
593
|
+
const value = context.unique('val');
|
|
594
|
+
return `
|
|
595
|
+
const ${valueAlias} = ${valuePath};
|
|
596
|
+
const ${value} = ${JSON.stringify(schema)};
|
|
597
|
+
${codeGenExpectNonError(valueAlias, path)}
|
|
598
|
+
if (typeof ${valueAlias} !== '${typeof schema}') { throw new TypeError(\`Invalid type \${typeof ${valueAlias}} for path "${path}", expected ${typeof schema}\`); }
|
|
599
|
+
if (${valueAlias} !== ${value}) { throw new TypeError(\`Invalid value \${JSON.stringify(${valueAlias})} for path "${path}", expected ${JSON.stringify(schema)}\`); }
|
|
600
|
+
`;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Compiles a schema into a validation function.
|
|
606
|
+
*
|
|
607
|
+
* This function takes a schema definition and generates a JavaScript function
|
|
608
|
+
* that can be used to validate data against the schema.
|
|
609
|
+
*
|
|
610
|
+
* @template T - The type of data the schema validates.
|
|
611
|
+
* @param schema - The schema to compile.
|
|
612
|
+
* @param rootName - A name for the root of the data structure (used in error messages).
|
|
613
|
+
* @returns A validation function that takes data as input and throws a TypeError if the data does not conform to the schema.
|
|
614
|
+
*
|
|
615
|
+
* @example
|
|
616
|
+
* ```typescript
|
|
617
|
+
* import { compile, optional, and, or } from 'ascertain';
|
|
618
|
+
*
|
|
619
|
+
* const userSchema = {
|
|
620
|
+
* name: String,
|
|
621
|
+
* age: Number,
|
|
622
|
+
* email: optional(String),
|
|
623
|
+
* role: or('admin', 'user', 'guest')
|
|
624
|
+
* };
|
|
625
|
+
*
|
|
626
|
+
* const validateUser = compile(userSchema, 'User');
|
|
627
|
+
*
|
|
628
|
+
* // Valid data - no error thrown
|
|
629
|
+
* validateUser({
|
|
630
|
+
* name: 'John Doe',
|
|
631
|
+
* age: 30,
|
|
632
|
+
* email: 'john@example.com',
|
|
633
|
+
* role: 'user'
|
|
634
|
+
* });
|
|
635
|
+
*
|
|
636
|
+
* // Invalid data - throws TypeError
|
|
637
|
+
* try {
|
|
638
|
+
* validateUser({
|
|
639
|
+
* name: 123, // Invalid: should be string
|
|
640
|
+
* age: 'thirty' // Invalid: should be number
|
|
641
|
+
* });
|
|
642
|
+
* } catch (error) {
|
|
643
|
+
* console.error(error.message); // Detailed validation errors
|
|
644
|
+
* }
|
|
645
|
+
* ```
|
|
646
|
+
*/
|
|
647
|
+
export const compile = <T>(schema: Schema<T>, rootName: string) => {
|
|
648
|
+
const context = new Context();
|
|
649
|
+
const code = codeGen(schema, context, 'data', rootName);
|
|
650
|
+
const validator = new Function('ctx', 'data', code);
|
|
651
|
+
return (data: T) => validator(context, data);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Asserts that data conforms to a given schema.
|
|
656
|
+
*
|
|
657
|
+
* This function is a convenient wrapper around `compile`. It compiles the schema
|
|
658
|
+
* and immediately validates the provided data against it.
|
|
659
|
+
*
|
|
660
|
+
* @template T - The type of data the schema validates.
|
|
661
|
+
* @param schema - The schema to validate against.
|
|
662
|
+
* @param data - The data to validate.
|
|
663
|
+
* @param rootName - A name for the root of the data structure (used in error messages, defaults to '[root]').
|
|
664
|
+
* @throws `{TypeError}` If the data does not conform to the schema.
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* ```typescript
|
|
668
|
+
* import { ascertain, optional, and, or } from 'ascertain';
|
|
669
|
+
*
|
|
670
|
+
* const userSchema = {
|
|
671
|
+
* name: String,
|
|
672
|
+
* age: Number,
|
|
673
|
+
* email: optional(String),
|
|
674
|
+
* active: Boolean
|
|
675
|
+
* };
|
|
676
|
+
*
|
|
677
|
+
* const userData = {
|
|
678
|
+
* name: 'Alice',
|
|
679
|
+
* age: 25,
|
|
680
|
+
* email: 'alice@example.com',
|
|
681
|
+
* active: true
|
|
682
|
+
* };
|
|
683
|
+
*
|
|
684
|
+
* // Validate data - throws if invalid, otherwise continues silently
|
|
685
|
+
* ascertain(userSchema, userData, 'UserData');
|
|
686
|
+
* console.log('User data is valid!');
|
|
687
|
+
*
|
|
688
|
+
* // Example with invalid data
|
|
689
|
+
* try {
|
|
690
|
+
* ascertain(userSchema, {
|
|
691
|
+
* name: 'Bob',
|
|
692
|
+
* age: 'twenty-five', // Invalid: should be number
|
|
693
|
+
* active: true
|
|
694
|
+
* }, 'UserData');
|
|
695
|
+
* } catch (error) {
|
|
696
|
+
* console.error('Validation failed:', error.message);
|
|
697
|
+
* }
|
|
698
|
+
* ```
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* ```typescript
|
|
702
|
+
* // Array validation
|
|
703
|
+
* const numbersSchema = [Number];
|
|
704
|
+
* const numbers = [1, 2, 3, 4, 5];
|
|
705
|
+
*
|
|
706
|
+
* ascertain(numbersSchema, numbers, 'Numbers');
|
|
707
|
+
*
|
|
708
|
+
* // Tuple validation
|
|
709
|
+
* const coordinateSchema = tuple(Number, Number);
|
|
710
|
+
* const point = [10, 20];
|
|
711
|
+
*
|
|
712
|
+
* ascertain(coordinateSchema, point, 'Point');
|
|
713
|
+
* ```
|
|
714
|
+
*/
|
|
715
|
+
export const ascertain = <T>(schema: Schema<T>, data: T, rootName = '[root]') => {
|
|
716
|
+
compile(schema, rootName)(data);
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Extracts the shape of a config object based on the schema keys.
|
|
721
|
+
* Recursively picks only the properties defined in the schema.
|
|
722
|
+
*/
|
|
723
|
+
export type ExtractShape<C, S> = {
|
|
724
|
+
[K in keyof S & keyof C]: S[K] extends object ? (C[K] extends object ? ExtractShape<C[K], S[K]> : C[K]) : C[K];
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Creates a typed validator function for a config object.
|
|
729
|
+
*
|
|
730
|
+
* Returns a function that validates a schema against the config and returns
|
|
731
|
+
* the same config reference with a narrowed type containing only the validated fields.
|
|
732
|
+
*
|
|
733
|
+
* @template C - The type of the config object.
|
|
734
|
+
* @param config - The config object to validate against.
|
|
735
|
+
* @param rootName - A name for the root of the data structure (used in error messages).
|
|
736
|
+
* @returns A validator function that takes a schema and returns the typed config subset.
|
|
737
|
+
*
|
|
738
|
+
* @example
|
|
739
|
+
* ```typescript
|
|
740
|
+
* import { createValidator, as } from 'ascertain';
|
|
741
|
+
*
|
|
742
|
+
* const config = {
|
|
743
|
+
* app: { name: as.string(process.env.APP_NAME) },
|
|
744
|
+
* kafka: { brokers: as.array(process.env.BROKERS, ',') },
|
|
745
|
+
* redis: { host: as.string(process.env.REDIS_HOST) },
|
|
746
|
+
* };
|
|
747
|
+
*
|
|
748
|
+
* const validate = createValidator(config, '[CONFIG]');
|
|
749
|
+
*
|
|
750
|
+
* // Consumer only validates what it needs
|
|
751
|
+
* const { app, kafka } = validate({
|
|
752
|
+
* app: { name: String },
|
|
753
|
+
* kafka: { brokers: [String] },
|
|
754
|
+
* });
|
|
755
|
+
*
|
|
756
|
+
* // app.name is typed as string
|
|
757
|
+
* // kafka.brokers is typed as string[]
|
|
758
|
+
* // redis is not accessible - TypeScript error
|
|
759
|
+
* ```
|
|
760
|
+
*/
|
|
761
|
+
export const createValidator = <C>(config: C, rootName = '[root]') => {
|
|
762
|
+
return <S extends Schema<Partial<C>>>(schema: S): ExtractShape<C, S> => {
|
|
763
|
+
ascertain(schema as Schema<C>, config, rootName);
|
|
764
|
+
return config as ExtractShape<C, S>;
|
|
765
|
+
};
|
|
766
|
+
};
|