@ultimat3/schema 1.2.0 → 2.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/CLAUDE.md +90 -0
- package/README.md +54 -3
- package/package.json +2 -1
- package/src/builder.ts +92 -17
- package/src/coerce.ts +41 -7
- package/src/describe-value.ts +71 -0
- package/src/discriminated-union.ts +117 -0
- package/src/errors.ts +20 -0
- package/src/index.ts +15 -12
- package/src/json-schema.ts +91 -9
- package/src/money-value.ts +142 -0
- package/src/node.ts +29 -0
- package/src/t.ts +25 -8
- package/src/validators.ts +102 -49
package/src/validators.ts
CHANGED
|
@@ -6,30 +6,27 @@ import {
|
|
|
6
6
|
type AnySchema,
|
|
7
7
|
type Check,
|
|
8
8
|
checkOf,
|
|
9
|
-
expected,
|
|
10
9
|
fail,
|
|
11
10
|
failWith,
|
|
11
|
+
isPlainObject,
|
|
12
12
|
makeSchema,
|
|
13
13
|
pass,
|
|
14
|
+
type Refinement,
|
|
14
15
|
type Schema,
|
|
15
16
|
type Shape,
|
|
16
17
|
type ShapeInput,
|
|
17
18
|
type ShapeOutput,
|
|
18
19
|
} from './builder';
|
|
20
|
+
import { expected } from './describe-value';
|
|
21
|
+
import { discriminatedUnionSchema } from './discriminated-union';
|
|
22
|
+
import { type MoneyValue, moneySchema } from './money-value';
|
|
19
23
|
import type { SchemaNode } from './node';
|
|
20
24
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
21
25
|
|
|
22
|
-
/** Structurally identical to `Money` in `@ultimat3/money`. Never a float. */
|
|
23
|
-
export interface MoneyValue {
|
|
24
|
-
readonly minor: number;
|
|
25
|
-
readonly currency: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
26
|
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
|
|
29
27
|
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
30
28
|
const CURSOR_RE = /^[A-Za-z0-9_-]+$/;
|
|
31
29
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
32
|
-
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
33
30
|
|
|
34
31
|
export interface StringSchema extends Schema<string, string> {
|
|
35
32
|
min(length: number): StringSchema;
|
|
@@ -52,17 +49,60 @@ export interface ObjectSchema<S extends Shape> extends Schema<ShapeInput<S>, Sha
|
|
|
52
49
|
|
|
53
50
|
type Simplified<S> = { [K in keyof S]: S[K] } & {};
|
|
54
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The literal the caller wrote, flags included. An error quoting `/^[a-z]+$/` for a pattern
|
|
54
|
+
* carrying `i` names something that would have matched the value it just refused.
|
|
55
|
+
*/
|
|
56
|
+
function describePattern(node: SchemaNode): string {
|
|
57
|
+
return node.patternFlags === undefined || node.patternFlags === ''
|
|
58
|
+
? String(node.pattern)
|
|
59
|
+
: `/${node.pattern}/${node.patternFlags}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Characters, not UTF-16 code units — the unit `json-schema.ts` already promises, since JSON
|
|
64
|
+
* Schema defines `minLength`/`maxLength` over code points and the message here has always said
|
|
65
|
+
* "chars". `'👍'.length` is 2, so `t.string.max(1)` refused a value the published schema, a human
|
|
66
|
+
* and Postgres' `char_length` all count as one.
|
|
67
|
+
*
|
|
68
|
+
* Only a surrogate makes the two counts differ, so the string is walked only when one is present
|
|
69
|
+
* — every ASCII value keeps the O(1) read this replaced.
|
|
70
|
+
*/
|
|
71
|
+
const HAS_SURROGATE = /[\uD800-\uDBFF]/;
|
|
72
|
+
|
|
73
|
+
function charCount(value: string): number {
|
|
74
|
+
return HAS_SURROGATE.test(value) ? [...value].length : value.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compiled once per schema, not once per validation. `lastIndex` is reset because a `g` or `y`
|
|
79
|
+
* pattern carries it between calls — a cached global RegExp answers `false` for the second
|
|
80
|
+
* `.test()` of the very value it just accepted, which the per-call construction hid.
|
|
81
|
+
*/
|
|
82
|
+
function patternTester(node: SchemaNode): ((value: string) => boolean) | undefined {
|
|
83
|
+
if (node.pattern === undefined) return undefined;
|
|
84
|
+
const source = node.pattern;
|
|
85
|
+
const flags = node.patternFlags;
|
|
86
|
+
let compiled: RegExp | undefined;
|
|
87
|
+
return (value) => {
|
|
88
|
+
compiled ??= new RegExp(source, flags);
|
|
89
|
+
compiled.lastIndex = 0;
|
|
90
|
+
return compiled.test(value);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
function stringLike(node: SchemaNode, what: string, test?: (value: string) => boolean) {
|
|
95
|
+
const matchesPattern = patternTester(node);
|
|
56
96
|
const check: Check<string> = (value, path) => {
|
|
57
97
|
if (typeof value !== 'string') return fail(path, expected(what, value));
|
|
58
|
-
if (node.minLength !== undefined && value
|
|
98
|
+
if (node.minLength !== undefined && charCount(value) < node.minLength) {
|
|
59
99
|
return fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
|
|
60
100
|
}
|
|
61
|
-
if (node.maxLength !== undefined && value
|
|
101
|
+
if (node.maxLength !== undefined && charCount(value) > node.maxLength) {
|
|
62
102
|
return fail(path, expected(`${what} of at most ${node.maxLength} chars`, value));
|
|
63
103
|
}
|
|
64
|
-
if (
|
|
65
|
-
return fail(path, expected(`${what} matching ${node
|
|
104
|
+
if (matchesPattern !== undefined && !matchesPattern(value)) {
|
|
105
|
+
return fail(path, expected(`${what} matching ${describePattern(node)}`, value));
|
|
66
106
|
}
|
|
67
107
|
if (test !== undefined && !test(value)) return fail(path, expected(what, value));
|
|
68
108
|
return pass(value);
|
|
@@ -80,7 +120,18 @@ function makeStringSchema(
|
|
|
80
120
|
...base,
|
|
81
121
|
min: (length) => makeStringSchema({ ...node, minLength: length }, what, test),
|
|
82
122
|
max: (length) => makeStringSchema({ ...node, maxLength: length }, what, test),
|
|
83
|
-
pattern: (regex) =>
|
|
123
|
+
pattern: (regex) =>
|
|
124
|
+
makeStringSchema(
|
|
125
|
+
// Flags travel with the source: a node holding only `source` rebuilt a *different*
|
|
126
|
+
// RegExp, so `/^[a-z]+$/i` refused `ABC` and quoted the pattern that matches it.
|
|
127
|
+
{
|
|
128
|
+
...node,
|
|
129
|
+
pattern: regex.source,
|
|
130
|
+
...(regex.flags === '' ? {} : { patternFlags: regex.flags }),
|
|
131
|
+
},
|
|
132
|
+
what,
|
|
133
|
+
test,
|
|
134
|
+
),
|
|
84
135
|
};
|
|
85
136
|
}
|
|
86
137
|
|
|
@@ -109,10 +160,6 @@ function makeNumberSchema(node: SchemaNode): NumberSchema {
|
|
|
109
160
|
};
|
|
110
161
|
}
|
|
111
162
|
|
|
112
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
113
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
163
|
export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
117
164
|
const properties: Record<string, SchemaNode> = {};
|
|
118
165
|
const checks: [string, Check<unknown>][] = [];
|
|
@@ -214,6 +261,15 @@ export function unionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
|
214
261
|
});
|
|
215
262
|
}
|
|
216
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Keys that reach an object's prototype rather than its own properties. A record's keys are the
|
|
266
|
+
* caller's, so `{"__proto__":{…}}` on a `{}` literal set the OUTPUT's prototype: `Object.keys`
|
|
267
|
+
* answered `[]` while `settings[k] ?? fallback` handed a handler the attacker's value for a key
|
|
268
|
+
* that was never sent. Refused by name AND built on a null prototype — the null prototype alone
|
|
269
|
+
* would keep `__proto__` as a silent own key nobody declared.
|
|
270
|
+
*/
|
|
271
|
+
const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
|
|
272
|
+
|
|
217
273
|
export function recordSchema<S extends AnySchema>(
|
|
218
274
|
values: S,
|
|
219
275
|
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
|
|
@@ -224,8 +280,15 @@ export function recordSchema<S extends AnySchema>(
|
|
|
224
280
|
(value, path) => {
|
|
225
281
|
if (!isPlainObject(value)) return fail(path, expected('an object', value));
|
|
226
282
|
const issues: StandardIssue[] = [];
|
|
227
|
-
const out: Record<string, unknown> =
|
|
283
|
+
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
228
284
|
for (const [key, entry] of Object.entries(value)) {
|
|
285
|
+
if (PROTOTYPE_KEYS.has(key)) {
|
|
286
|
+
issues.push({
|
|
287
|
+
message: expected(`a record key that is not ${[...PROTOTYPE_KEYS].join(' | ')}`, key),
|
|
288
|
+
path: [...path, key],
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
229
292
|
const result = valueCheck(entry, [...path, key]);
|
|
230
293
|
if (result.ok) out[key] = result.value;
|
|
231
294
|
else issues.push(...result.issues);
|
|
@@ -248,6 +311,18 @@ export function optionalSchema<S extends AnySchema>(
|
|
|
248
311
|
return schema.optional() as Schema<InferInput<S> | undefined, InferOutput<S> | undefined>;
|
|
249
312
|
}
|
|
250
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The free-function spelling of `schema.refine(...)`, shipped beside `t.refine` for the same
|
|
316
|
+
* reason `nullableSchema` ships beside `t.nullable`: a call site that already holds a schema
|
|
317
|
+
* should not have to reach for the namespace.
|
|
318
|
+
*/
|
|
319
|
+
export function refineSchema<In, Out>(
|
|
320
|
+
schema: Schema<In, Out>,
|
|
321
|
+
refinement: Refinement<Out>,
|
|
322
|
+
): Schema<In, Out> {
|
|
323
|
+
return schema.refine(refinement);
|
|
324
|
+
}
|
|
325
|
+
|
|
251
326
|
function isTimeZone(value: string): boolean {
|
|
252
327
|
try {
|
|
253
328
|
new Intl.DateTimeFormat('en', { timeZone: value });
|
|
@@ -283,37 +358,6 @@ const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | strin
|
|
|
283
358
|
},
|
|
284
359
|
);
|
|
285
360
|
|
|
286
|
-
const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>(
|
|
287
|
-
{
|
|
288
|
-
kind: 'money',
|
|
289
|
-
description: 'integer minor units plus an ISO 4217 currency code',
|
|
290
|
-
properties: {
|
|
291
|
-
minor: { kind: 'number', integer: true },
|
|
292
|
-
currency: { kind: 'string', pattern: CURRENCY_RE.source },
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
(value, path) => {
|
|
296
|
-
if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
|
|
297
|
-
const minor = value['minor'];
|
|
298
|
-
const currency = value['currency'];
|
|
299
|
-
const issues: StandardIssue[] = [];
|
|
300
|
-
if (typeof minor !== 'number' || !Number.isInteger(minor)) {
|
|
301
|
-
issues.push({
|
|
302
|
-
message: expected('an integer number of minor units', minor),
|
|
303
|
-
path: [...path, 'minor'],
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) {
|
|
307
|
-
issues.push({
|
|
308
|
-
message: expected('a 3-letter ISO 4217 code', currency),
|
|
309
|
-
path: [...path, 'currency'],
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
if (issues.length > 0) return failWith(issues);
|
|
313
|
-
return pass({ minor: minor as number, currency: currency as string });
|
|
314
|
-
},
|
|
315
|
-
);
|
|
316
|
-
|
|
317
361
|
/** The shape a schema provider must implement to back `t`. */
|
|
318
362
|
export interface TNamespace {
|
|
319
363
|
readonly string: StringSchema;
|
|
@@ -339,6 +383,13 @@ export interface TNamespace {
|
|
|
339
383
|
union<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
340
384
|
...members: S
|
|
341
385
|
): Schema<InferInput<S[number]>, InferOutput<S[number]>>;
|
|
386
|
+
/** A union routed by one literal key: one branch's issues on failure, not every branch's. */
|
|
387
|
+
discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
388
|
+
discriminant: string,
|
|
389
|
+
...members: S
|
|
390
|
+
): Schema<InferInput<S[number]>, InferOutput<S[number]>>;
|
|
391
|
+
/** A rule the IR cannot state structurally — cross-field, cross-row, or arithmetic. */
|
|
392
|
+
refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out>;
|
|
342
393
|
record<S extends AnySchema>(
|
|
343
394
|
values: S,
|
|
344
395
|
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>>;
|
|
@@ -383,6 +434,8 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
383
434
|
enumerated: <const V extends readonly [string, ...string[]]>(...values: V) => enumSchema(values),
|
|
384
435
|
literal: literalSchema,
|
|
385
436
|
union: unionSchema,
|
|
437
|
+
discriminatedUnion: discriminatedUnionSchema,
|
|
438
|
+
refine: refineSchema,
|
|
386
439
|
record: recordSchema,
|
|
387
440
|
nullable: nullableSchema,
|
|
388
441
|
optional: optionalSchema,
|