@ultimat3/schema 1.2.0 → 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/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.length < node.minLength) {
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.length > node.maxLength) {
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 (node.pattern !== undefined && !new RegExp(node.pattern).test(value)) {
65
- return fail(path, expected(`${what} matching ${node.pattern}`, value));
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) => makeStringSchema({ ...node, pattern: regex.source }, what, test),
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,