ascertain 2.0.88 → 3.0.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 +323 -153
- package/build/index.cjs +483 -180
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +231 -186
- package/build/index.js +474 -180
- package/build/index.js.map +1 -1
- package/package.json +22 -19
- package/src/index.ts +991 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol for validating object keys against a schema.
|
|
3
|
+
*/
|
|
4
|
+
export const $keys = Symbol.for('@@keys');
|
|
5
|
+
/**
|
|
6
|
+
* Symbol for validating object values against a schema.
|
|
7
|
+
*/
|
|
8
|
+
export const $values = Symbol.for('@@values');
|
|
9
|
+
/**
|
|
10
|
+
* Symbol for enforcing strict object validation (no extra properties allowed).
|
|
11
|
+
*/
|
|
12
|
+
export const $strict = Symbol.for('@@strict');
|
|
13
|
+
|
|
14
|
+
const $op = Symbol.for('@@op');
|
|
15
|
+
|
|
16
|
+
const OR = Symbol.for('@@or');
|
|
17
|
+
const AND = Symbol.for('@@and');
|
|
18
|
+
const OPTIONAL = Symbol.for('@@optional');
|
|
19
|
+
const TUPLE = Symbol.for('@@tuple');
|
|
20
|
+
const DISCRIMINATED = Symbol.for('@@discriminated');
|
|
21
|
+
|
|
22
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
23
|
+
|
|
24
|
+
interface OrShape<T> {
|
|
25
|
+
readonly schemas: Schema<T>[];
|
|
26
|
+
readonly [$op]: typeof OR;
|
|
27
|
+
}
|
|
28
|
+
interface AndShape<T> {
|
|
29
|
+
readonly schemas: Schema<T>[];
|
|
30
|
+
readonly [$op]: typeof AND;
|
|
31
|
+
}
|
|
32
|
+
interface OptionalShape<T> {
|
|
33
|
+
readonly schemas: Schema<T>[];
|
|
34
|
+
readonly [$op]: typeof OPTIONAL;
|
|
35
|
+
}
|
|
36
|
+
interface TupleShape<T> {
|
|
37
|
+
readonly schemas: Schema<T>[];
|
|
38
|
+
readonly [$op]: typeof TUPLE;
|
|
39
|
+
}
|
|
40
|
+
interface DiscriminatedShape<T> {
|
|
41
|
+
readonly schemas: Schema<T>[];
|
|
42
|
+
readonly [$op]: typeof DISCRIMINATED;
|
|
43
|
+
readonly key: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Tagged<T> = OrShape<T> | AndShape<T> | OptionalShape<T> | TupleShape<T> | DiscriminatedShape<T>;
|
|
47
|
+
|
|
48
|
+
const OrCtor = function <T>(this: OrShape<T>, schemas: Schema<T>[]) {
|
|
49
|
+
(this as Mutable<OrShape<T>>).schemas = schemas;
|
|
50
|
+
} as unknown as { new <T>(schemas: Schema<T>[]): OrShape<T>; prototype: { [$op]: typeof OR } };
|
|
51
|
+
OrCtor.prototype[$op] = OR;
|
|
52
|
+
|
|
53
|
+
const AndCtor = function <T>(this: AndShape<T>, schemas: Schema<T>[]) {
|
|
54
|
+
(this as Mutable<AndShape<T>>).schemas = schemas;
|
|
55
|
+
} as unknown as { new <T>(schemas: Schema<T>[]): AndShape<T>; prototype: { [$op]: typeof AND } };
|
|
56
|
+
AndCtor.prototype[$op] = AND;
|
|
57
|
+
|
|
58
|
+
const OptionalCtor = function <T>(this: OptionalShape<T>, schema: Schema<T>) {
|
|
59
|
+
(this as Mutable<OptionalShape<T>>).schemas = [schema];
|
|
60
|
+
} as unknown as { new <T>(schema: Schema<T>): OptionalShape<T>; prototype: { [$op]: typeof OPTIONAL } };
|
|
61
|
+
OptionalCtor.prototype[$op] = OPTIONAL;
|
|
62
|
+
|
|
63
|
+
const TupleCtor = function <T>(this: TupleShape<T>, schemas: Schema<T>[]) {
|
|
64
|
+
(this as Mutable<TupleShape<T>>).schemas = schemas;
|
|
65
|
+
} as unknown as { new <T>(schemas: Schema<T>[]): TupleShape<T>; prototype: { [$op]: typeof TUPLE } };
|
|
66
|
+
TupleCtor.prototype[$op] = TUPLE;
|
|
67
|
+
|
|
68
|
+
const DiscriminatedCtor = function <T>(this: DiscriminatedShape<T>, schemas: Schema<T>[], key: string) {
|
|
69
|
+
(this as Mutable<DiscriminatedShape<T>>).schemas = schemas;
|
|
70
|
+
(this as Mutable<DiscriminatedShape<T>>).key = key;
|
|
71
|
+
} as unknown as { new <T>(schemas: Schema<T>[], key: string): DiscriminatedShape<T>; prototype: { [$op]: typeof DISCRIMINATED } };
|
|
72
|
+
DiscriminatedCtor.prototype[$op] = DISCRIMINATED;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Represents a schema for validating data.
|
|
76
|
+
*
|
|
77
|
+
* Schemas can be defined for various data types, including objects, arrays, and primitives.
|
|
78
|
+
*
|
|
79
|
+
* @template T - The type of data the schema validates.
|
|
80
|
+
*/
|
|
81
|
+
export type Schema<T> =
|
|
82
|
+
T extends Record<string | number | symbol, unknown>
|
|
83
|
+
? { [K in keyof T]?: Schema<T[K]> | unknown } & { [$keys]?: Schema<keyof T> } & { [$values]?: Schema<T[keyof T]> } & { [$strict]?: boolean }
|
|
84
|
+
: T extends Array<infer A>
|
|
85
|
+
? Schema<A>[] | unknown
|
|
86
|
+
: unknown;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Operator for validating data against any of the provided schemas (logical OR).
|
|
90
|
+
*/
|
|
91
|
+
export const or = <T>(...schemas: Schema<T>[]): OrShape<T> => {
|
|
92
|
+
if (schemas.length === 0) throw new TypeError('Operator requires at least one schema');
|
|
93
|
+
return new OrCtor(schemas);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Operator for validating data against all provided schemas (logical AND).
|
|
98
|
+
*/
|
|
99
|
+
export const and = <T>(...schemas: Schema<T>[]): AndShape<T> => {
|
|
100
|
+
if (schemas.length === 0) throw new TypeError('Operator requires at least one schema');
|
|
101
|
+
return new AndCtor(schemas);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Operator for making a schema optional (nullable).
|
|
106
|
+
*/
|
|
107
|
+
export const optional = <T>(schema: Schema<T>): OptionalShape<T> => new OptionalCtor(schema);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Operator for validating data against a fixed-length tuple of schemas.
|
|
111
|
+
*/
|
|
112
|
+
export const tuple = <T>(...schemas: Schema<T>[]): TupleShape<T> => {
|
|
113
|
+
if (schemas.length === 0) throw new TypeError('Operator requires at least one schema');
|
|
114
|
+
return new TupleCtor(schemas);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Operator for validating data against a discriminated union.
|
|
119
|
+
*
|
|
120
|
+
* Optimizes validation by checking the discriminant field first and only
|
|
121
|
+
* validating the matching variant. More efficient than `or()` for unions
|
|
122
|
+
* where each variant has a common field with a unique literal value.
|
|
123
|
+
*
|
|
124
|
+
* @param schemas - Array of object schemas, each with a discriminant field containing a literal value.
|
|
125
|
+
* @param key - The name of the discriminant field present in all variants.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* const messageSchema = discriminated([
|
|
130
|
+
* { type: 'email', address: String },
|
|
131
|
+
* { type: 'sms', phone: String },
|
|
132
|
+
* { type: 'push', token: String },
|
|
133
|
+
* ], 'type');
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export const discriminated = <T>(schemas: Schema<T>[], key: string): DiscriminatedShape<T> => {
|
|
137
|
+
if (schemas.length === 0) throw new TypeError('discriminated requires at least one schema');
|
|
138
|
+
return new DiscriminatedCtor(schemas, key);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Decodes a base64-encoded string to UTF-8.
|
|
143
|
+
*
|
|
144
|
+
* Uses `Buffer` in Node.js environments and `atob` in browsers.
|
|
145
|
+
*
|
|
146
|
+
* @param value - The base64-encoded string to decode.
|
|
147
|
+
* @returns The decoded UTF-8 string.
|
|
148
|
+
*/
|
|
149
|
+
export const fromBase64 =
|
|
150
|
+
/* c8 ignore next */
|
|
151
|
+
typeof Buffer === 'undefined' ? (value: string) => atob(value) : (value: string) => Buffer.from(value, 'base64').toString('utf-8');
|
|
152
|
+
|
|
153
|
+
const MULTIPLIERS = {
|
|
154
|
+
ms: 1,
|
|
155
|
+
s: 1000,
|
|
156
|
+
m: 60000,
|
|
157
|
+
h: 3600000,
|
|
158
|
+
d: 86400000,
|
|
159
|
+
w: 604800000,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const TIME_REGEX = /^(\d*\.?\d*)(ms|s|m|h|d|w)?$/;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Creates a TypeError with the given message, typed as T for deferred error handling.
|
|
166
|
+
*
|
|
167
|
+
* Used by `as.*` conversion utilities to return errors that can be caught
|
|
168
|
+
* during schema validation rather than throwing immediately.
|
|
169
|
+
*
|
|
170
|
+
* @template T - The expected return type (for type compatibility with conversion functions).
|
|
171
|
+
* @param message - The error message.
|
|
172
|
+
* @returns A TypeError instance typed as T.
|
|
173
|
+
*/
|
|
174
|
+
export const asError = <T>(message: string) => new TypeError(message) as unknown as T;
|
|
175
|
+
|
|
176
|
+
export const as = {
|
|
177
|
+
/**
|
|
178
|
+
* Attempts to convert a value to a string.
|
|
179
|
+
*
|
|
180
|
+
* @param value - The value to convert.
|
|
181
|
+
* @returns The value as a string, or a TypeError if not a string.
|
|
182
|
+
*/
|
|
183
|
+
string: (value: string | undefined): string => {
|
|
184
|
+
return typeof value === 'string' ? value : asError(`Invalid value "${value}", expected a string`);
|
|
185
|
+
},
|
|
186
|
+
/**
|
|
187
|
+
* Attempts to convert a value to a number.
|
|
188
|
+
*
|
|
189
|
+
* Supports integers, floats, scientific notation (1e10), and prefixed formats:
|
|
190
|
+
* - Hexadecimal: `0x` or `0X` (e.g., `'0xFF'` → 255)
|
|
191
|
+
* - Octal: `0o` or `0O` (e.g., `'0o77'` → 63)
|
|
192
|
+
* - Binary: `0b` or `0B` (e.g., `'0b1010'` → 10)
|
|
193
|
+
*
|
|
194
|
+
* All formats support optional leading sign (`+` or `-`).
|
|
195
|
+
*
|
|
196
|
+
* @param value - The value to convert (expected to be a string representation of a number).
|
|
197
|
+
* @returns The value as a number, or a TypeError if not a valid number.
|
|
198
|
+
*/
|
|
199
|
+
number: (value: string | undefined): number => {
|
|
200
|
+
if (typeof value !== 'string') {
|
|
201
|
+
return asError(`Invalid value ${value}, expected a valid number`);
|
|
202
|
+
}
|
|
203
|
+
const start = value[0] === '-' || value[0] === '+' ? 1 : 0;
|
|
204
|
+
const c0 = value.charCodeAt(start);
|
|
205
|
+
const c1 = value.charCodeAt(start + 1) | 32;
|
|
206
|
+
|
|
207
|
+
if (c0 === 48 && (c1 === 120 || c1 === 111 || c1 === 98)) {
|
|
208
|
+
// '0' followed by 'x', 'o', or 'b'
|
|
209
|
+
const result = Number(start ? value.slice(1) : value);
|
|
210
|
+
if (Number.isNaN(result)) return asError(`Invalid value ${value}, expected a valid number`);
|
|
211
|
+
return value[0] === '-' ? -result : result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = value.trim() ? Number(value) : NaN;
|
|
215
|
+
return Number.isNaN(result) ? asError(`Invalid value ${value}, expected a valid number`) : result;
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* Attempts to convert a value to a Date object.
|
|
219
|
+
*
|
|
220
|
+
* @param value - The value to convert (expected to be a string representation of a date).
|
|
221
|
+
* @returns The value as a Date object, or a TypeError if not a valid date.
|
|
222
|
+
*/
|
|
223
|
+
date: (value: string | undefined): Date => {
|
|
224
|
+
const result = Date.parse(value as string);
|
|
225
|
+
const date = new Date(result);
|
|
226
|
+
return Number.isNaN(date.valueOf()) ? asError(`Invalid value "${value}", expected a valid date format`) : date;
|
|
227
|
+
},
|
|
228
|
+
/**
|
|
229
|
+
* Attempts to convert a value to a time duration in milliseconds.
|
|
230
|
+
*
|
|
231
|
+
* @param value - The value to convert (e.g., "5s" for 5 seconds).
|
|
232
|
+
* @param conversionFactor - Optional factor to divide the result by (default is 1).
|
|
233
|
+
* @returns The time duration in milliseconds, or a TypeError if the format is invalid.
|
|
234
|
+
*/
|
|
235
|
+
time: (value: string | undefined, conversionFactor = 1): number => {
|
|
236
|
+
if (!value) return asError(`Invalid value ${value}, expected a valid time format`);
|
|
237
|
+
|
|
238
|
+
const matches = value.match(TIME_REGEX);
|
|
239
|
+
if (!matches) return asError(`Invalid value ${value}, expected a valid time format`);
|
|
240
|
+
|
|
241
|
+
const [, amount, unit = 'ms'] = matches;
|
|
242
|
+
const multiplier = MULTIPLIERS[unit as keyof typeof MULTIPLIERS];
|
|
243
|
+
const parsed = parseFloat(amount);
|
|
244
|
+
|
|
245
|
+
if (!multiplier || Number.isNaN(parsed)) {
|
|
246
|
+
return asError(`Invalid value ${value}, expected a valid time format`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return Math.floor((parsed * multiplier) / conversionFactor);
|
|
250
|
+
},
|
|
251
|
+
/**
|
|
252
|
+
* Attempts to convert a value to a boolean.
|
|
253
|
+
*
|
|
254
|
+
* @param value - The boolean like value to convert (e.g., "true", "1", "enabled").
|
|
255
|
+
* @returns The value as a boolean, or a TypeError if it could not be converted to a boolean.
|
|
256
|
+
*/
|
|
257
|
+
boolean: (value: string | undefined): boolean =>
|
|
258
|
+
/^(0|1|true|false|enabled|disabled)$/i.test(value as string)
|
|
259
|
+
? /^(1|true|enabled)$/i.test(value as string)
|
|
260
|
+
: asError(`Invalid value ${value}, expected a boolean like`),
|
|
261
|
+
/**
|
|
262
|
+
* Attempts to convert a string into an array of strings by splitting it using the given delimiter.
|
|
263
|
+
*
|
|
264
|
+
* @param value - The string value to attempt to split into an array.
|
|
265
|
+
* @param delimiter - The character or string used to separate elements in the input string.
|
|
266
|
+
* @returns An array of strings if the conversion is successful, or a TypeError if the value is not a string.
|
|
267
|
+
*/
|
|
268
|
+
array: (value: string | undefined, delimiter: string): string[] => value?.split?.(delimiter) ?? asError(`Invalid value ${value}, expected an array`),
|
|
269
|
+
/**
|
|
270
|
+
* Attempts to parse a JSON string into a JavaScript object.
|
|
271
|
+
*
|
|
272
|
+
* @template T - The expected type of the parsed JSON object.
|
|
273
|
+
* @param value - The JSON string to attempt to parse.
|
|
274
|
+
* @returns The parsed JSON object if successful, or a TypeError if the value is not valid JSON.
|
|
275
|
+
*/
|
|
276
|
+
json: <T = object>(value: string | undefined): T => {
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(value as string);
|
|
279
|
+
} catch {
|
|
280
|
+
return asError(`Invalid value ${value}, expected a valid JSON string`);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
/**
|
|
284
|
+
* Attempts to decode a base64-encoded string.
|
|
285
|
+
*
|
|
286
|
+
* @param value - The base64-encoded string to attempt to decode.
|
|
287
|
+
* @returns The decoded string if successful, or a TypeError if the value is not valid base64.
|
|
288
|
+
*/
|
|
289
|
+
base64: (value: string | undefined): string => {
|
|
290
|
+
try {
|
|
291
|
+
return fromBase64(value as string);
|
|
292
|
+
} catch {
|
|
293
|
+
return asError(`Invalid value ${value}, expected a valid base64 string`);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A class representing the context for schema validation.
|
|
300
|
+
*
|
|
301
|
+
* Stores a registry of values encountered during validation and provides methods for managing it.
|
|
302
|
+
* @internal
|
|
303
|
+
*/
|
|
304
|
+
class Context {
|
|
305
|
+
public readonly registry: unknown[] = [];
|
|
306
|
+
private readonly lookupMap: Map<unknown, number> = new Map();
|
|
307
|
+
private varIndex = 0;
|
|
308
|
+
|
|
309
|
+
register(value: unknown): number {
|
|
310
|
+
const index = this.lookupMap.get(value);
|
|
311
|
+
if (index !== undefined) {
|
|
312
|
+
return index;
|
|
313
|
+
}
|
|
314
|
+
{
|
|
315
|
+
const index = this.registry.push(value) - 1;
|
|
316
|
+
this.lookupMap.set(value, index);
|
|
317
|
+
return index;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
unique(prefix: string) {
|
|
322
|
+
return `${prefix}$$${this.varIndex++}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
type Mode =
|
|
327
|
+
| { fast: true; onFail?: string }
|
|
328
|
+
| { fast: false; firstError: true; issues: string; path: PropertyKey[]; pathExpr: string }
|
|
329
|
+
| { fast: false; firstError: false; issues: string; path: PropertyKey[]; pathExpr: string };
|
|
330
|
+
|
|
331
|
+
const isTagged = (schema: unknown): schema is Tagged<unknown> => (schema as Tagged<unknown>)?.[$op] !== undefined;
|
|
332
|
+
|
|
333
|
+
const childMode = (mode: Exclude<Mode, { fast: true }>, key: PropertyKey | { dynamic: string }): Mode => {
|
|
334
|
+
if (typeof key === 'object' && 'dynamic' in key) {
|
|
335
|
+
return {
|
|
336
|
+
fast: false,
|
|
337
|
+
firstError: mode.firstError,
|
|
338
|
+
issues: mode.issues,
|
|
339
|
+
path: mode.path,
|
|
340
|
+
pathExpr: `[${mode.path.map((k) => JSON.stringify(k)).join(',')}${mode.path.length ? ',' : ''}${key.dynamic}]`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const newPath = [...mode.path, key];
|
|
344
|
+
return { fast: false, firstError: mode.firstError, issues: mode.issues, path: newPath, pathExpr: JSON.stringify(newPath) };
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const toLiteral = (value: unknown): string => (typeof value === 'bigint' ? `${value}n` : JSON.stringify(value));
|
|
348
|
+
|
|
349
|
+
const codeGen = <T>(schema: Schema<T>, context: Context, valuePath: string, mode: Mode): string => {
|
|
350
|
+
const emit = mode.fast
|
|
351
|
+
? null
|
|
352
|
+
: mode.firstError
|
|
353
|
+
? (msg: string) => `${mode.issues} = [{ message: ${msg}, path: ${mode.pathExpr} }]; return ${mode.issues};`
|
|
354
|
+
: (msg: string) => `(${mode.issues} || (${mode.issues} = [])).push({ message: ${msg}, path: ${mode.pathExpr} });`;
|
|
355
|
+
const fail = mode.fast ? (mode.onFail ?? 'return false;') : '';
|
|
356
|
+
|
|
357
|
+
if (isTagged(schema)) {
|
|
358
|
+
const tag = schema[$op];
|
|
359
|
+
if (tag === AND) {
|
|
360
|
+
const valueAlias = context.unique('v');
|
|
361
|
+
const code = schema.schemas.map((s) => codeGen(s, context, valueAlias, mode)).join('\n');
|
|
362
|
+
return `const ${valueAlias} = ${valuePath};\n${code}`;
|
|
363
|
+
} else if (tag === OR) {
|
|
364
|
+
const valueAlias = context.unique('v');
|
|
365
|
+
const foundValid = context.unique('valid');
|
|
366
|
+
if (mode.fast) {
|
|
367
|
+
const branches = schema.schemas.map((s) => {
|
|
368
|
+
const branchValid = context.unique('valid');
|
|
369
|
+
const branchCode = codeGen(s, context, valueAlias, { ...mode, onFail: `${branchValid} = false;` });
|
|
370
|
+
return `if (!${foundValid}) { let ${branchValid} = true; ${branchCode} if (${branchValid}) { ${foundValid} = true; } }`;
|
|
371
|
+
});
|
|
372
|
+
return `const ${valueAlias} = ${valuePath};\nlet ${foundValid} = false;\n${branches.join('\n')}\nif (!${foundValid}) { ${fail} }`;
|
|
373
|
+
} else if (mode.firstError) {
|
|
374
|
+
const firstBranchIssues = context.unique('iss');
|
|
375
|
+
const branches = schema.schemas.map((s, idx) => {
|
|
376
|
+
const branchIssues = context.unique('iss');
|
|
377
|
+
const branchCode = codeGen(s, context, valueAlias, {
|
|
378
|
+
fast: false,
|
|
379
|
+
firstError: true,
|
|
380
|
+
issues: branchIssues,
|
|
381
|
+
path: mode.path,
|
|
382
|
+
pathExpr: mode.pathExpr,
|
|
383
|
+
}).replace(new RegExp(`; return ${branchIssues};`, 'g'), ';');
|
|
384
|
+
if (idx === 0) {
|
|
385
|
+
return `if (!${foundValid}) { let ${branchIssues}; ${branchCode} if (!${branchIssues}) { ${foundValid} = true; } else { ${firstBranchIssues} = ${branchIssues}; } }`;
|
|
386
|
+
}
|
|
387
|
+
return `if (!${foundValid}) { let ${branchIssues}; ${branchCode} if (!${branchIssues}) { ${foundValid} = true; } }`;
|
|
388
|
+
});
|
|
389
|
+
return `const ${valueAlias} = ${valuePath};\nlet ${firstBranchIssues};\nlet ${foundValid} = false;\n${branches.join('\n')}\nif (!${foundValid}) { return ${firstBranchIssues}; }`;
|
|
390
|
+
} else {
|
|
391
|
+
const localIssues = context.unique('iss');
|
|
392
|
+
const branches = schema.schemas.map((s) => {
|
|
393
|
+
const branchIssues = context.unique('iss');
|
|
394
|
+
const branchCode = codeGen(s, context, valueAlias, {
|
|
395
|
+
fast: false,
|
|
396
|
+
firstError: false,
|
|
397
|
+
issues: branchIssues,
|
|
398
|
+
path: mode.path,
|
|
399
|
+
pathExpr: mode.pathExpr,
|
|
400
|
+
});
|
|
401
|
+
return `if (!${foundValid}) { let ${branchIssues}; ${branchCode} if (!${branchIssues}) { ${foundValid} = true; } else { ${localIssues}.push(...${branchIssues}); } }`;
|
|
402
|
+
});
|
|
403
|
+
return `const ${valueAlias} = ${valuePath};\nconst ${localIssues} = [];\nlet ${foundValid} = false;\n${branches.join('\n')}\nif (!${foundValid}) { (${mode.issues} || (${mode.issues} = [])).push(...${localIssues}); }`;
|
|
404
|
+
}
|
|
405
|
+
} else if (tag === OPTIONAL) {
|
|
406
|
+
const valueAlias = context.unique('v');
|
|
407
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} !== undefined && ${valueAlias} !== null) { ${codeGen((schema as OptionalShape<T>).schemas[0], context, valueAlias, mode)} }`;
|
|
408
|
+
} else if (tag === TUPLE) {
|
|
409
|
+
const valueAlias = context.unique('v');
|
|
410
|
+
if (mode.fast) {
|
|
411
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} === null || typeof ${valueAlias} !== 'object' || !Array.isArray(${valueAlias}) || ${valueAlias}.length !== ${schema.schemas.length}) { ${fail} }\n${schema.schemas.map((s, idx) => codeGen(s, context, `${valueAlias}[${idx}]`, mode)).join('\n')}`;
|
|
412
|
+
} else {
|
|
413
|
+
return [
|
|
414
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
415
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`,
|
|
416
|
+
`else if (typeof ${valueAlias} !== 'object') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected an instance of Array\``)} }`,
|
|
417
|
+
`else if (!Array.isArray(${valueAlias})) { ${emit!(`\`Invalid instance of \${${valueAlias}.constructor?.name}, expected an instance of Array\``)} }`,
|
|
418
|
+
`else if (${valueAlias}.length !== ${schema.schemas.length}) { ${emit!(`\`Invalid tuple length \${${valueAlias}.length}, expected ${schema.schemas.length}\``)} }`,
|
|
419
|
+
`else { ${schema.schemas.map((s, idx) => codeGen(s, context, `${valueAlias}[${idx}]`, childMode(mode, idx))).join('\n')} }`,
|
|
420
|
+
].join('\n');
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
const { key, schemas } = schema as DiscriminatedShape<T>;
|
|
424
|
+
const valueAlias = context.unique('v');
|
|
425
|
+
const discriminantAlias = context.unique('d');
|
|
426
|
+
const keyStr = JSON.stringify(key);
|
|
427
|
+
|
|
428
|
+
const variants: { value: unknown; schema: Schema<T> }[] = [];
|
|
429
|
+
for (const s of schemas) {
|
|
430
|
+
if (typeof s !== 'object' || s === null || !(key in s)) {
|
|
431
|
+
throw new TypeError(`discriminated: each schema must have the discriminant key "${key}"`);
|
|
432
|
+
}
|
|
433
|
+
const discriminantValue = (s as Record<string, unknown>)[key];
|
|
434
|
+
if (typeof discriminantValue !== 'string' && typeof discriminantValue !== 'number' && typeof discriminantValue !== 'boolean') {
|
|
435
|
+
throw new TypeError(`discriminated: discriminant value must be a string, number, or boolean literal`);
|
|
436
|
+
}
|
|
437
|
+
variants.push({ value: discriminantValue, schema: s });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (mode.fast) {
|
|
441
|
+
const branches = variants.map(({ value, schema: s }) => {
|
|
442
|
+
const branchCode = codeGen(s, context, valueAlias, mode);
|
|
443
|
+
return `if (${discriminantAlias} === ${JSON.stringify(value)}) { ${branchCode} }`;
|
|
444
|
+
});
|
|
445
|
+
return [
|
|
446
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
447
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined || typeof ${valueAlias} !== 'object' || ${valueAlias} instanceof Error) { ${fail} }`,
|
|
448
|
+
`const ${discriminantAlias} = ${valueAlias}[${keyStr}];`,
|
|
449
|
+
branches.join(' else ') + ` else { ${fail} }`,
|
|
450
|
+
].join('\n');
|
|
451
|
+
} else {
|
|
452
|
+
const validValues = variants.map((v) => JSON.stringify(v.value)).join(', ');
|
|
453
|
+
const branches = variants.map(({ value, schema: s }) => {
|
|
454
|
+
const branchCode = codeGen(s, context, valueAlias, mode);
|
|
455
|
+
return `if (${discriminantAlias} === ${JSON.stringify(value)}) { ${branchCode} }`;
|
|
456
|
+
});
|
|
457
|
+
return [
|
|
458
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
459
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`,
|
|
460
|
+
`else if (typeof ${valueAlias} !== 'object') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected an object\``)} }`,
|
|
461
|
+
`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`,
|
|
462
|
+
`else {`,
|
|
463
|
+
` const ${discriminantAlias} = ${valueAlias}[${keyStr}];`,
|
|
464
|
+
` ${branches.join(' else ')} else { ${emit!(`\`Invalid discriminant value \${JSON.stringify(${discriminantAlias})}, expected one of: ${validValues}\``)} }`,
|
|
465
|
+
`}`,
|
|
466
|
+
].join('\n');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (typeof schema === 'function') {
|
|
472
|
+
const valueAlias = context.unique('v');
|
|
473
|
+
const name = (schema as { name?: string })?.name;
|
|
474
|
+
const s = schema as unknown;
|
|
475
|
+
const primitiveType =
|
|
476
|
+
s === String ? 'string' : s === Number ? 'number' : s === Boolean ? 'boolean' : s === BigInt ? 'bigint' : s === Symbol ? 'symbol' : null;
|
|
477
|
+
|
|
478
|
+
if (mode.fast) {
|
|
479
|
+
if (primitiveType) {
|
|
480
|
+
const checks = [`typeof ${valueAlias} !== '${primitiveType}'`];
|
|
481
|
+
if (primitiveType === 'number') checks.push(`Number.isNaN(${valueAlias})`);
|
|
482
|
+
return `const ${valueAlias} = ${valuePath};\nif (${checks.join(' || ')}) { ${fail} }`;
|
|
483
|
+
} else if (name === 'Function') {
|
|
484
|
+
return `const ${valueAlias} = ${valuePath};\nif (typeof ${valueAlias} !== 'function') { ${fail} }`;
|
|
485
|
+
} else {
|
|
486
|
+
const isError = (schema as unknown) === Error || schema?.prototype instanceof Error;
|
|
487
|
+
const index = context.register(schema);
|
|
488
|
+
const registryAlias = context.unique('r');
|
|
489
|
+
return `const ${valueAlias} = ${valuePath};\nconst ${registryAlias} = ctx.registry[${index}];\nif (${valueAlias} === null || ${valueAlias} === undefined${isError ? '' : ` || ${valueAlias} instanceof Error`} || (typeof ${valueAlias} === 'object' && !(${valueAlias} instanceof ${registryAlias})) || (typeof ${valueAlias} !== 'object' && ${valueAlias}?.constructor !== ${registryAlias}) || Number.isNaN(${valueAlias}?.valueOf?.())) { ${fail} }`;
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
const code: string[] = [`const ${valueAlias} = ${valuePath};`];
|
|
493
|
+
if (primitiveType) {
|
|
494
|
+
code.push(`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`);
|
|
495
|
+
code.push(`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`);
|
|
496
|
+
code.push(`else if (typeof ${valueAlias} !== '${primitiveType}') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected type ${name}\``)} }`);
|
|
497
|
+
if (primitiveType === 'number')
|
|
498
|
+
code.push(`else if (Number.isNaN(${valueAlias})) { ${emit!(`\`Invalid value \${${valueAlias}}, expected a valid ${name}\``)} }`);
|
|
499
|
+
} else if (name === 'Function') {
|
|
500
|
+
code.push(`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`);
|
|
501
|
+
code.push(`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`);
|
|
502
|
+
code.push(`else if (typeof ${valueAlias} !== 'function') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected type Function\``)} }`);
|
|
503
|
+
} else {
|
|
504
|
+
const isError = (schema as unknown) === Error || schema?.prototype instanceof Error;
|
|
505
|
+
const index = context.register(schema);
|
|
506
|
+
const registryAlias = context.unique('r');
|
|
507
|
+
code.push(`const ${registryAlias} = ctx.registry[${index}];`);
|
|
508
|
+
code.push(`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`);
|
|
509
|
+
if (!isError) code.push(`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`);
|
|
510
|
+
code.push(
|
|
511
|
+
`else if (typeof ${valueAlias} === 'object' && !(${valueAlias} instanceof ${registryAlias})) { ${emit!(`\`Invalid instance of \${${valueAlias}?.constructor?.name}, expected an instance of ${name}\``)} }`,
|
|
512
|
+
);
|
|
513
|
+
code.push(
|
|
514
|
+
`else if (typeof ${valueAlias} !== 'object' && ${valueAlias}?.constructor !== ${registryAlias}) { ${emit!(`\`Invalid type \${${valueAlias}?.constructor?.name}, expected type ${name}\``)} }`,
|
|
515
|
+
);
|
|
516
|
+
code.push(`else if (Number.isNaN(${valueAlias}?.valueOf?.())) { ${emit!(`\`Invalid value \${${valueAlias}}, expected a valid ${name}\``)} }`);
|
|
517
|
+
}
|
|
518
|
+
return code.join('\n');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (Array.isArray(schema)) {
|
|
523
|
+
const valueAlias = context.unique('v');
|
|
524
|
+
if (mode.fast) {
|
|
525
|
+
let code = `const ${valueAlias} = ${valuePath};\nif (!Array.isArray(${valueAlias})) { ${fail} }`;
|
|
526
|
+
if (schema.length === 1) {
|
|
527
|
+
const value = context.unique('val');
|
|
528
|
+
const key = context.unique('key');
|
|
529
|
+
code += `\nfor (let ${key} = 0; ${key} < ${valueAlias}.length; ${key}++) { const ${value} = ${valueAlias}[${key}]; ${codeGen(schema[0], context, value, mode)} }`;
|
|
530
|
+
} else if (schema.length > 1) {
|
|
531
|
+
code += `\nif (${valueAlias}.length > ${schema.length}) { ${fail} }`;
|
|
532
|
+
code += '\n' + schema.map((s, idx) => codeGen(s, context, `${valueAlias}[${idx}]`, mode)).join('\n');
|
|
533
|
+
}
|
|
534
|
+
return code;
|
|
535
|
+
} else {
|
|
536
|
+
const code: string[] = [
|
|
537
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
538
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`,
|
|
539
|
+
`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`,
|
|
540
|
+
`else if (typeof ${valueAlias} !== 'object') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected an instance of Array\``)} }`,
|
|
541
|
+
`else if (!Array.isArray(${valueAlias})) { ${emit!(`\`Invalid instance of \${${valueAlias}.constructor?.name}, expected an instance of Array\``)} }`,
|
|
542
|
+
];
|
|
543
|
+
if (schema.length > 0) {
|
|
544
|
+
const value = context.unique('val');
|
|
545
|
+
const key = context.unique('key');
|
|
546
|
+
if (schema.length === 1) {
|
|
547
|
+
// Dynamic key - use runtime concat
|
|
548
|
+
code.push(
|
|
549
|
+
`else { for (let ${key} = 0; ${key} < ${valueAlias}.length; ${key}++) { const ${value} = ${valueAlias}[${key}]; ${codeGen(schema[0], context, value, childMode(mode, { dynamic: key }))} } }`,
|
|
550
|
+
);
|
|
551
|
+
} else {
|
|
552
|
+
code.push(
|
|
553
|
+
`else if (${valueAlias}.length > ${schema.length}) { ${emit!(`\`Invalid tuple length \${${valueAlias}.length}, expected ${schema.length}\``)} }`,
|
|
554
|
+
);
|
|
555
|
+
code.push(`else { ${schema.map((s, idx) => codeGen(s, context, `${valueAlias}[${idx}]`, childMode(mode, idx))).join('\n')} }`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return code.join('\n');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (typeof schema === 'object' && schema !== null) {
|
|
563
|
+
if (schema instanceof RegExp) {
|
|
564
|
+
const valueAlias = context.unique('v');
|
|
565
|
+
if (mode.fast) {
|
|
566
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} === null || ${valueAlias} === undefined || ${valueAlias} instanceof Error || !${schema.toString()}.test(String(${valueAlias}))) { ${fail} }`;
|
|
567
|
+
} else {
|
|
568
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }\nelse if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }\nelse if (!${schema.toString()}.test(String(${valueAlias}))) { ${emit!(`\`Invalid value \${${valueAlias}}, expected to match ${schema.toString()}\``)} }`;
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
const valueAlias = context.unique('v');
|
|
572
|
+
if (mode.fast) {
|
|
573
|
+
let code = `const ${valueAlias} = ${valuePath};\nif (${valueAlias} === null || typeof ${valueAlias} !== 'object' || ${valueAlias} instanceof Error) { ${fail} }`;
|
|
574
|
+
if ($keys in schema) {
|
|
575
|
+
const keysAlias = context.unique('k');
|
|
576
|
+
const kAlias = context.unique('k');
|
|
577
|
+
code += `\nconst ${keysAlias} = Object.keys(${valueAlias});\nfor (const ${kAlias} of ${keysAlias}) { ${codeGen(schema[$keys], context, kAlias, mode)} }`;
|
|
578
|
+
}
|
|
579
|
+
if ($values in schema) {
|
|
580
|
+
const vAlias = context.unique('val');
|
|
581
|
+
const kAlias = context.unique('k');
|
|
582
|
+
const entriesAlias = context.unique('en');
|
|
583
|
+
code += `\nconst ${entriesAlias} = Object.entries(${valueAlias});\nfor (const [${kAlias}, ${vAlias}] of ${entriesAlias}) { ${codeGen(schema[$values], context, vAlias, mode)} }`;
|
|
584
|
+
}
|
|
585
|
+
if ($strict in schema && schema[$strict]) {
|
|
586
|
+
const keysAlias = context.unique('k');
|
|
587
|
+
const kAlias = context.unique('k');
|
|
588
|
+
const extraAlias = context.unique('ex');
|
|
589
|
+
code += `\nconst ${keysAlias} = new Set(${JSON.stringify(Object.keys(schema))});\nconst ${extraAlias} = Object.keys(${valueAlias}).filter(${kAlias} => !${keysAlias}.has(${kAlias}));\nif (${extraAlias}.length !== 0) { ${fail} }`;
|
|
590
|
+
}
|
|
591
|
+
code +=
|
|
592
|
+
'\n' +
|
|
593
|
+
Object.entries(schema)
|
|
594
|
+
.map(([key, s]) => codeGen(s, context, `${valueAlias}[${JSON.stringify(key)}]`, mode))
|
|
595
|
+
.join('\n');
|
|
596
|
+
return code;
|
|
597
|
+
} else {
|
|
598
|
+
const code: string[] = [
|
|
599
|
+
`const ${valueAlias} = ${valuePath};`,
|
|
600
|
+
`if (${valueAlias} === null || ${valueAlias} === undefined) { ${emit!(`\`Invalid value \${${valueAlias}}, expected non-nullable\``)} }`,
|
|
601
|
+
`else if (typeof ${valueAlias} !== 'object') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected an instance of Object\``)} }`,
|
|
602
|
+
`else if (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }`,
|
|
603
|
+
'else {',
|
|
604
|
+
];
|
|
605
|
+
const innerCode: string[] = [];
|
|
606
|
+
if ($keys in schema) {
|
|
607
|
+
const keysAlias = context.unique('k');
|
|
608
|
+
const kAlias = context.unique('k');
|
|
609
|
+
innerCode.push(`const ${keysAlias} = Object.keys(${valueAlias});`);
|
|
610
|
+
// Dynamic key - use runtime concat
|
|
611
|
+
innerCode.push(`for (const ${kAlias} of ${keysAlias}) { ${codeGen(schema[$keys], context, kAlias, childMode(mode, { dynamic: kAlias }))} }`);
|
|
612
|
+
}
|
|
613
|
+
if ($values in schema) {
|
|
614
|
+
const vAlias = context.unique('val');
|
|
615
|
+
const kAlias = context.unique('k');
|
|
616
|
+
const entriesAlias = context.unique('en');
|
|
617
|
+
innerCode.push(`const ${entriesAlias} = Object.entries(${valueAlias});`);
|
|
618
|
+
// Dynamic key - use runtime concat
|
|
619
|
+
innerCode.push(
|
|
620
|
+
`for (const [${kAlias}, ${vAlias}] of ${entriesAlias}) { ${codeGen(schema[$values], context, vAlias, childMode(mode, { dynamic: kAlias }))} }`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if ($strict in schema && schema[$strict]) {
|
|
624
|
+
const keysAlias = context.unique('k');
|
|
625
|
+
const kAlias = context.unique('k');
|
|
626
|
+
const extraAlias = context.unique('ex');
|
|
627
|
+
innerCode.push(`const ${keysAlias} = new Set(${JSON.stringify(Object.keys(schema))});`);
|
|
628
|
+
innerCode.push(`const ${extraAlias} = Object.keys(${valueAlias}).filter(${kAlias} => !${keysAlias}.has(${kAlias}));`);
|
|
629
|
+
innerCode.push(`if (${extraAlias}.length !== 0) { ${emit!(`\`Extra properties: \${${extraAlias}}, are not allowed\``)} }`);
|
|
630
|
+
}
|
|
631
|
+
// Static keys - pre-register paths
|
|
632
|
+
innerCode.push(...Object.entries(schema).map(([key, s]) => codeGen(s, context, `${valueAlias}[${JSON.stringify(key)}]`, childMode(mode, key))));
|
|
633
|
+
code.push(innerCode.join('\n'), '}');
|
|
634
|
+
return code.join('\n');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (typeof schema === 'symbol') {
|
|
640
|
+
const index = context.register(schema);
|
|
641
|
+
const valueAlias = context.unique('v');
|
|
642
|
+
const registryAlias = context.unique('r');
|
|
643
|
+
if (mode.fast) {
|
|
644
|
+
return `const ${valueAlias} = ${valuePath};\nconst ${registryAlias} = ctx.registry[${index}];\nif (typeof ${valueAlias} !== 'symbol' || ${valueAlias} !== ${registryAlias}) { ${fail} }`;
|
|
645
|
+
} else {
|
|
646
|
+
return `const ${valueAlias} = ${valuePath};\nconst ${registryAlias} = ctx.registry[${index}];\nif (typeof ${valueAlias} !== 'symbol') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected symbol\``)} }\nelse if (${valueAlias} !== ${registryAlias}) { ${emit!(`\`Invalid value \${${valueAlias}.toString()}, expected ${schema.toString()}\``)} }`;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (schema === null || schema === undefined) {
|
|
651
|
+
const valueAlias = context.unique('v');
|
|
652
|
+
if (mode.fast) {
|
|
653
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} !== null && ${valueAlias} !== undefined) { ${fail} }`;
|
|
654
|
+
} else {
|
|
655
|
+
return `const ${valueAlias} = ${valuePath};\nif (${valueAlias} !== null && ${valueAlias} !== undefined) { ${emit!(`\`Invalid value \${String(${valueAlias})}, expected nullable\``)} }`;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const valueAlias = context.unique('v');
|
|
660
|
+
if (mode.fast) {
|
|
661
|
+
return `const ${valueAlias} = ${valuePath};\nif (typeof ${valueAlias} !== '${typeof schema}' || ${valueAlias} !== ${toLiteral(schema)}) { ${fail} }`;
|
|
662
|
+
} else {
|
|
663
|
+
const value = context.unique('val');
|
|
664
|
+
return `const ${valueAlias} = ${valuePath};\nconst ${value} = ${toLiteral(schema)};\nif (${valueAlias} instanceof Error) { ${emit!(`\`\${${valueAlias}.message}\``)} }\nelse if (typeof ${valueAlias} !== '${typeof schema}') { ${emit!(`\`Invalid type \${typeof ${valueAlias}}, expected ${typeof schema}\``)} }\nelse if (${valueAlias} !== ${value}) { ${emit!(`\`Invalid value \${String(${valueAlias})}, expected ${toLiteral(schema)}\``)} }`;
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const emptyIssues: StandardSchemaV1.Issue[] = [];
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Validator function returned by compile().
|
|
672
|
+
* Returns true if valid, false if invalid.
|
|
673
|
+
* Access `.issues` property after validation to get error details.
|
|
674
|
+
*/
|
|
675
|
+
export interface Validator<T> {
|
|
676
|
+
(data: T): boolean;
|
|
677
|
+
issues: ReadonlyArray<StandardSchemaV1.Issue>;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Options for the compile function.
|
|
682
|
+
*/
|
|
683
|
+
export interface CompileOptions {
|
|
684
|
+
/**
|
|
685
|
+
* When true, collects all validation errors instead of stopping at the first.
|
|
686
|
+
* Default is false (first-error mode) for optimal performance.
|
|
687
|
+
*/
|
|
688
|
+
allErrors?: boolean;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Compiles a schema into a high-performance validation function.
|
|
693
|
+
*
|
|
694
|
+
* By default uses first-error mode which stops at the first validation failure
|
|
695
|
+
* and returns immediately. This provides optimal performance for invalid data.
|
|
696
|
+
*
|
|
697
|
+
* Set `allErrors: true` to collect all validation errors (slower but more informative).
|
|
698
|
+
*
|
|
699
|
+
* @template T - The type of data the schema validates.
|
|
700
|
+
* @param schema - The schema to compile.
|
|
701
|
+
* @param options - Optional configuration (allErrors: boolean).
|
|
702
|
+
* @returns A validator function that returns boolean. Access `.issues` for error details.
|
|
703
|
+
*
|
|
704
|
+
* @example
|
|
705
|
+
* ```typescript
|
|
706
|
+
* import { compile, optional, or } from 'ascertain';
|
|
707
|
+
*
|
|
708
|
+
* const userSchema = {
|
|
709
|
+
* name: String,
|
|
710
|
+
* age: Number,
|
|
711
|
+
* email: optional(String),
|
|
712
|
+
* role: or('admin', 'user', 'guest')
|
|
713
|
+
* };
|
|
714
|
+
*
|
|
715
|
+
* // First-error mode (default) - fastest for invalid data
|
|
716
|
+
* const validate = compile(userSchema);
|
|
717
|
+
*
|
|
718
|
+
* // All-errors mode - collects all validation issues
|
|
719
|
+
* const validateAll = compile(userSchema, { allErrors: true });
|
|
720
|
+
*
|
|
721
|
+
* // Valid data
|
|
722
|
+
* if (validate({ name: 'John', age: 30, role: 'user' })) {
|
|
723
|
+
* console.log('Valid!');
|
|
724
|
+
* }
|
|
725
|
+
*
|
|
726
|
+
* // Invalid data - check .issues for details
|
|
727
|
+
* if (!validate({ name: 123, age: 'thirty' })) {
|
|
728
|
+
* console.log(validate.issues); // Array with first validation issue
|
|
729
|
+
* }
|
|
730
|
+
* ```
|
|
731
|
+
*/
|
|
732
|
+
export const compile = <T>(schema: Schema<T>, options?: CompileOptions): Validator<T> => {
|
|
733
|
+
const allErrors = options?.allErrors ?? false;
|
|
734
|
+
|
|
735
|
+
if (allErrors) {
|
|
736
|
+
const fastContext = new Context();
|
|
737
|
+
const fastCode = `${codeGen(schema, fastContext, 'data', { fast: true })}\nreturn true;`;
|
|
738
|
+
const fastValidator = new Function('ctx', `return (data) => {\n${fastCode}\n};`)(fastContext) as (data: T) => boolean;
|
|
739
|
+
|
|
740
|
+
const issueContext = new Context();
|
|
741
|
+
const issueCode = `let issues;\n${codeGen(schema, issueContext, 'data', { fast: false, firstError: false, issues: 'issues', path: [], pathExpr: '[]' })}\nreturn issues || [];`;
|
|
742
|
+
const issueValidator = new Function('ctx', `return (data) => {\n${issueCode}\n};`)(issueContext) as (data: T) => StandardSchemaV1.Issue[];
|
|
743
|
+
|
|
744
|
+
const validator = ((data: T): boolean => {
|
|
745
|
+
if (fastValidator(data)) {
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
validator.issues = issueValidator(data);
|
|
749
|
+
return false;
|
|
750
|
+
}) as Validator<T>;
|
|
751
|
+
validator.issues = emptyIssues;
|
|
752
|
+
return validator;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const fastContext = new Context();
|
|
756
|
+
const fastCode = `${codeGen(schema, fastContext, 'data', { fast: true })}\nreturn true;`;
|
|
757
|
+
const fastValidator = new Function('ctx', `return (data) => {\n${fastCode}\n};`)(fastContext) as (data: T) => boolean;
|
|
758
|
+
|
|
759
|
+
const context = new Context();
|
|
760
|
+
const code = codeGen(schema, context, 'data', { fast: false, firstError: true, issues: 'issues', path: [], pathExpr: '[]' });
|
|
761
|
+
const firstErrorValidator = new Function('ctx', `return (data) => {\nlet issues;\n${code}\nreturn issues;\n};`)(context) as (
|
|
762
|
+
data: T,
|
|
763
|
+
) => StandardSchemaV1.Issue[] | undefined;
|
|
764
|
+
|
|
765
|
+
const validator = ((data: T): boolean => {
|
|
766
|
+
if (fastValidator(data)) {
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
validator.issues = firstErrorValidator(data)!;
|
|
770
|
+
return false;
|
|
771
|
+
}) as Validator<T>;
|
|
772
|
+
validator.issues = emptyIssues;
|
|
773
|
+
return validator;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Asserts that data conforms to a given schema.
|
|
778
|
+
*
|
|
779
|
+
* This function is a convenient wrapper around `compile`. It compiles the schema
|
|
780
|
+
* and immediately validates the provided data against it.
|
|
781
|
+
*
|
|
782
|
+
* @template T - The type of data the schema validates.
|
|
783
|
+
* @param schema - The schema to validate against.
|
|
784
|
+
* @param data - The data to validate.
|
|
785
|
+
* @throws `{TypeError}` If the data does not conform to the schema.
|
|
786
|
+
*
|
|
787
|
+
* @example
|
|
788
|
+
* ```typescript
|
|
789
|
+
* import { ascertain, optional, and, or } from 'ascertain';
|
|
790
|
+
*
|
|
791
|
+
* const userSchema = {
|
|
792
|
+
* name: String,
|
|
793
|
+
* age: Number,
|
|
794
|
+
* email: optional(String),
|
|
795
|
+
* active: Boolean
|
|
796
|
+
* };
|
|
797
|
+
*
|
|
798
|
+
* const userData = {
|
|
799
|
+
* name: 'Alice',
|
|
800
|
+
* age: 25,
|
|
801
|
+
* email: 'alice@example.com',
|
|
802
|
+
* active: true
|
|
803
|
+
* };
|
|
804
|
+
*
|
|
805
|
+
* // Validate data - throws if invalid, otherwise continues silently
|
|
806
|
+
* ascertain(userSchema, userData);
|
|
807
|
+
* console.log('User data is valid!');
|
|
808
|
+
*
|
|
809
|
+
* // Example with invalid data
|
|
810
|
+
* try {
|
|
811
|
+
* ascertain(userSchema, {
|
|
812
|
+
* name: 'Bob',
|
|
813
|
+
* age: 'twenty-five', // Invalid: should be number
|
|
814
|
+
* active: true
|
|
815
|
+
* });
|
|
816
|
+
* } catch (error) {
|
|
817
|
+
* console.error('Validation failed:', error.message);
|
|
818
|
+
* }
|
|
819
|
+
* ```
|
|
820
|
+
*
|
|
821
|
+
* @example
|
|
822
|
+
* ```typescript
|
|
823
|
+
* // Array validation
|
|
824
|
+
* const numbersSchema = [Number];
|
|
825
|
+
* const numbers = [1, 2, 3, 4, 5];
|
|
826
|
+
*
|
|
827
|
+
* ascertain(numbersSchema, numbers);
|
|
828
|
+
*
|
|
829
|
+
* // Tuple validation
|
|
830
|
+
* const coordinateSchema = tuple(Number, Number);
|
|
831
|
+
* const point = [10, 20];
|
|
832
|
+
*
|
|
833
|
+
* ascertain(coordinateSchema, point);
|
|
834
|
+
* ```
|
|
835
|
+
*/
|
|
836
|
+
export const ascertain = <T>(schema: Schema<T>, data: T) => {
|
|
837
|
+
const validator = compile(schema);
|
|
838
|
+
if (!validator(data)) {
|
|
839
|
+
throw new TypeError(validator.issues[0].message, { cause: { issues: validator.issues } });
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Extracts the shape of a config object based on the schema keys.
|
|
845
|
+
* Recursively picks only the properties defined in the schema.
|
|
846
|
+
*/
|
|
847
|
+
export type ExtractShape<C, S> = {
|
|
848
|
+
[K in keyof S & keyof C]: S[K] extends object ? (C[K] extends object ? ExtractShape<C[K], S[K]> : C[K]) : C[K];
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Creates a typed validator function for a config object.
|
|
853
|
+
*
|
|
854
|
+
* Returns a function that validates a schema against the config and returns
|
|
855
|
+
* the same config reference with a narrowed type containing only the validated fields.
|
|
856
|
+
*
|
|
857
|
+
* @template C - The type of the config object.
|
|
858
|
+
* @param config - The config object to validate against.
|
|
859
|
+
* @returns A validator function that takes a schema and returns the typed config subset.
|
|
860
|
+
*
|
|
861
|
+
* @example
|
|
862
|
+
* ```typescript
|
|
863
|
+
* import { createValidator, as } from 'ascertain';
|
|
864
|
+
*
|
|
865
|
+
* const config = {
|
|
866
|
+
* app: { name: as.string(process.env.APP_NAME) },
|
|
867
|
+
* kafka: { brokers: as.array(process.env.BROKERS, ',') },
|
|
868
|
+
* redis: { host: as.string(process.env.REDIS_HOST) },
|
|
869
|
+
* };
|
|
870
|
+
*
|
|
871
|
+
* const validate = createValidator(config);
|
|
872
|
+
*
|
|
873
|
+
* // Consumer only validates what it needs
|
|
874
|
+
* const { app, kafka } = validate({
|
|
875
|
+
* app: { name: String },
|
|
876
|
+
* kafka: { brokers: [String] },
|
|
877
|
+
* });
|
|
878
|
+
*
|
|
879
|
+
* // app.name is typed as string
|
|
880
|
+
* // kafka.brokers is typed as string[]
|
|
881
|
+
* // redis is not accessible - TypeScript error
|
|
882
|
+
* ```
|
|
883
|
+
*/
|
|
884
|
+
export const createValidator = <C>(config: C) => {
|
|
885
|
+
return <S extends Schema<Partial<C>>>(schema: S): ExtractShape<C, S> => {
|
|
886
|
+
ascertain(schema as Schema<C>, config);
|
|
887
|
+
return config as ExtractShape<C, S>;
|
|
888
|
+
};
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
892
|
+
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export namespace StandardSchemaV1 {
|
|
896
|
+
export interface Props<Input = unknown, Output = Input> {
|
|
897
|
+
readonly version: 1;
|
|
898
|
+
readonly vendor: string;
|
|
899
|
+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
|
900
|
+
readonly types?: { readonly input: Input; readonly output: Output };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
904
|
+
|
|
905
|
+
export interface SuccessResult<Output> {
|
|
906
|
+
readonly value: Output;
|
|
907
|
+
readonly issues?: undefined;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export interface FailureResult {
|
|
911
|
+
readonly issues: ReadonlyArray<Issue>;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export interface Issue {
|
|
915
|
+
readonly message: string;
|
|
916
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export interface PathSegment {
|
|
920
|
+
readonly key: PropertyKey;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Wraps an Ascertain schema to make it Standard Schema v1 compliant.
|
|
926
|
+
*
|
|
927
|
+
* Creates a validator that implements the Standard Schema specification,
|
|
928
|
+
* enabling interoperability with tools like tRPC, TanStack Form, and other
|
|
929
|
+
* ecosystem libraries that consume Standard Schema-compliant validators.
|
|
930
|
+
*
|
|
931
|
+
* The returned function can be used both as a regular Ascertain validator
|
|
932
|
+
* (throws on error) and as a Standard Schema validator (returns result object).
|
|
933
|
+
*
|
|
934
|
+
* @template T - The type of data the schema validates.
|
|
935
|
+
* @param schema - The Ascertain schema to wrap.
|
|
936
|
+
* @returns A function that validates data, with a `~standard` property for Standard Schema compliance.
|
|
937
|
+
*
|
|
938
|
+
* @see https://standardschema.dev/
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* ```typescript
|
|
942
|
+
* import { standardSchema, or, optional } from 'ascertain';
|
|
943
|
+
*
|
|
944
|
+
* // Create a Standard Schema-compliant validator
|
|
945
|
+
* const userValidator = standardSchema({
|
|
946
|
+
* name: String,
|
|
947
|
+
* age: Number,
|
|
948
|
+
* role: or('admin', 'user'),
|
|
949
|
+
* email: optional(String),
|
|
950
|
+
* });
|
|
951
|
+
*
|
|
952
|
+
* // Use as regular Ascertain validator (throws on error)
|
|
953
|
+
* userValidator({ name: 'Alice', age: 30, role: 'admin' });
|
|
954
|
+
*
|
|
955
|
+
* // Use Standard Schema interface (returns result object)
|
|
956
|
+
* const result = userValidator['~standard'].validate(unknownData);
|
|
957
|
+
* if (result.issues) {
|
|
958
|
+
* console.log(result.issues);
|
|
959
|
+
* } else {
|
|
960
|
+
* console.log(result.value); // typed as User
|
|
961
|
+
* }
|
|
962
|
+
*
|
|
963
|
+
* // Works with tRPC, TanStack Form, etc.
|
|
964
|
+
* ```
|
|
965
|
+
*/
|
|
966
|
+
interface StandardSchemaFn<T> {
|
|
967
|
+
(data: T): void;
|
|
968
|
+
'~standard': StandardSchemaV1.Props<T, T>;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export const standardSchema = <T>(schema: Schema<T>): StandardSchemaFn<T> => {
|
|
972
|
+
const validator = compile(schema);
|
|
973
|
+
|
|
974
|
+
const fn = ((data: T) => {
|
|
975
|
+
if (!validator(data)) {
|
|
976
|
+
throw new TypeError(validator.issues[0].message, { cause: { issues: validator.issues } });
|
|
977
|
+
}
|
|
978
|
+
}) as StandardSchemaFn<T>;
|
|
979
|
+
fn['~standard'] = {
|
|
980
|
+
version: 1 as const,
|
|
981
|
+
vendor: 'ascertain',
|
|
982
|
+
validate: (value: unknown): StandardSchemaV1.Result<T> => {
|
|
983
|
+
if (validator(value as T)) {
|
|
984
|
+
return { value: value as T };
|
|
985
|
+
}
|
|
986
|
+
return { issues: validator.issues };
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
return fn;
|
|
991
|
+
};
|