@ix-xs/node-comfort 2.0.0 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Schema.js CHANGED
@@ -15,11 +15,12 @@
15
15
  * });
16
16
  *
17
17
  * const user = User.parse(req.body); // typed, or a ValidationError listing every issue
18
- * const result = User.safeParse(input); // { success, data } or { success, error }
18
+ * const result = User.safeParse(input); // { success, data } or { success, error, issues }
19
19
  */
20
20
 
21
- const { ValidationError } = require("./errors.js");
22
21
  const { types } = require("node:util");
22
+ const { ValidationError } = require("./errors.js");
23
+ const { UNSAFE_KEYS } = require("./internal/shared.js");
23
24
 
24
25
  /**
25
26
  * The type a schema produces.
@@ -49,9 +50,10 @@ const { types } = require("node:util");
49
50
  */
50
51
 
51
52
  /**
52
- * What `safeParse()` returns.
53
+ * What `safeParse()` returns. On failure, `issues` is the plain list and
54
+ * `error` the same thing as a `ValidationError`, built when you read it.
53
55
  * @template T
54
- * @typedef {{ success: true, data: T, error?: undefined } | { success: false, data?: undefined, error: ValidationError }} SafeParseResult
56
+ * @typedef {{ success: true, data: T, error?: undefined, issues?: undefined } | { success: false, data?: undefined, error: ValidationError, issues: import("./errors").ValidationIssue[] }} SafeParseResult
55
57
  */
56
58
 
57
59
  /**
@@ -72,12 +74,45 @@ const { types } = require("node:util");
72
74
  */
73
75
 
74
76
  /**
75
- * A step in a schema: a check, a transformation or a refinement.
76
- * @typedef {{ kind: "check", test: (value: any) => boolean, code: string, message: Message, details?: Record<string, unknown> } | { kind: "transform", fn: (value: any) => any } | { kind: "refine", test: (value: any) => boolean, options: RefineOptions }} Step
77
+ * A step in a schema: a check, a transformation, a refinement or a pipe
78
+ * into another schema.
79
+ * @typedef {{ kind: "check", test: (value: any) => boolean, code: string, message: Message, details?: Record<string, unknown>, json?: Record<string, unknown> } | { kind: "transform", fn: (value: any) => any } | { kind: "refine", test: (value: any) => boolean, options: RefineOptions } | { kind: "pipe", schema: Schema<any> }} Step
80
+ */
81
+
82
+ /**
83
+ * A JSON Schema, as made by `toJSONSchema()`.
84
+ * @typedef {{ [keyword: string]: any }} JSONSchema
85
+ */
86
+
87
+ /**
88
+ * @typedef {{ defs: Record<string, JSONSchema>, names: Map<Schema<any>, string> }} _JSONContext
77
89
  */
78
90
 
79
91
  const INVALID = Symbol("invalid");
80
92
 
93
+ const _TRUE = ["true", "1", "yes", "on", "y"];
94
+ const _FALSE = ["false", "0", "no", "off", "n", ""];
95
+ const _DATE = /^\d{4}-\d{2}-\d{2}$/;
96
+
97
+ /**
98
+ * What a failed `safeParse()` returns. The error is only built if it's
99
+ * read: capturing a stack trace costs far more than the validation itself.
100
+ * @param {import("./errors").ValidationIssue[]} issues
101
+ * @returns {{ success: false, error: ValidationError, issues: import("./errors").ValidationIssue[] }}
102
+ */
103
+ const _failure = (issues) => {
104
+ /** @type {ValidationError | undefined} */
105
+ let error;
106
+ return /** @type {any} */ (Object.defineProperty({ success: false, issues }, "error", { enumerable: true, get: () => (error ??= new ValidationError(issues)) }));
107
+ };
108
+
109
+ /**
110
+ * @param {Schema<any>} schema
111
+ * @param {_JSONContext} ctx
112
+ * @returns {JSONSchema}
113
+ */
114
+ const _jsonOf = (schema, ctx) => /** @type {any} */ (schema)._toJSON(ctx);
115
+
81
116
  /** @param {unknown} value */
82
117
  const _typeName = (value) => {
83
118
  if (value === null) return "null";
@@ -188,7 +223,7 @@ class Schema {
188
223
  let value = input;
189
224
  if (value === undefined && this._default) {
190
225
  const def = this._default.value;
191
- value = typeof def === "function" ? def() : structuredClone(def);
226
+ value = typeof def === "function" ? def() : def !== null && typeof def === "object" ? require("./Obj.js").clone(def) : def;
192
227
  }
193
228
  if (value === undefined && this._optional) return undefined;
194
229
  if (value === null && this._nullable) return null;
@@ -196,14 +231,18 @@ class Schema {
196
231
  let out = this._check(value, ctx);
197
232
  if (ctx.issues.length === before) {
198
233
  for (const step of this._steps) {
199
- if (step.kind === "transform") {
200
- if (ctx.issues.length > before) break;
201
- out = step.fn(out);
202
- } else if (step.kind === "check") {
234
+ if (step.kind === "check") {
203
235
  if (!step.test(out)) _issue(ctx, step.code, _message(step.message, out), step.details);
204
- } else if (ctx.issues.length === before && !step.test(out)) {
236
+ } else if (ctx.issues.length > before) {
237
+ // What follows needs a value that passed everything before it.
238
+ if (step.kind !== "refine") break;
239
+ } else if (step.kind === "transform") {
240
+ out = step.fn(out);
241
+ } else if (step.kind === "pipe") {
242
+ out = _runChild(step.schema, out, ctx);
243
+ } else if (!step.test(out)) {
205
244
  const saved = ctx.path;
206
- ctx.path = [...ctx.path, ...(step.options.path ?? [])];
245
+ if (step.options.path) ctx.path = [...saved, ...step.options.path];
207
246
  _issue(ctx, step.options.code ?? "custom", _message(step.options.message ?? "Invalid value", out));
208
247
  ctx.path = saved;
209
248
  }
@@ -250,14 +289,15 @@ class Schema {
250
289
  * @returns {SafeParseResult<T>}
251
290
  */
252
291
  safeParse(value) {
292
+ /** @type {ParseContext} */
253
293
  const ctx = { issues: [], path: [] };
254
294
  const out = this._run(value, ctx);
255
- if (ctx.issues.length) return { success: false, error: new ValidationError(ctx.issues) };
295
+ if (ctx.issues.length) return _failure(ctx.issues);
256
296
  return { success: true, data: /** @type {T} */ (out) };
257
297
  }
258
298
 
259
299
  /**
260
- * Is the value valid? Works as a type guard. Transformations don't run.
300
+ * Is the value valid? Works as a type guard, and builds no error.
261
301
  *
262
302
  * @example
263
303
  * if (Email.is(input)) sendMail(input); // input is a string here
@@ -266,7 +306,10 @@ class Schema {
266
306
  * @returns {value is T}
267
307
  */
268
308
  is(value) {
269
- return this.safeParse(value).success;
309
+ /** @type {ParseContext} */
310
+ const ctx = { issues: [], path: [] };
311
+ this._run(value, ctx);
312
+ return ctx.issues.length === 0;
270
313
  }
271
314
 
272
315
  /**
@@ -394,6 +437,76 @@ class Schema {
394
437
  return array(/** @type {Schema<T>} */ (this));
395
438
  }
396
439
 
440
+ /**
441
+ * Sends the result into another schema. With `transform()` in between,
442
+ * it validates what a conversion produced.
443
+ *
444
+ * @example
445
+ * const Port = s.string().transform(Number).pipe(s.number().int().min(1).max(65535));
446
+ * Port.parse("8080"); // 8080
447
+ * Port.parse("abc"); // ValidationError: Expected number, received NaN
448
+ *
449
+ * @template U
450
+ * @param {Schema<U>} next
451
+ * @returns {Schema<U>}
452
+ */
453
+ pipe(next) {
454
+ return /** @type {any} */ (this._with({ kind: "pipe", schema: next }));
455
+ }
456
+
457
+ /**
458
+ * Describes what this schema accepts as a JSON Schema (draft 2020-12).
459
+ * Use it for an OpenAPI document, a form generator, or the input of a
460
+ * tool you give to a language model. Rules that only exist in code,
461
+ * like `refine()`, `transform()` and what follows a `pipe()`, can't be
462
+ * expressed and are left out.
463
+ *
464
+ * @example
465
+ * s.object({ name: s.string().min(2), age: s.number().int().optional() }).toJSONSchema();
466
+ * // {
467
+ * // type: "object",
468
+ * // properties: { name: { type: "string", minLength: 2 }, age: { type: "integer" } },
469
+ * // required: ["name"],
470
+ * // }
471
+ *
472
+ * @returns {JSONSchema}
473
+ */
474
+ toJSONSchema() {
475
+ /** @type {_JSONContext} */
476
+ const ctx = { defs: {}, names: new Map() };
477
+ const out = this._toJSON(ctx);
478
+ if (ctx.names.size) out.$defs = ctx.defs;
479
+ return out;
480
+ }
481
+
482
+ /**
483
+ * The JSON Schema of the type itself. Subclasses override it.
484
+ * @param {_JSONContext} _ctx
485
+ * @returns {JSONSchema}
486
+ * @protected
487
+ */
488
+ _json(_ctx) {
489
+ return {};
490
+ }
491
+
492
+ /**
493
+ * The JSON Schema with what every schema can carry: rules, `null`,
494
+ * a description, a default.
495
+ * @param {_JSONContext} ctx
496
+ * @returns {JSONSchema}
497
+ * @protected
498
+ */
499
+ _toJSON(ctx) {
500
+ let out = this._json(ctx);
501
+ for (const step of this._steps) {
502
+ if (step.kind === "check" && step.json) Object.assign(out, step.json);
503
+ }
504
+ if (this._nullable) out = { anyOf: [out, { type: "null" }] };
505
+ if (this._description !== undefined) out.description = this._description;
506
+ if (this._default && typeof this._default.value !== "function") out.default = this._default.value;
507
+ return out;
508
+ }
509
+
397
510
  /**
398
511
  * Attaches a description, available as `.description`. Useful to generate
399
512
  * docs or forms.
@@ -441,11 +554,12 @@ class StringSchema extends Schema {
441
554
  * @param {string} code
442
555
  * @param {Message} message
443
556
  * @param {Record<string, unknown>} [details]
557
+ * @param {Record<string, unknown>} [json] What the rule is called in JSON Schema.
444
558
  * @returns {this}
445
559
  * @protected
446
560
  */
447
- _rule(test, code, message, details) {
448
- return this._with({ kind: "check", test, code, message, details });
561
+ _rule(test, code, message, details, json) {
562
+ return this._with({ kind: "check", test, code, message, details, json });
449
563
  }
450
564
 
451
565
  /**
@@ -455,7 +569,7 @@ class StringSchema extends Schema {
455
569
  * @returns {this}
456
570
  */
457
571
  min(length, message = `Must contain at least ${length} character${length > 1 ? "s" : ""}`) {
458
- return this._rule((v) => v.length >= length, "too_small", message, { minimum: length });
572
+ return this._rule((v) => v.length >= length, "too_small", message, { minimum: length }, { minLength: length });
459
573
  }
460
574
 
461
575
  /**
@@ -465,7 +579,7 @@ class StringSchema extends Schema {
465
579
  * @returns {this}
466
580
  */
467
581
  max(length, message = `Must contain at most ${length} character${length > 1 ? "s" : ""}`) {
468
- return this._rule((v) => v.length <= length, "too_big", message, { maximum: length });
582
+ return this._rule((v) => v.length <= length, "too_big", message, { maximum: length }, { maxLength: length });
469
583
  }
470
584
 
471
585
  /**
@@ -475,7 +589,7 @@ class StringSchema extends Schema {
475
589
  * @returns {this}
476
590
  */
477
591
  length(length, message = `Must contain exactly ${length} characters`) {
478
- return this._rule((v) => v.length === length, "invalid_length", message, { exact: length });
592
+ return this._rule((v) => v.length === length, "invalid_length", message, { exact: length }, { minLength: length, maxLength: length });
479
593
  }
480
594
 
481
595
  /**
@@ -484,7 +598,7 @@ class StringSchema extends Schema {
484
598
  * @returns {this}
485
599
  */
486
600
  nonempty(message = "Must not be empty") {
487
- return this._rule((v) => v.length > 0, "too_small", message, { minimum: 1 });
601
+ return this._rule((v) => v.length > 0, "too_small", message, { minimum: 1 }, { minLength: 1 });
488
602
  }
489
603
 
490
604
  /**
@@ -494,7 +608,7 @@ class StringSchema extends Schema {
494
608
  */
495
609
  email(message = "Invalid email address") {
496
610
  const { isEmail } = require("./Checker.js");
497
- return this._rule(isEmail, "invalid_string", message, { validation: "email" });
611
+ return this._rule(isEmail, "invalid_string", message, { validation: "email" }, { format: "email" });
498
612
  }
499
613
 
500
614
  /**
@@ -505,7 +619,7 @@ class StringSchema extends Schema {
505
619
  url(options = "Invalid URL") {
506
620
  const opts = typeof options === "object" ? options : { message: options };
507
621
  const { isURL } = require("./Checker.js");
508
- return this._rule((v) => isURL(v, { protocols: opts.protocols }), "invalid_string", opts.message ?? "Invalid URL", { validation: "url" });
622
+ return this._rule((v) => isURL(v, { protocols: opts.protocols }), "invalid_string", opts.message ?? "Invalid URL", { validation: "url" }, { format: "uri" });
509
623
  }
510
624
 
511
625
  /**
@@ -515,7 +629,7 @@ class StringSchema extends Schema {
515
629
  */
516
630
  uuid(message = "Invalid UUID") {
517
631
  const { isUUID } = require("./Checker.js");
518
- return this._rule((v) => isUUID(v), "invalid_string", message, { validation: "uuid" });
632
+ return this._rule((v) => isUUID(v), "invalid_string", message, { validation: "uuid" }, { format: "uuid" });
519
633
  }
520
634
 
521
635
  /**
@@ -527,7 +641,7 @@ class StringSchema extends Schema {
527
641
  ip(versionOrMessage, message = "Invalid IP address") {
528
642
  const version = typeof versionOrMessage === "number" ? versionOrMessage : undefined;
529
643
  const { isIP } = require("./Checker.js");
530
- return this._rule((v) => isIP(v, version), "invalid_string", typeof versionOrMessage === "number" ? message : versionOrMessage ?? message, { validation: "ip" });
644
+ return this._rule((v) => isIP(v, version), "invalid_string", typeof versionOrMessage === "number" ? message : versionOrMessage ?? message, { validation: "ip" }, version === 4 ? { format: "ipv4" } : version === 6 ? { format: "ipv6" } : undefined);
531
645
  }
532
646
 
533
647
  /**
@@ -537,7 +651,7 @@ class StringSchema extends Schema {
537
651
  */
538
652
  datetime(message = "Invalid ISO date") {
539
653
  const { isISODate } = require("./Checker.js");
540
- return this._rule(isISODate, "invalid_string", message, { validation: "datetime" });
654
+ return this._rule(isISODate, "invalid_string", message, { validation: "datetime" }, { format: "date-time" });
541
655
  }
542
656
 
543
657
  /**
@@ -550,7 +664,7 @@ class StringSchema extends Schema {
550
664
  return this._rule((v) => {
551
665
  pattern.lastIndex = 0;
552
666
  return pattern.test(v);
553
- }, "invalid_string", message, { validation: "regex" });
667
+ }, "invalid_string", message, { validation: "regex" }, { pattern: pattern.source });
554
668
  }
555
669
 
556
670
  /**
@@ -606,6 +720,84 @@ class StringSchema extends Schema {
606
720
  toUpperCase() {
607
721
  return this._with({ kind: "transform", fn: (/** @type {string} */ v) => v.toUpperCase() });
608
722
  }
723
+
724
+ /**
725
+ * A calendar date like `2024-01-31`, without a time.
726
+ * @param {Message} [message]
727
+ * @returns {this}
728
+ */
729
+ date(message = "Invalid date, expected YYYY-MM-DD") {
730
+ return this._rule((v) => _DATE.test(v) && new Date(`${v}T00:00:00Z`).toISOString().startsWith(v), "invalid_string", message, { validation: "date" }, { format: "date" });
731
+ }
732
+
733
+ /**
734
+ * A URL slug like `my-first-post`: lowercase letters, digits and single dashes.
735
+ * @param {Message} [message]
736
+ * @returns {this}
737
+ */
738
+ slug(message = "Invalid slug") {
739
+ const { isSlug } = require("./Checker.js");
740
+ return this._rule(isSlug, "invalid_string", message, { validation: "slug" }, { pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" });
741
+ }
742
+
743
+ /**
744
+ * Hexadecimal digits only.
745
+ * @param {Message} [message]
746
+ * @returns {this}
747
+ */
748
+ hex(message = "Invalid hexadecimal string") {
749
+ const { isHex } = require("./Checker.js");
750
+ return this._rule((v) => isHex(v), "invalid_string", message, { validation: "hex" }, { pattern: "^[0-9a-fA-F]+$" });
751
+ }
752
+
753
+ /**
754
+ * Base64 data.
755
+ * @param {Message} [message]
756
+ * @returns {this}
757
+ */
758
+ base64(message = "Invalid Base64 string") {
759
+ const { isBase64 } = require("./Checker.js");
760
+ return this._rule((v) => isBase64(v), "invalid_string", message, { validation: "base64" }, { contentEncoding: "base64" });
761
+ }
762
+
763
+ /**
764
+ * A ULID.
765
+ * @param {Message} [message]
766
+ * @returns {this}
767
+ */
768
+ ulid(message = "Invalid ULID") {
769
+ const { isULID } = require("./Checker.js");
770
+ return this._rule(isULID, "invalid_string", message, { validation: "ulid" });
771
+ }
772
+
773
+ /**
774
+ * A semantic version like `1.4.0` or `2.0.0-beta.1`.
775
+ * @param {Message} [message]
776
+ * @returns {this}
777
+ */
778
+ semver(message = "Invalid version") {
779
+ const { isSemver } = require("./Checker.js");
780
+ return this._rule(isSemver, "invalid_string", message, { validation: "semver" });
781
+ }
782
+
783
+ /**
784
+ * A host name like `api.example.com`.
785
+ * @param {Message} [message]
786
+ * @returns {this}
787
+ */
788
+ hostname(message = "Invalid host name") {
789
+ const { isHostname } = require("./Checker.js");
790
+ return this._rule((v) => isHostname(v), "invalid_string", message, { validation: "hostname" }, { format: "hostname" });
791
+ }
792
+
793
+ /**
794
+ * @returns {JSONSchema}
795
+ * @override
796
+ * @protected
797
+ */
798
+ _json() {
799
+ return { type: "string" };
800
+ }
609
801
  }
610
802
 
611
803
  /**
@@ -642,11 +834,12 @@ class NumberSchema extends Schema {
642
834
  * @param {string} code
643
835
  * @param {Message} message
644
836
  * @param {Record<string, unknown>} [details]
837
+ * @param {Record<string, unknown>} [json] What the rule is called in JSON Schema.
645
838
  * @returns {this}
646
839
  * @protected
647
840
  */
648
- _rule(test, code, message, details) {
649
- return this._with({ kind: "check", test, code, message, details });
841
+ _rule(test, code, message, details, json) {
842
+ return this._with({ kind: "check", test, code, message, details, json });
650
843
  }
651
844
 
652
845
  /**
@@ -656,7 +849,7 @@ class NumberSchema extends Schema {
656
849
  * @returns {this}
657
850
  */
658
851
  min(value, message = `Must be greater than or equal to ${value}`) {
659
- return this._rule((v) => v >= value, "too_small", message, { minimum: value });
852
+ return this._rule((v) => v >= value, "too_small", message, { minimum: value }, { minimum: value });
660
853
  }
661
854
 
662
855
  /**
@@ -666,7 +859,7 @@ class NumberSchema extends Schema {
666
859
  * @returns {this}
667
860
  */
668
861
  max(value, message = `Must be less than or equal to ${value}`) {
669
- return this._rule((v) => v <= value, "too_big", message, { maximum: value });
862
+ return this._rule((v) => v <= value, "too_big", message, { maximum: value }, { maximum: value });
670
863
  }
671
864
 
672
865
  /**
@@ -675,7 +868,7 @@ class NumberSchema extends Schema {
675
868
  * @returns {this}
676
869
  */
677
870
  int(message = "Must be an integer") {
678
- return this._rule(Number.isInteger, "invalid_type", message, { expected: "integer" });
871
+ return this._rule(Number.isInteger, "invalid_type", message, { expected: "integer" }, { type: "integer" });
679
872
  }
680
873
 
681
874
  /**
@@ -684,7 +877,7 @@ class NumberSchema extends Schema {
684
877
  * @returns {this}
685
878
  */
686
879
  positive(message = "Must be positive") {
687
- return this._rule((v) => v > 0, "too_small", message, { minimum: 0, exclusive: true });
880
+ return this._rule((v) => v > 0, "too_small", message, { minimum: 0, exclusive: true }, { exclusiveMinimum: 0 });
688
881
  }
689
882
 
690
883
  /**
@@ -693,7 +886,7 @@ class NumberSchema extends Schema {
693
886
  * @returns {this}
694
887
  */
695
888
  nonnegative(message = "Must not be negative") {
696
- return this._rule((v) => v >= 0, "too_small", message, { minimum: 0 });
889
+ return this._rule((v) => v >= 0, "too_small", message, { minimum: 0 }, { minimum: 0 });
697
890
  }
698
891
 
699
892
  /**
@@ -702,7 +895,7 @@ class NumberSchema extends Schema {
702
895
  * @returns {this}
703
896
  */
704
897
  negative(message = "Must be negative") {
705
- return this._rule((v) => v < 0, "too_big", message, { maximum: 0, exclusive: true });
898
+ return this._rule((v) => v < 0, "too_big", message, { maximum: 0, exclusive: true }, { exclusiveMaximum: 0 });
706
899
  }
707
900
 
708
901
  /**
@@ -712,7 +905,46 @@ class NumberSchema extends Schema {
712
905
  * @returns {this}
713
906
  */
714
907
  multipleOf(step, message = `Must be a multiple of ${step}`) {
715
- return this._rule((v) => Math.abs(v / step - Math.round(v / step)) < 1e-9, "not_multiple_of", message, { multipleOf: step });
908
+ return this._rule((v) => Math.abs(v / step - Math.round(v / step)) < 1e-9, "not_multiple_of", message, { multipleOf: step }, { multipleOf: step });
909
+ }
910
+
911
+ /**
912
+ * Greater than `value`, which itself is refused.
913
+ * @param {number} value
914
+ * @param {Message} [message]
915
+ * @returns {this}
916
+ */
917
+ gt(value, message = `Must be greater than ${value}`) {
918
+ return this._rule((v) => v > value, "too_small", message, { minimum: value, exclusive: true }, { exclusiveMinimum: value });
919
+ }
920
+
921
+ /**
922
+ * Less than `value`, which itself is refused.
923
+ * @param {number} value
924
+ * @param {Message} [message]
925
+ * @returns {this}
926
+ */
927
+ lt(value, message = `Must be less than ${value}`) {
928
+ return this._rule((v) => v < value, "too_big", message, { maximum: value, exclusive: true }, { exclusiveMaximum: value });
929
+ }
930
+
931
+ /**
932
+ * An integer JavaScript can hold exactly: up to 2^53 - 1 either way.
933
+ * Ids from a database or an API should pass this before any arithmetic.
934
+ * @param {Message} [message]
935
+ * @returns {this}
936
+ */
937
+ safe(message = "Must be a safe integer") {
938
+ return this._rule(Number.isSafeInteger, "invalid_type", message, { expected: "safe integer" }, { type: "integer", minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER });
939
+ }
940
+
941
+ /**
942
+ * @returns {JSONSchema}
943
+ * @override
944
+ * @protected
945
+ */
946
+ _json() {
947
+ return { type: "number" };
716
948
  }
717
949
  }
718
950
 
@@ -736,11 +968,20 @@ class BooleanSchema extends Schema {
736
968
  if (typeof value === "boolean") return value;
737
969
  if (this._coerce) {
738
970
  const text = String(value).trim().toLowerCase();
739
- if (["true", "1", "yes", "on", "y"].includes(text)) return true;
740
- if (["false", "0", "no", "off", "n", ""].includes(text)) return false;
971
+ if (_TRUE.includes(text)) return true;
972
+ if (_FALSE.includes(text)) return false;
741
973
  }
742
974
  return _typeIssue(ctx, "boolean", value, this._typeMessage);
743
975
  }
976
+
977
+ /**
978
+ * @returns {JSONSchema}
979
+ * @override
980
+ * @protected
981
+ */
982
+ _json() {
983
+ return { type: "boolean" };
984
+ }
744
985
  }
745
986
 
746
987
  /**
@@ -770,6 +1011,15 @@ class BigIntSchema extends Schema {
770
1011
  }
771
1012
  return _typeIssue(ctx, "bigint", value, this._typeMessage);
772
1013
  }
1014
+
1015
+ /**
1016
+ * @returns {JSONSchema}
1017
+ * @override
1018
+ * @protected
1019
+ */
1020
+ _json() {
1021
+ return { type: "integer" };
1022
+ }
773
1023
  }
774
1024
 
775
1025
  /**
@@ -817,6 +1067,15 @@ class DateSchema extends Schema {
817
1067
  const limit = new Date(date).getTime();
818
1068
  return this._with({ kind: "check", test: (/** @type {Date} */ v) => v.getTime() <= limit, code: "too_big", message: message ?? `Must be on or before ${new Date(limit).toISOString()}` });
819
1069
  }
1070
+
1071
+ /**
1072
+ * @returns {JSONSchema}
1073
+ * @override
1074
+ * @protected
1075
+ */
1076
+ _json() {
1077
+ return { type: "string", format: "date-time" };
1078
+ }
820
1079
  }
821
1080
 
822
1081
  /**
@@ -831,6 +1090,8 @@ class LiteralSchema extends Schema {
831
1090
  super();
832
1091
  /** @protected */
833
1092
  this._values = values;
1093
+ /** @protected @type {Set<unknown>} */
1094
+ this._set = new Set(values);
834
1095
  }
835
1096
 
836
1097
  /**
@@ -841,12 +1102,23 @@ class LiteralSchema extends Schema {
841
1102
  * @protected
842
1103
  */
843
1104
  _check(value, ctx) {
844
- if (this._values.some((v) => Object.is(v, value))) return value;
1105
+ // A Set treats 0 and -0 as the same value: tell them apart, as before.
1106
+ if (this._set.has(value) && (value !== 0 || this._values.some((v) => Object.is(v, value)))) return value;
845
1107
  const list = this._values.map((v) => JSON.stringify(v)).join(" | ");
846
1108
  const code = this._values.length > 1 ? "invalid_enum_value" : "invalid_literal";
847
1109
  return _issue(ctx, code, this._typeMessage ? _message(this._typeMessage, value) : `Expected ${list}, received ${JSON.stringify(value) ?? String(value)}`, { expected: list, received: _typeName(value) });
848
1110
  }
849
1111
 
1112
+ /**
1113
+ * @returns {JSONSchema}
1114
+ * @override
1115
+ * @protected
1116
+ */
1117
+ _json() {
1118
+ const values = this._values.map((v) => (typeof v === "bigint" ? Number(v) : v === undefined ? null : v));
1119
+ return values.length === 1 ? { const: values[0] } : values.every((v) => typeof v === "string") ? { type: "string", enum: values } : { enum: values };
1120
+ }
1121
+
850
1122
  /** The accepted values. */
851
1123
  get options() {
852
1124
  return [...this._values];
@@ -911,12 +1183,13 @@ class ArraySchema extends Schema {
911
1183
  */
912
1184
  _check(value, ctx) {
913
1185
  if (!Array.isArray(value)) return _typeIssue(ctx, "array", value, this._typeMessage);
914
- const out = [];
1186
+ const item = this._item;
1187
+ const path = ctx.path;
1188
+ const out = new Array(value.length);
915
1189
  for (let i = 0; i < value.length; i++) {
916
- const saved = ctx.path;
917
- ctx.path = [...saved, i];
918
- out.push(_runChild(this._item, value[i], ctx));
919
- ctx.path = saved;
1190
+ path.push(i);
1191
+ out[i] = _runChild(item, value[i], ctx);
1192
+ path.pop();
920
1193
  }
921
1194
  return out;
922
1195
  }
@@ -925,11 +1198,13 @@ class ArraySchema extends Schema {
925
1198
  * @param {(v: T[]) => boolean} test
926
1199
  * @param {string} code
927
1200
  * @param {Message} message
1201
+ * @param {Record<string, unknown>} [details]
1202
+ * @param {Record<string, unknown>} [json]
928
1203
  * @returns {this}
929
1204
  * @protected
930
1205
  */
931
- _rule(test, code, message) {
932
- return this._with({ kind: "check", test, code, message });
1206
+ _rule(test, code, message, details, json) {
1207
+ return this._with({ kind: "check", test, code, message, details, json });
933
1208
  }
934
1209
 
935
1210
  /**
@@ -939,7 +1214,7 @@ class ArraySchema extends Schema {
939
1214
  * @returns {this}
940
1215
  */
941
1216
  min(count, message = `Must contain at least ${count} item${count > 1 ? "s" : ""}`) {
942
- return this._rule((v) => v.length >= count, "too_small", message);
1217
+ return this._rule((v) => v.length >= count, "too_small", message, { minimum: count }, { minItems: count });
943
1218
  }
944
1219
 
945
1220
  /**
@@ -949,7 +1224,7 @@ class ArraySchema extends Schema {
949
1224
  * @returns {this}
950
1225
  */
951
1226
  max(count, message = `Must contain at most ${count} item${count > 1 ? "s" : ""}`) {
952
- return this._rule((v) => v.length <= count, "too_big", message);
1227
+ return this._rule((v) => v.length <= count, "too_big", message, { maximum: count }, { maxItems: count });
953
1228
  }
954
1229
 
955
1230
  /**
@@ -959,7 +1234,7 @@ class ArraySchema extends Schema {
959
1234
  * @returns {this}
960
1235
  */
961
1236
  length(count, message = `Must contain exactly ${count} items`) {
962
- return this._rule((v) => v.length === count, "invalid_length", message);
1237
+ return this._rule((v) => v.length === count, "invalid_length", message, { exact: count }, { minItems: count, maxItems: count });
963
1238
  }
964
1239
 
965
1240
  /**
@@ -968,7 +1243,7 @@ class ArraySchema extends Schema {
968
1243
  * @returns {this}
969
1244
  */
970
1245
  nonempty(message = "Must not be empty") {
971
- return this._rule((v) => v.length > 0, "too_small", message);
1246
+ return this._rule((v) => v.length > 0, "too_small", message, { minimum: 1 }, { minItems: 1 });
972
1247
  }
973
1248
 
974
1249
  /**
@@ -978,9 +1253,19 @@ class ArraySchema extends Schema {
978
1253
  * @returns {this}
979
1254
  */
980
1255
  unique(keyOrMessage, message = "Must not contain duplicates") {
981
- const key = typeof keyOrMessage === "function" && keyOrMessage.length === 1 ? /** @type {(item: T) => unknown} */ (keyOrMessage) : (/** @type {T} */ item) => item;
1256
+ const byKey = typeof keyOrMessage === "function" && keyOrMessage.length === 1 ? /** @type {(item: T) => unknown} */ (keyOrMessage) : undefined;
982
1257
  const msg = typeof keyOrMessage === "string" ? keyOrMessage : message;
983
- return this._rule((v) => new Set(v.map(key)).size === v.length, "not_unique", msg);
1258
+ return this._rule((v) => new Set(byKey ? v.map(byKey) : v).size === v.length, "not_unique", msg, undefined, byKey ? undefined : { uniqueItems: true });
1259
+ }
1260
+
1261
+ /**
1262
+ * @param {_JSONContext} ctx
1263
+ * @returns {JSONSchema}
1264
+ * @override
1265
+ * @protected
1266
+ */
1267
+ _json(ctx) {
1268
+ return { type: "array", items: _jsonOf(this._item, ctx) };
984
1269
  }
985
1270
 
986
1271
  /** The schema of the items. */
@@ -1004,6 +1289,12 @@ class ObjectSchema extends Schema {
1004
1289
  super();
1005
1290
  /** @protected */
1006
1291
  this._shape = shape;
1292
+ // Read once here rather than on every parse. A key named __proto__ could
1293
+ // only ever replace the prototype of the result, so it's left out.
1294
+ /** @protected @type {string[]} */
1295
+ this._keys = Object.keys(shape).filter((key) => key !== "__proto__");
1296
+ /** @protected @type {Array<Schema<any>>} */
1297
+ this._schemas = this._keys.map((key) => shape[key]);
1007
1298
  }
1008
1299
 
1009
1300
  /**
@@ -1016,22 +1307,65 @@ class ObjectSchema extends Schema {
1016
1307
  _check(value, ctx) {
1017
1308
  if (value === null || typeof value !== "object" || Array.isArray(value)) return _typeIssue(ctx, "object", value, this._typeMessage);
1018
1309
  const input = /** @type {Record<string, unknown>} */ (value);
1310
+ const keys = this._keys;
1311
+ const schemas = this._schemas;
1312
+ const path = ctx.path;
1019
1313
  /** @type {Record<string, unknown>} */
1020
1314
  const out = {};
1021
- for (const [key, schema] of Object.entries(this._shape)) {
1022
- const saved = ctx.path;
1023
- ctx.path = [...saved, key];
1024
- const result = _runChild(schema, input[key], ctx);
1025
- ctx.path = saved;
1026
- if (result !== undefined || Object.prototype.hasOwnProperty.call(input, key)) out[key] = result;
1315
+ for (let i = 0; i < keys.length; i++) {
1316
+ const key = keys[i];
1317
+ let raw = input[key];
1318
+ // What every object inherits, like toString, is not data.
1319
+ if (typeof raw === "function" && !Object.hasOwn(input, key)) raw = undefined;
1320
+ path.push(key);
1321
+ const result = _runChild(schemas[i], raw, ctx);
1322
+ path.pop();
1323
+ if (result !== undefined || Object.hasOwn(input, key)) out[key] = result;
1027
1324
  }
1028
- const unknownKeys = Object.keys(input).filter((key) => !(key in this._shape));
1029
- if (unknownKeys.length) {
1030
- if (this._unknown === "strict") _issue(ctx, "unrecognized_keys", `Unrecognized key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}`, { keys: unknownKeys });
1031
- else if (this._unknown === "passthrough") {
1032
- for (const key of unknownKeys) if (key !== "__proto__" && key !== "constructor" && key !== "prototype") out[key] = input[key];
1033
- }
1325
+ if (this._unknown === "strip") return out;
1326
+ /** @type {string[] | undefined} */
1327
+ let unknown;
1328
+ for (const key of Object.keys(input)) {
1329
+ if (Object.hasOwn(this._shape, key)) continue;
1330
+ if (this._unknown === "strict") (unknown ??= []).push(key);
1331
+ else if (!UNSAFE_KEYS.has(key)) out[key] = input[key];
1034
1332
  }
1333
+ if (unknown) _issue(ctx, "unrecognized_keys", `Unrecognized key${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`, { keys: unknown });
1334
+ return out;
1335
+ }
1336
+
1337
+ /**
1338
+ * A schema with another shape that keeps how this one treats unknown keys.
1339
+ * @param {Shape} shape
1340
+ * @returns {any}
1341
+ * @protected
1342
+ */
1343
+ _reshape(shape) {
1344
+ const copy = new ObjectSchema(shape);
1345
+ copy._unknown = this._unknown;
1346
+ return copy;
1347
+ }
1348
+
1349
+ /**
1350
+ * @param {_JSONContext} ctx
1351
+ * @returns {JSONSchema}
1352
+ * @override
1353
+ * @protected
1354
+ */
1355
+ _json(ctx) {
1356
+ /** @type {Record<string, JSONSchema>} */
1357
+ const properties = {};
1358
+ const required = [];
1359
+ for (let i = 0; i < this._keys.length; i++) {
1360
+ const child = /** @type {any} */ (this._schemas[i]);
1361
+ properties[this._keys[i]] = _jsonOf(child, ctx);
1362
+ // any() and unknown() take undefined too, so a key of theirs can be left out
1363
+ if (!child._optional && !child._default && child.constructor !== Schema) required.push(this._keys[i]);
1364
+ }
1365
+ /** @type {JSONSchema} */
1366
+ const out = { type: "object", properties };
1367
+ if (required.length) out.required = required;
1368
+ if (this._unknown === "strict") out.additionalProperties = false;
1035
1369
  return out;
1036
1370
  }
1037
1371
 
@@ -1067,9 +1401,7 @@ class ObjectSchema extends Schema {
1067
1401
  * @returns {ObjectSchema<Omit<S, keyof M> & M>}
1068
1402
  */
1069
1403
  extend(more) {
1070
- const copy = /** @type {any} */ (object({ ...this._shape, ...more }));
1071
- copy._unknown = this._unknown;
1072
- return copy;
1404
+ return this._reshape({ ...this._shape, ...more });
1073
1405
  }
1074
1406
 
1075
1407
  /**
@@ -1082,7 +1414,7 @@ class ObjectSchema extends Schema {
1082
1414
  /** @type {any} */
1083
1415
  const shape = {};
1084
1416
  for (const key of keys) shape[key] = this._shape[key];
1085
- return object(shape);
1417
+ return this._reshape(shape);
1086
1418
  }
1087
1419
 
1088
1420
  /**
@@ -1095,7 +1427,7 @@ class ObjectSchema extends Schema {
1095
1427
  /** @type {any} */
1096
1428
  const shape = { ...this._shape };
1097
1429
  for (const key of keys) delete shape[key];
1098
- return object(shape);
1430
+ return this._reshape(shape);
1099
1431
  }
1100
1432
 
1101
1433
  /**
@@ -1105,8 +1437,54 @@ class ObjectSchema extends Schema {
1105
1437
  partial() {
1106
1438
  /** @type {any} */
1107
1439
  const shape = {};
1108
- for (const [key, schema] of Object.entries(this._shape)) shape[key] = schema.optional();
1109
- return object(shape);
1440
+ for (let i = 0; i < this._keys.length; i++) shape[this._keys[i]] = this._schemas[i].optional();
1441
+ return this._reshape(shape);
1442
+ }
1443
+
1444
+ /**
1445
+ * Makes every key required again: the opposite of `partial()`.
1446
+ * @returns {ObjectSchema<{ [K in keyof S]: Schema<Exclude<Infer<S[K]>, undefined>> }>}
1447
+ */
1448
+ required() {
1449
+ /** @type {any} */
1450
+ const shape = {};
1451
+ for (let i = 0; i < this._keys.length; i++) {
1452
+ const copy = /** @type {any} */ (this._schemas[i])._clone();
1453
+ copy._optional = false;
1454
+ shape[this._keys[i]] = copy;
1455
+ }
1456
+ return this._reshape(shape);
1457
+ }
1458
+
1459
+ /**
1460
+ * Combines two object schemas. Keys of the other one win, and so does
1461
+ * its way of treating unknown keys.
1462
+ *
1463
+ * @example
1464
+ * const Timestamps = s.object({ createdAt: s.date(), updatedAt: s.date() });
1465
+ * const Post = s.object({ title: s.string() }).merge(Timestamps);
1466
+ *
1467
+ * @template {Shape} M
1468
+ * @param {ObjectSchema<M>} other
1469
+ * @returns {ObjectSchema<Omit<S, keyof M> & M>}
1470
+ */
1471
+ merge(other) {
1472
+ const copy = /** @type {any} */ (new ObjectSchema({ ...this._shape, ...other._shape }));
1473
+ copy._unknown = other._unknown;
1474
+ return copy;
1475
+ }
1476
+
1477
+ /**
1478
+ * A schema that accepts the name of any key: handy for a `sort` or
1479
+ * `fields` query parameter.
1480
+ *
1481
+ * @example
1482
+ * const SortBy = User.keyof(); // "name" | "email" | "age" | "role"
1483
+ *
1484
+ * @returns {LiteralSchema<keyof S & string>}
1485
+ */
1486
+ keyof() {
1487
+ return new LiteralSchema(/** @type {Array<keyof S & string>} */ (this._keys));
1110
1488
  }
1111
1489
  }
1112
1490
 
@@ -1149,6 +1527,75 @@ class UnionSchema extends Schema {
1149
1527
  ctx.issues.push(...best);
1150
1528
  return INVALID;
1151
1529
  }
1530
+
1531
+ /**
1532
+ * @param {_JSONContext} ctx
1533
+ * @returns {JSONSchema}
1534
+ * @override
1535
+ * @protected
1536
+ */
1537
+ _json(ctx) {
1538
+ return { anyOf: this._options.map((option) => _jsonOf(option, ctx)) };
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * A union of objects told apart by one key.
1544
+ *
1545
+ * @template T
1546
+ * @extends {Schema<T>}
1547
+ */
1548
+ class DiscriminatedUnionSchema extends Schema {
1549
+ /**
1550
+ * @param {string} key
1551
+ * @param {ReadonlyArray<ObjectSchema<any>>} options
1552
+ */
1553
+ constructor(key, options) {
1554
+ super();
1555
+ /** @protected */
1556
+ this._key = key;
1557
+ /** @protected */
1558
+ this._options = options;
1559
+ /** @protected @type {Map<unknown, Schema<any>>} */
1560
+ this._byValue = new Map();
1561
+ for (const option of options) {
1562
+ const values = /** @type {any} */ (option)?._shape?.[key]?._values;
1563
+ if (!Array.isArray(values)) throw new TypeError(`discriminatedUnion(): every option must be an object with a literal "${key}"`);
1564
+ for (const value of values) {
1565
+ if (this._byValue.has(value)) throw new TypeError(`discriminatedUnion(): two options have "${key}" set to ${JSON.stringify(value)}`);
1566
+ this._byValue.set(value, option);
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * @param {unknown} value
1573
+ * @param {ParseContext} ctx
1574
+ * @returns {unknown}
1575
+ * @override
1576
+ * @protected
1577
+ */
1578
+ _check(value, ctx) {
1579
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return _typeIssue(ctx, "object", value, this._typeMessage);
1580
+ const tag = /** @type {Record<string, unknown>} */ (value)[this._key];
1581
+ const option = this._byValue.get(tag);
1582
+ if (option) return _runChild(option, value, ctx);
1583
+ const expected = [...this._byValue.keys()].map((known) => JSON.stringify(known)).join(" | ");
1584
+ ctx.path.push(this._key);
1585
+ _issue(ctx, "invalid_union_discriminator", `Expected ${expected}, received ${JSON.stringify(tag) ?? String(tag)}`, { expected, received: _typeName(tag) });
1586
+ ctx.path.pop();
1587
+ return INVALID;
1588
+ }
1589
+
1590
+ /**
1591
+ * @param {_JSONContext} ctx
1592
+ * @returns {JSONSchema}
1593
+ * @override
1594
+ * @protected
1595
+ */
1596
+ _json(ctx) {
1597
+ return { oneOf: this._options.map((option) => _jsonOf(option, ctx)), discriminator: { propertyName: this._key } };
1598
+ }
1152
1599
  }
1153
1600
 
1154
1601
  /**
@@ -1177,13 +1624,24 @@ class TupleSchema extends Schema {
1177
1624
  if (value.length !== this._items.length) {
1178
1625
  return _issue(ctx, "invalid_length", `Expected ${this._items.length} items, received ${value.length}`);
1179
1626
  }
1180
- return this._items.map((schema, i) => {
1181
- const saved = ctx.path;
1182
- ctx.path = [...saved, i];
1183
- const out = _runChild(schema, value[i], ctx);
1184
- ctx.path = saved;
1185
- return out;
1186
- });
1627
+ const path = ctx.path;
1628
+ const out = new Array(this._items.length);
1629
+ for (let i = 0; i < out.length; i++) {
1630
+ path.push(i);
1631
+ out[i] = _runChild(this._items[i], value[i], ctx);
1632
+ path.pop();
1633
+ }
1634
+ return out;
1635
+ }
1636
+
1637
+ /**
1638
+ * @param {_JSONContext} ctx
1639
+ * @returns {JSONSchema}
1640
+ * @override
1641
+ * @protected
1642
+ */
1643
+ _json(ctx) {
1644
+ return { type: "array", prefixItems: this._items.map((item) => _jsonOf(item, ctx)), items: false, minItems: this._items.length, maxItems: this._items.length };
1187
1645
  }
1188
1646
  }
1189
1647
 
@@ -1215,19 +1673,33 @@ class RecordSchema extends Schema {
1215
1673
  */
1216
1674
  _check(value, ctx) {
1217
1675
  if (value === null || typeof value !== "object" || Array.isArray(value)) return _typeIssue(ctx, "object", value, this._typeMessage);
1676
+ const input = /** @type {Record<string, unknown>} */ (value);
1677
+ const path = ctx.path;
1218
1678
  /** @type {Record<string, unknown>} */
1219
1679
  const out = {};
1220
- for (const [key, item] of Object.entries(value)) {
1221
- if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
1222
- const saved = ctx.path;
1223
- ctx.path = [...saved, key];
1680
+ for (const key of Object.keys(input)) {
1681
+ if (UNSAFE_KEYS.has(key)) continue;
1682
+ path.push(key);
1224
1683
  const k = this._keys ? _runChild(this._keys, key, ctx) : key;
1225
- const v = _runChild(this._values, item, ctx);
1226
- ctx.path = saved;
1227
- if (typeof k === "string") out[k] = v;
1684
+ const v = _runChild(this._values, input[key], ctx);
1685
+ path.pop();
1686
+ if (typeof k === "string" && !UNSAFE_KEYS.has(k)) out[k] = v;
1228
1687
  }
1229
1688
  return out;
1230
1689
  }
1690
+
1691
+ /**
1692
+ * @param {_JSONContext} ctx
1693
+ * @returns {JSONSchema}
1694
+ * @override
1695
+ * @protected
1696
+ */
1697
+ _json(ctx) {
1698
+ /** @type {JSONSchema} */
1699
+ const out = { type: "object", additionalProperties: _jsonOf(this._values, ctx) };
1700
+ if (this._keys) out.propertyNames = _jsonOf(this._keys, ctx);
1701
+ return out;
1702
+ }
1231
1703
  }
1232
1704
 
1233
1705
  /**
@@ -1242,6 +1714,8 @@ class LazySchema extends Schema {
1242
1714
  super();
1243
1715
  /** @protected */
1244
1716
  this._getter = getter;
1717
+ /** @protected @type {Schema<T> | undefined} */
1718
+ this._resolved = undefined;
1245
1719
  }
1246
1720
 
1247
1721
  /**
@@ -1253,9 +1727,28 @@ class LazySchema extends Schema {
1253
1727
  */
1254
1728
  _check(value, ctx) {
1255
1729
  const before = ctx.issues.length;
1256
- const out = _runChild(this._getter(), value, ctx);
1730
+ const out = _runChild((this._resolved ??= this._getter()), value, ctx);
1257
1731
  return ctx.issues.length > before ? INVALID : out;
1258
1732
  }
1733
+
1734
+ /**
1735
+ * A schema that refers to itself can't be written inline: it goes under
1736
+ * `$defs` and is pointed at.
1737
+ * @param {_JSONContext} ctx
1738
+ * @returns {JSONSchema}
1739
+ * @override
1740
+ * @protected
1741
+ */
1742
+ _json(ctx) {
1743
+ const target = (this._resolved ??= this._getter());
1744
+ let name = ctx.names.get(target);
1745
+ if (!name) {
1746
+ name = `schema${ctx.names.size + 1}`;
1747
+ ctx.names.set(target, name);
1748
+ ctx.defs[name] = _jsonOf(target, ctx);
1749
+ }
1750
+ return { $ref: `#/$defs/${name}` };
1751
+ }
1259
1752
  }
1260
1753
 
1261
1754
  /**
@@ -1403,6 +1896,27 @@ function union(options) {
1403
1896
  return new UnionSchema(options);
1404
1897
  }
1405
1898
 
1899
+ /**
1900
+ * A union of objects told apart by one key, like the `type` of an event.
1901
+ * Unlike `union()`, it goes straight to the right schema, and a wrong
1902
+ * object gets the errors of the kind it claims to be.
1903
+ *
1904
+ * @example
1905
+ * const Event = s.discriminatedUnion("type", [
1906
+ * s.object({ type: s.literal("click"), x: s.number(), y: s.number() }),
1907
+ * s.object({ type: s.literal("key"), key: s.string() }),
1908
+ * ]);
1909
+ * Event.parse({ type: "key", key: "Enter" });
1910
+ *
1911
+ * @template {ReadonlyArray<ObjectSchema<any>>} U
1912
+ * @param {string} key The key every option has, with a different literal in each.
1913
+ * @param {U} options
1914
+ * @returns {DiscriminatedUnionSchema<Infer<U[number]>>}
1915
+ */
1916
+ function discriminatedUnion(key, options) {
1917
+ return new DiscriminatedUnionSchema(key, options);
1918
+ }
1919
+
1406
1920
  /**
1407
1921
  * A fixed-length array with one schema per position.
1408
1922
  *
@@ -1574,6 +2088,7 @@ module.exports = {
1574
2088
  array,
1575
2089
  object,
1576
2090
  union,
2091
+ discriminatedUnion,
1577
2092
  tuple,
1578
2093
  record,
1579
2094
  lazy,