@evolu/common 8.9.0 → 8.10.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 (233) hide show
  1. package/dist/src/Bytes.d.ts +647 -0
  2. package/dist/src/Bytes.d.ts.map +1 -0
  3. package/dist/src/{Binary.js → Bytes.js} +266 -16
  4. package/dist/src/Config.d.ts +142 -0
  5. package/dist/src/Config.d.ts.map +1 -0
  6. package/dist/src/Config.js +181 -0
  7. package/dist/src/Fs.d.ts +302 -0
  8. package/dist/src/Fs.d.ts.map +1 -0
  9. package/dist/src/Fs.js +111 -0
  10. package/dist/src/Number.d.ts +50 -7
  11. package/dist/src/Number.d.ts.map +1 -1
  12. package/dist/src/Number.js +47 -8
  13. package/dist/src/Object.d.ts +32 -0
  14. package/dist/src/Object.d.ts.map +1 -1
  15. package/dist/src/Object.js +41 -0
  16. package/dist/src/Sqlite.js +1 -1
  17. package/dist/src/Time.d.ts +74 -12
  18. package/dist/src/Time.d.ts.map +1 -1
  19. package/dist/src/Time.js +40 -2
  20. package/dist/src/Type.d.ts +1451 -79
  21. package/dist/src/Type.d.ts.map +1 -1
  22. package/dist/src/Type.js +1303 -96
  23. package/dist/src/index.d.ts +3 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +3 -1
  26. package/dist/src/intl/_en.d.ts +24 -1
  27. package/dist/src/intl/_en.d.ts.map +1 -1
  28. package/dist/src/intl/_en.js +20 -0
  29. package/dist/src/intl/ar.d.ts +24 -1
  30. package/dist/src/intl/ar.d.ts.map +1 -1
  31. package/dist/src/intl/ar.js +20 -0
  32. package/dist/src/intl/bn.d.ts +24 -1
  33. package/dist/src/intl/bn.d.ts.map +1 -1
  34. package/dist/src/intl/bn.js +20 -0
  35. package/dist/src/intl/ca.d.ts +24 -1
  36. package/dist/src/intl/ca.d.ts.map +1 -1
  37. package/dist/src/intl/ca.js +20 -0
  38. package/dist/src/intl/cs.d.ts +24 -1
  39. package/dist/src/intl/cs.d.ts.map +1 -1
  40. package/dist/src/intl/cs.js +20 -0
  41. package/dist/src/intl/da.d.ts +24 -1
  42. package/dist/src/intl/da.d.ts.map +1 -1
  43. package/dist/src/intl/da.js +20 -0
  44. package/dist/src/intl/de.d.ts +24 -1
  45. package/dist/src/intl/de.d.ts.map +1 -1
  46. package/dist/src/intl/de.js +20 -0
  47. package/dist/src/intl/el.d.ts +24 -1
  48. package/dist/src/intl/el.d.ts.map +1 -1
  49. package/dist/src/intl/el.js +20 -0
  50. package/dist/src/intl/es.d.ts +24 -1
  51. package/dist/src/intl/es.d.ts.map +1 -1
  52. package/dist/src/intl/es.js +20 -0
  53. package/dist/src/intl/fa.d.ts +24 -1
  54. package/dist/src/intl/fa.d.ts.map +1 -1
  55. package/dist/src/intl/fa.js +20 -0
  56. package/dist/src/intl/fi.d.ts +24 -1
  57. package/dist/src/intl/fi.d.ts.map +1 -1
  58. package/dist/src/intl/fi.js +20 -0
  59. package/dist/src/intl/fil.d.ts +24 -1
  60. package/dist/src/intl/fil.d.ts.map +1 -1
  61. package/dist/src/intl/fil.js +20 -0
  62. package/dist/src/intl/fr.d.ts +24 -1
  63. package/dist/src/intl/fr.d.ts.map +1 -1
  64. package/dist/src/intl/fr.js +20 -0
  65. package/dist/src/intl/he.d.ts +24 -1
  66. package/dist/src/intl/he.d.ts.map +1 -1
  67. package/dist/src/intl/he.js +20 -0
  68. package/dist/src/intl/hi.d.ts +24 -1
  69. package/dist/src/intl/hi.d.ts.map +1 -1
  70. package/dist/src/intl/hi.js +20 -0
  71. package/dist/src/intl/hr.d.ts +24 -1
  72. package/dist/src/intl/hr.d.ts.map +1 -1
  73. package/dist/src/intl/hr.js +20 -0
  74. package/dist/src/intl/hu.d.ts +22 -1
  75. package/dist/src/intl/hu.d.ts.map +1 -1
  76. package/dist/src/intl/hu.js +18 -0
  77. package/dist/src/intl/id.d.ts +24 -1
  78. package/dist/src/intl/id.d.ts.map +1 -1
  79. package/dist/src/intl/id.js +20 -0
  80. package/dist/src/intl/it.d.ts +24 -1
  81. package/dist/src/intl/it.d.ts.map +1 -1
  82. package/dist/src/intl/it.js +20 -0
  83. package/dist/src/intl/ja.d.ts +24 -1
  84. package/dist/src/intl/ja.d.ts.map +1 -1
  85. package/dist/src/intl/ja.js +20 -0
  86. package/dist/src/intl/ko.d.ts +24 -1
  87. package/dist/src/intl/ko.d.ts.map +1 -1
  88. package/dist/src/intl/ko.js +20 -0
  89. package/dist/src/intl/ml.d.ts +24 -1
  90. package/dist/src/intl/ml.d.ts.map +1 -1
  91. package/dist/src/intl/ml.js +20 -0
  92. package/dist/src/intl/mr.d.ts +24 -1
  93. package/dist/src/intl/mr.d.ts.map +1 -1
  94. package/dist/src/intl/mr.js +20 -0
  95. package/dist/src/intl/ms.d.ts +24 -1
  96. package/dist/src/intl/ms.d.ts.map +1 -1
  97. package/dist/src/intl/ms.js +20 -0
  98. package/dist/src/intl/nb.d.ts +22 -1
  99. package/dist/src/intl/nb.d.ts.map +1 -1
  100. package/dist/src/intl/nb.js +18 -0
  101. package/dist/src/intl/nl.d.ts +24 -1
  102. package/dist/src/intl/nl.d.ts.map +1 -1
  103. package/dist/src/intl/nl.js +20 -0
  104. package/dist/src/intl/pa.d.ts +24 -1
  105. package/dist/src/intl/pa.d.ts.map +1 -1
  106. package/dist/src/intl/pa.js +20 -0
  107. package/dist/src/intl/pl.d.ts +23 -0
  108. package/dist/src/intl/pl.d.ts.map +1 -1
  109. package/dist/src/intl/pl.js +20 -0
  110. package/dist/src/intl/pt-BR.d.ts +24 -1
  111. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  112. package/dist/src/intl/pt-BR.js +20 -0
  113. package/dist/src/intl/pt.d.ts +24 -1
  114. package/dist/src/intl/pt.d.ts.map +1 -1
  115. package/dist/src/intl/pt.js +20 -0
  116. package/dist/src/intl/ro.d.ts +24 -1
  117. package/dist/src/intl/ro.d.ts.map +1 -1
  118. package/dist/src/intl/ro.js +20 -0
  119. package/dist/src/intl/sk.d.ts +24 -1
  120. package/dist/src/intl/sk.d.ts.map +1 -1
  121. package/dist/src/intl/sk.js +20 -0
  122. package/dist/src/intl/sl.d.ts +24 -1
  123. package/dist/src/intl/sl.d.ts.map +1 -1
  124. package/dist/src/intl/sl.js +20 -0
  125. package/dist/src/intl/sv.d.ts +24 -1
  126. package/dist/src/intl/sv.d.ts.map +1 -1
  127. package/dist/src/intl/sv.js +20 -0
  128. package/dist/src/intl/sw.d.ts +21 -0
  129. package/dist/src/intl/sw.d.ts.map +1 -1
  130. package/dist/src/intl/sw.js +18 -0
  131. package/dist/src/intl/ta.d.ts +24 -1
  132. package/dist/src/intl/ta.d.ts.map +1 -1
  133. package/dist/src/intl/ta.js +20 -0
  134. package/dist/src/intl/te.d.ts +24 -1
  135. package/dist/src/intl/te.d.ts.map +1 -1
  136. package/dist/src/intl/te.js +20 -0
  137. package/dist/src/intl/th.d.ts +24 -1
  138. package/dist/src/intl/th.d.ts.map +1 -1
  139. package/dist/src/intl/th.js +20 -0
  140. package/dist/src/intl/tr.d.ts +24 -1
  141. package/dist/src/intl/tr.d.ts.map +1 -1
  142. package/dist/src/intl/tr.js +20 -0
  143. package/dist/src/intl/uk.d.ts +80 -57
  144. package/dist/src/intl/uk.d.ts.map +1 -1
  145. package/dist/src/intl/uk.js +174 -149
  146. package/dist/src/intl/ur.d.ts +24 -1
  147. package/dist/src/intl/ur.d.ts.map +1 -1
  148. package/dist/src/intl/ur.js +20 -0
  149. package/dist/src/intl/vi.d.ts +24 -1
  150. package/dist/src/intl/vi.d.ts.map +1 -1
  151. package/dist/src/intl/vi.js +20 -0
  152. package/dist/src/intl/zh-CN.d.ts +24 -1
  153. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  154. package/dist/src/intl/zh-CN.js +20 -0
  155. package/dist/src/intl/zh-TW.d.ts +24 -1
  156. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  157. package/dist/src/intl/zh-TW.js +20 -0
  158. package/dist/src/local-first/Protocol.d.ts +1 -1
  159. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  160. package/dist/src/local-first/Protocol.js +1 -1
  161. package/dist/src/local-first/Schema.d.ts +10 -0
  162. package/dist/src/local-first/Schema.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +1 -1
  164. package/dist/src/local-first/Timestamp.js +1 -1
  165. package/package.json +1 -1
  166. package/src/{Binary.test.ts → Bytes.test.ts} +286 -1
  167. package/src/{Binary.ts → Bytes.ts} +652 -21
  168. package/src/Config.test.ts +672 -0
  169. package/src/Config.ts +410 -0
  170. package/src/Fs.test.ts +105 -0
  171. package/src/Fs.ts +414 -0
  172. package/src/Number.test.ts +82 -18
  173. package/src/Number.ts +76 -8
  174. package/src/Object.test.ts +115 -1
  175. package/src/Object.ts +44 -0
  176. package/src/Sqlite.ts +1 -1
  177. package/src/Time.test.ts +82 -11
  178. package/src/Time.ts +141 -16
  179. package/src/Type.test.ts +3114 -157
  180. package/src/Type.ts +2553 -196
  181. package/src/index.ts +3 -1
  182. package/src/intl/_en.ts +70 -0
  183. package/src/intl/ar.ts +71 -0
  184. package/src/intl/bn.ts +70 -0
  185. package/src/intl/ca.ts +70 -0
  186. package/src/intl/cs.ts +70 -0
  187. package/src/intl/da.ts +70 -0
  188. package/src/intl/de.ts +70 -0
  189. package/src/intl/el.ts +70 -0
  190. package/src/intl/es.ts +70 -0
  191. package/src/intl/fa.ts +70 -0
  192. package/src/intl/fi.ts +70 -0
  193. package/src/intl/fil.ts +70 -0
  194. package/src/intl/fr.ts +70 -0
  195. package/src/intl/he.ts +70 -0
  196. package/src/intl/hi.ts +70 -0
  197. package/src/intl/hr.ts +70 -0
  198. package/src/intl/hu.ts +69 -0
  199. package/src/intl/id.ts +70 -0
  200. package/src/intl/intl.test.ts +819 -1
  201. package/src/intl/it.ts +70 -0
  202. package/src/intl/ja.ts +70 -0
  203. package/src/intl/ko.ts +68 -0
  204. package/src/intl/ml.ts +70 -0
  205. package/src/intl/mr.ts +70 -0
  206. package/src/intl/ms.ts +71 -0
  207. package/src/intl/nb.ts +69 -0
  208. package/src/intl/nl.ts +70 -0
  209. package/src/intl/pa.ts +70 -0
  210. package/src/intl/pl.ts +63 -0
  211. package/src/intl/pt-BR.ts +70 -0
  212. package/src/intl/pt.ts +71 -0
  213. package/src/intl/ro.ts +70 -0
  214. package/src/intl/sk.ts +71 -0
  215. package/src/intl/sl.ts +70 -0
  216. package/src/intl/sv.ts +70 -0
  217. package/src/intl/sw.ts +62 -0
  218. package/src/intl/ta.ts +70 -0
  219. package/src/intl/te.ts +70 -0
  220. package/src/intl/th.ts +68 -0
  221. package/src/intl/tr.ts +70 -0
  222. package/src/intl/uk.ts +228 -155
  223. package/src/intl/ur.ts +70 -0
  224. package/src/intl/vi.ts +70 -0
  225. package/src/intl/zh-CN.ts +68 -0
  226. package/src/intl/zh-TW.ts +68 -0
  227. package/src/local-first/Protocol.test.ts +1 -1
  228. package/src/local-first/Protocol.ts +1 -1
  229. package/src/local-first/Schema.ts +11 -0
  230. package/src/local-first/Storage.ts +1 -1
  231. package/src/local-first/Timestamp.ts +1 -1
  232. package/dist/src/Binary.d.ts +0 -254
  233. package/dist/src/Binary.d.ts.map +0 -1
package/src/Type.test.ts CHANGED
@@ -15,6 +15,12 @@ import {
15
15
  assertThrowsInstanceOf,
16
16
  assertTrue,
17
17
  } from "./Assert.ts";
18
+ import {
19
+ formatCapitalizedError,
20
+ formatUncapitalizedError,
21
+ formatUppercasedError,
22
+ formatLowercasedError,
23
+ } from "./intl/_en.ts";
18
24
  import * as cs from "./intl/cs.ts";
19
25
  import {
20
26
  allResult,
@@ -36,15 +42,54 @@ import {
36
42
  between,
37
43
  BigInt,
38
44
  Boolean,
45
+ BooleanFromString,
39
46
  brand,
47
+ CamelCaseIdentifier,
48
+ CamelCaseIdentifierFromConstantCaseIdentifier,
49
+ camelCaseToPascalCase,
50
+ camelCaseToSnakeCase,
51
+ camelCaseToKebabCase,
52
+ camelCaseToConstantCase,
53
+ pascalCaseToCamelCase,
54
+ pascalCaseToSnakeCase,
55
+ pascalCaseToKebabCase,
56
+ pascalCaseToConstantCase,
57
+ snakeCaseToCamelCase,
58
+ snakeCaseToPascalCase,
59
+ snakeCaseToKebabCase,
60
+ snakeCaseToConstantCase,
61
+ kebabCaseToCamelCase,
62
+ kebabCaseToPascalCase,
63
+ kebabCaseToSnakeCase,
64
+ kebabCaseToConstantCase,
65
+ constantCaseToCamelCase,
66
+ constantCaseToPascalCase,
67
+ constantCaseToSnakeCase,
68
+ constantCaseToKebabCase,
69
+ ConstantCaseIdentifier,
70
+ identifier,
71
+ KebabCaseIdentifier,
72
+ PascalCaseIdentifier,
73
+ SnakeCaseIdentifier,
74
+ capitalize,
40
75
  capitalized,
76
+ uncapitalize,
77
+ uncapitalized,
78
+ uppercase,
79
+ uppercased,
80
+ lowercase,
81
+ lowercased,
41
82
  CapitalizedString,
83
+ UncapitalizedString,
84
+ UppercasedString,
85
+ LowercasedString,
42
86
  instanceOf,
43
87
  objectTag,
44
88
  createId,
45
89
  createIdAsUuidv7,
46
90
  createIdFromString,
47
91
  createType,
92
+ createTypeWithError,
48
93
  Data,
49
94
  Date,
50
95
  DateIso,
@@ -66,6 +111,7 @@ import {
66
111
  greaterThanOrEqualTo,
67
112
  int,
68
113
  Int,
114
+ IntFromString,
69
115
  Int64,
70
116
  Int64FromInt64String,
71
117
  Int64String,
@@ -121,10 +167,13 @@ import {
121
167
  Null,
122
168
  nullishOr,
123
169
  nullOr,
170
+ withDefault,
171
+ type Defaulted,
124
172
  nullableToOptional,
125
173
  Number,
126
174
  Object,
127
175
  object,
176
+ objectKeys,
128
177
  omit,
129
178
  onePositiveInt,
130
179
  optional,
@@ -135,12 +184,16 @@ import {
135
184
  PositiveFiniteNumber,
136
185
  PositiveInt,
137
186
  PositiveNumber,
187
+ Port,
188
+ PortFromString,
189
+ prefixed,
138
190
  Ratio,
139
191
  record,
140
192
  regex,
141
193
  result,
142
194
  nextResult,
143
195
  set,
196
+ startsWith,
144
197
  String,
145
198
  Symbol,
146
199
  SimplePassword,
@@ -174,8 +227,8 @@ import {
174
227
  type ArrayType,
175
228
  type BrandFactory,
176
229
  type BrandType,
230
+ type StartsWithError,
177
231
  type Base64UrlError,
178
- type CapitalizedError,
179
232
  type DataError,
180
233
  type DataIssue,
181
234
  type DataType,
@@ -190,8 +243,12 @@ import {
190
243
  type ExtractTyped,
191
244
  type FiniteError,
192
245
  type GreaterThanError,
246
+ type IdentifierCasing,
247
+ type IdentifierError,
193
248
  type InferErrors,
194
249
  type InferType,
250
+ type TypeIssue,
251
+ typeErrorToIssues,
195
252
  type InstanceConstructor,
196
253
  type IsData,
197
254
  type InstanceOfError,
@@ -295,6 +352,8 @@ import {
295
352
  type UnionMemberError,
296
353
  type UnionType,
297
354
  type ValidationOptions,
355
+ type ValidateLiteral,
356
+ type ValidateOutput,
298
357
  } from "./Type.ts";
299
358
 
300
359
  const createNullRecord = <T extends object>(entries: T): T =>
@@ -463,7 +522,14 @@ describe("Type", () => {
463
522
  Base64Url,
464
523
  BigInt,
465
524
  Boolean,
525
+ BooleanFromString,
526
+ CamelCaseIdentifier,
527
+ CamelCaseIdentifierFromConstantCaseIdentifier,
466
528
  CapitalizedString,
529
+ UncapitalizedString,
530
+ UppercasedString,
531
+ LowercasedString,
532
+ ConstantCaseIdentifier,
467
533
  Data,
468
534
  Date,
469
535
  DateIso,
@@ -482,6 +548,7 @@ describe("Type", () => {
482
548
  Id,
483
549
  IdBytes,
484
550
  Int,
551
+ IntFromString,
485
552
  Int64,
486
553
  Int64FromInt64String,
487
554
  Int64String,
@@ -490,6 +557,7 @@ describe("Type", () => {
490
557
  JsonObject,
491
558
  JsonValue,
492
559
  JsonValueFromJson,
560
+ KebabCaseIdentifier,
493
561
  NegativeDecimalString,
494
562
  NegativeInt,
495
563
  NegativeNumber,
@@ -510,12 +578,16 @@ describe("Type", () => {
510
578
  Null,
511
579
  Number,
512
580
  Object,
581
+ PascalCaseIdentifier,
513
582
  PositiveDecimalString,
514
583
  PositiveFiniteNumber,
515
584
  PositiveInt,
516
585
  PositiveNumber,
586
+ Port,
587
+ PortFromString,
517
588
  Ratio,
518
589
  SimplePassword,
590
+ SnakeCaseIdentifier,
519
591
  String,
520
592
  Symbol,
521
593
  TrimmedString,
@@ -593,7 +665,11 @@ describe("Type", () => {
593
665
  "Between6-7",
594
666
  "BigInt",
595
667
  "Boolean",
668
+ "BooleanFromString",
669
+ "CamelCaseIdentifier",
670
+ "CamelCaseIdentifierFromConstantCaseIdentifier",
596
671
  "Capitalized",
672
+ "ConstantCaseIdentifier",
597
673
  "Data",
598
674
  "Date",
599
675
  "DateIso",
@@ -612,6 +688,7 @@ describe("Type", () => {
612
688
  "Int64",
613
689
  "Int64FromInt64String",
614
690
  "Int64String",
691
+ "IntFromString",
615
692
  "IntrospectionChild",
616
693
  "IntrospectionKey",
617
694
  "IntrospectionRegex",
@@ -619,13 +696,16 @@ describe("Type", () => {
619
696
  "Json",
620
697
  "JsonValue",
621
698
  "JsonValueFromJson",
699
+ "KebabCaseIdentifier",
622
700
  "Length16",
623
701
  "Length4",
624
702
  "LessThan200",
625
703
  "LessThan3",
626
704
  "LessThanOrEqualTo1",
627
705
  "LessThanOrEqualTo4",
706
+ "LessThanOrEqualTo65535",
628
707
  "Literal",
708
+ "Lowercased",
629
709
  "Map",
630
710
  "MaxLength100",
631
711
  "MaxLength1000",
@@ -648,12 +728,16 @@ describe("Type", () => {
648
728
  "Number",
649
729
  "NumberFromString",
650
730
  "Object",
731
+ "PascalCaseIdentifier",
732
+ "Port",
733
+ "PortFromString",
651
734
  "Positive",
652
735
  "PositiveDecimalString",
653
736
  "Ratio",
654
737
  "Record",
655
738
  "Set",
656
739
  "SimplePassword",
740
+ "SnakeCaseIdentifier",
657
741
  "String",
658
742
  "Symbol",
659
743
  "TemplateLiteral",
@@ -661,8 +745,10 @@ describe("Type", () => {
661
745
  "Tuple",
662
746
  "UInt64",
663
747
  "Uint8Array",
748
+ "Uncapitalized",
664
749
  "Union",
665
750
  "Unknown",
751
+ "Uppercased",
666
752
  "UrlSafeString",
667
753
  ] as const;
668
754
 
@@ -1076,11 +1162,108 @@ describe("Type operations", () => {
1076
1162
  it("map only errors returned after the typed Input boundary", () => {
1077
1163
  const error = { type: "Positive", value: 0 } as const;
1078
1164
 
1079
- assertAssertionError(() => PositiveNumber.orThrow(0), "getOrThrow", error);
1165
+ assertAssertionError(
1166
+ () => PositiveNumber.orThrow(0),
1167
+ PositiveNumber.formatError(error),
1168
+ error,
1169
+ );
1080
1170
  assertSame(PositiveNumber.orNull(0), null);
1081
1171
  });
1082
1172
  });
1083
1173
 
1174
+ describe("Type.orThrow", () => {
1175
+ it("formats a returned validation error once and preserves its identity", () => {
1176
+ const cause = { type: "Text", value: "invalid" } as const;
1177
+ const decode = mock.fn(() => err(cause));
1178
+ const format = mock.fn(
1179
+ (_error: typeof cause) => "Expected configured text.",
1180
+ );
1181
+ const Text = transform(
1182
+ "Text",
1183
+ String,
1184
+ String,
1185
+ {
1186
+ from: decode,
1187
+ to: (value) => value,
1188
+ },
1189
+ format,
1190
+ );
1191
+
1192
+ const thrown = assertThrowsInstanceOf(() => Text.orThrow("invalid"), Error);
1193
+
1194
+ assertEqual(thrown.message, "Expected configured text.");
1195
+ assertSame(thrown.cause, cause);
1196
+ assertEqual(decode.mock.callCount(), 1);
1197
+ assertEqual(format.mock.callCount(), 1);
1198
+ assertSame(format.mock.calls[0]?.arguments[0], cause);
1199
+ });
1200
+ });
1201
+
1202
+ describe("typeErrorToIssues", () => {
1203
+ it("formats collected errors without decoding again", () => {
1204
+ const decode = mock.fn((value: string) => ok(value));
1205
+ const Text = transform("Text", String, String, {
1206
+ from: decode,
1207
+ to: (value) => value,
1208
+ });
1209
+ const Model = object({ text: Text, counts: array(IntFromString) });
1210
+ const result = Model.fromUnknown(
1211
+ { text: "ok", counts: ["bad", "worse"] },
1212
+ { errors: "all" },
1213
+ );
1214
+ assertErr(result);
1215
+ assertEqual(typeErrorToIssues(Model, result.error), [
1216
+ {
1217
+ path: ["counts", 0],
1218
+ message: 'The value "bad" is not a decimal integer.',
1219
+ },
1220
+ {
1221
+ path: ["counts", 1],
1222
+ message: 'The value "worse" is not a decimal integer.',
1223
+ },
1224
+ ]);
1225
+ assertEqual(decode.mock.callCount(), 1);
1226
+ });
1227
+
1228
+ it("retains root paths and only the errors collected during decoding", () => {
1229
+ const root = String.fromUnknown(1);
1230
+ assertErr(root);
1231
+ const issues = typeErrorToIssues(String, root.error);
1232
+ assertType<typeof issues, NonEmptyReadonlyArray<TypeIssue>>();
1233
+ assertEqual(issues, [{ path: [], message: "A value 1 is not a string." }]);
1234
+ const Values = array(IntFromString);
1235
+ const result = Values.fromUnknown(["bad", "worse"]);
1236
+ assertErr(result);
1237
+ assertEqual(typeErrorToIssues(Values, result.error), [
1238
+ { path: [0], message: 'The value "bad" is not a decimal integer.' },
1239
+ ]);
1240
+ });
1241
+
1242
+ it("uses localized messages for nested issues", () => {
1243
+ const Model = object({ labels: array(minLength(1)(String)) });
1244
+ const LocalizedModel = localizeTypes(
1245
+ { Model },
1246
+ {
1247
+ cs: {
1248
+ Array: cs.formatArrayError,
1249
+ MinLength1: cs.formatMinLengthError,
1250
+ Object: cs.formatObjectError,
1251
+ String: cs.formatStringError,
1252
+ },
1253
+ },
1254
+ ).cs.Model;
1255
+ const result = LocalizedModel.fromUnknown(
1256
+ { labels: [1, ""] },
1257
+ { errors: "all" },
1258
+ );
1259
+ assertErr(result);
1260
+ assertEqual(typeErrorToIssues(LocalizedModel, result.error), [
1261
+ { path: ["labels", 0], message: "Hodnota 1 musí být text." },
1262
+ { path: ["labels", 1], message: "Text nesmí být prázdný." },
1263
+ ]);
1264
+ });
1265
+ });
1266
+
1084
1267
  describe("Standard Schema", () => {
1085
1268
  it("infers the exact Input and Output", () => {
1086
1269
  const _NumberFromString = setupNumberFromString();
@@ -1302,7 +1485,11 @@ describe("Standard Schema", () => {
1302
1485
  assertEqual(result, {
1303
1486
  issues: [
1304
1487
  {
1305
- message: "A value does not match any allowed variant.",
1488
+ message: [
1489
+ "A value does not match any allowed variant.",
1490
+ "- 0: String: A value null is not a string.",
1491
+ "- 1: Number: A value null is not a number.",
1492
+ ].join("\n"),
1306
1493
  path: ["value"],
1307
1494
  },
1308
1495
  ],
@@ -1702,6 +1889,158 @@ describe("localizeTypes", () => {
1702
1889
  );
1703
1890
  });
1704
1891
 
1892
+ it("preserves configured defaults while localizing their Types", () => {
1893
+ const defaultValue = { enabled: true };
1894
+ const Selected = createType(
1895
+ "Selected",
1896
+ (value): Result<typeof defaultValue, TypeError<"Selected">> =>
1897
+ value === defaultValue ? ok(defaultValue) : err({ type: "Selected" }),
1898
+ () => "Expected the selected object.",
1899
+ );
1900
+ const Model = object({
1901
+ replaced: withDefault(optional(Selected), defaultValue),
1902
+ preserved: withDefault(optional(Selected), defaultValue, {
1903
+ strategy: "preserve",
1904
+ }),
1905
+ });
1906
+ const formatters = {
1907
+ test: {
1908
+ Object: () => "Localized Object.",
1909
+ Selected: () => "Localized Selected.",
1910
+ },
1911
+ };
1912
+ const localized = localizeTypes({ Model, Selected }, formatters).test;
1913
+
1914
+ for (const types of [
1915
+ localized,
1916
+ localizeTypes(localized, formatters).test,
1917
+ ]) {
1918
+ for (const property of [
1919
+ types.Model.props.replaced,
1920
+ types.Model.props.preserved,
1921
+ ]) {
1922
+ assertSame(property.value, defaultValue);
1923
+ assertSame(property.type, types.Selected);
1924
+ assertTrue(property.type.is(property.value));
1925
+ }
1926
+
1927
+ const Reconstructed = object(types.Model.props);
1928
+ const decoded = Reconstructed.orThrow({});
1929
+ assertSame(decoded.replaced, defaultValue);
1930
+ assertSame(decoded.preserved.value, defaultValue);
1931
+ assertEqual(Reconstructed.to(decoded), { replaced: defaultValue });
1932
+
1933
+ const invalid = Reconstructed.fromUnknown({ replaced: {} });
1934
+ assertErr(invalid);
1935
+ assertEqual(
1936
+ Reconstructed.formatError(invalid.error),
1937
+ "Localized Selected.",
1938
+ );
1939
+ }
1940
+ });
1941
+
1942
+ it("keeps configured default data opaque", () => {
1943
+ const cyclicObject: { self?: unknown } = {};
1944
+ cyclicObject.self = cyclicObject;
1945
+ const cyclicArray: Array<unknown> = [];
1946
+ cyclicArray.push(cyclicArray);
1947
+ const accessor = globalThis.Object.defineProperty({}, "value", {
1948
+ enumerable: true,
1949
+ get: () => {
1950
+ throw new Error("A default getter must not be read.");
1951
+ },
1952
+ });
1953
+
1954
+ for (const defaultValue of [
1955
+ cyclicObject,
1956
+ cyclicArray,
1957
+ accessor,
1958
+ String,
1959
+ { "~evolu/instance": "Type" },
1960
+ ]) {
1961
+ const Model = object({
1962
+ replaced: withDefault(optional(Unknown), defaultValue),
1963
+ preserved: withDefault(optional(Unknown), defaultValue, {
1964
+ strategy: "preserve",
1965
+ }),
1966
+ });
1967
+ const localized = localizeTypes(
1968
+ { Model },
1969
+ { test: { Object: () => "Localized Object." } },
1970
+ ).test.Model;
1971
+
1972
+ assertSame(localized.props.replaced.value, defaultValue);
1973
+ assertSame(localized.props.preserved.value, defaultValue);
1974
+ const decoded = localized.orThrow({});
1975
+ assertSame(decoded.replaced, defaultValue);
1976
+ assertSame(decoded.preserved.value, defaultValue);
1977
+ const encoded = localized.to(decoded);
1978
+ assertSame(encoded.replaced, defaultValue);
1979
+ assertFalse(globalThis.Object.hasOwn(encoded, "preserved"));
1980
+ }
1981
+ });
1982
+
1983
+ it("retains localized errors when making defaulted properties partial", () => {
1984
+ const Model = object({
1985
+ replaced: withDefault(optional(Boolean), true),
1986
+ preserved: withDefault(optional(Boolean), true, {
1987
+ strategy: "preserve",
1988
+ }),
1989
+ ordinary: optional(Boolean),
1990
+ });
1991
+ const first = localizeTypes(
1992
+ { Model },
1993
+ {
1994
+ test: {
1995
+ Boolean: () => "First Boolean.",
1996
+ Object: () => "First Object.",
1997
+ },
1998
+ },
1999
+ ).test.Model;
2000
+ const second = localizeTypes(
2001
+ { Model: first },
2002
+ {
2003
+ test: {
2004
+ Boolean: () => "Second Boolean.",
2005
+ Object: () => "Second Object.",
2006
+ },
2007
+ },
2008
+ ).test.Model;
2009
+
2010
+ for (const [model, message] of [
2011
+ [first, "First Boolean."],
2012
+ [second, "Second Boolean."],
2013
+ ] as const) {
2014
+ const Partial = partial(model.props);
2015
+ const input = { replaced: "bad", preserved: "bad", ordinary: "bad" };
2016
+ const result = Partial.fromUnknown(input, { errors: "all" });
2017
+ assertErr(result);
2018
+ const issues = ["replaced", "preserved", "ordinary"].map((key) => ({
2019
+ path: [key],
2020
+ message,
2021
+ }));
2022
+ assertEqual(typeErrorToIssues(Partial, result.error), issues);
2023
+ assertEqual(Partial.formatError(result.error), message);
2024
+ assertEqual(Partial["~standard"].validate(input), { issues });
2025
+ assertSame(Partial.props.replaced.type.parent, model.props.replaced.type);
2026
+ assertSame(
2027
+ Partial.props.preserved.type.parent,
2028
+ model.props.preserved.type,
2029
+ );
2030
+
2031
+ assertOk(Partial.fromUnknown({}), {});
2032
+ assertEqual(Partial.to({}), {});
2033
+ const supplied = { replaced: false, preserved: false, ordinary: false };
2034
+ const decoded = Partial.orThrow(supplied);
2035
+ assertEqual(decoded, {
2036
+ replaced: false,
2037
+ preserved: { value: false, defaultUsed: false },
2038
+ ordinary: false,
2039
+ });
2040
+ assertEqual(Partial.to(decoded), supplied);
2041
+ }
2042
+ });
2043
+
1705
2044
  it("preserves localized child errors when composed afterward", async () => {
1706
2045
  const LocalizedString = localizeTypes(
1707
2046
  { String },
@@ -1832,6 +2171,11 @@ describe("localizeTypes", () => {
1832
2171
  assertEqual(types.Strings.to(["Evolu"]), ["Evolu"]);
1833
2172
 
1834
2173
  const positiveError = { type: "Positive", value: 0 } as const;
2174
+ assertAssertionError(
2175
+ () => types.PositiveNumber.orThrow(0),
2176
+ "Localized Positive.",
2177
+ positiveError,
2178
+ );
1835
2179
  for (const operation of [
1836
2180
  () => types.PositiveNumber.from(0 as PositiveNumber),
1837
2181
  () => types.PositiveNumber.to(0 as PositiveNumber),
@@ -1960,7 +2304,7 @@ describe("localizeTypes", () => {
1960
2304
  >();
1961
2305
  });
1962
2306
 
1963
- it("lets a Union own its complete failure and localizes its members", () => {
2307
+ it("localizes a Union summary and its retained member failures", () => {
1964
2308
  const Value = union(String, Number);
1965
2309
  const types = localizeTypes(
1966
2310
  { Value },
@@ -1976,7 +2320,10 @@ describe("localizeTypes", () => {
1976
2320
  const stringResult = types.Value.members[0].fromUnknown(1);
1977
2321
 
1978
2322
  assertErr(result);
1979
- assertEqual(types.Value.formatError(result.error), "Localized Union.");
2323
+ assertEqual(
2324
+ types.Value.formatError(result.error),
2325
+ "Localized Union.\n- 0: String: Localized String.",
2326
+ );
1980
2327
  assertFalse(globalThis.Object.is(types.Value.members[0], String));
1981
2328
  assertFalse(globalThis.Object.is(types.Value.members[1], Number));
1982
2329
  assertErr(stringResult);
@@ -2003,6 +2350,49 @@ describe("localizeTypes", () => {
2003
2350
  >();
2004
2351
  });
2005
2352
 
2353
+ it("localizes nested Union details and can localize the result again", async () => {
2354
+ const Value = object({
2355
+ field: union(object({ count: union(String, Number) }), Null),
2356
+ });
2357
+ const localized = localizeTypes(
2358
+ { Value },
2359
+ {
2360
+ first: {
2361
+ Object: () => "Object one.",
2362
+ String: () => "String one.",
2363
+ Number: () => "Number one.",
2364
+ Literal: () => "Literal one.",
2365
+ Union: () => "Union one.",
2366
+ },
2367
+ },
2368
+ ).first;
2369
+ const second = localizeTypes(localized, {
2370
+ second: {
2371
+ Object: () => "Object two.",
2372
+ String: () => "String two.",
2373
+ Number: () => "Number two.",
2374
+ Literal: () => "Literal two.",
2375
+ Union: () => "Union two.",
2376
+ },
2377
+ }).second;
2378
+ const input = { field: { count: false } };
2379
+ const result = second.Value.fromUnknown(input, { errors: "all" });
2380
+ assertErr(result);
2381
+ const message = [
2382
+ "Union two.",
2383
+ '- 0: Object["count"]: Union two.',
2384
+ " - 0: String: String two.",
2385
+ " - 1: Number: Number two.",
2386
+ "- 1: Literal: Literal two.",
2387
+ ].join("\n");
2388
+ const issues = [{ path: ["field"], message }];
2389
+ assertEqual(second.Value.formatError(result.error), message);
2390
+ assertEqual(typeErrorToIssues(second.Value, result.error), issues);
2391
+ assertEqual(await second.Value["~standard"].validate(input), { issues });
2392
+ assertFalse(localized.Value.formatError(result.error).includes("two."));
2393
+ assertFalse(Value.formatError(result.error).includes("two."));
2394
+ });
2395
+
2006
2396
  it("localizes TemplateLiteral captures and their reflected Tuple", () => {
2007
2397
  const Part = literal("a");
2008
2398
  const Value = templateLiteralParser(Part);
@@ -3191,6 +3581,149 @@ describe("createType", () => {
3191
3581
  });
3192
3582
  });
3193
3583
 
3584
+ describe("createTypeWithError", () => {
3585
+ it("delegates validation and error collection without callback options", () => {
3586
+ const Syntax = object({ name: String, count: Number });
3587
+ interface ModelError extends TypeError<"Model"> {
3588
+ readonly cause: InferErrors<typeof Syntax>;
3589
+ readonly value: unknown;
3590
+ }
3591
+ const mapError = mock.fn(
3592
+ (cause: InferErrors<typeof Syntax>, value: unknown): ModelError => ({
3593
+ type: "Model",
3594
+ cause,
3595
+ value,
3596
+ }),
3597
+ );
3598
+ const Model = createTypeWithError("Model", Syntax, mapError, (error) => {
3599
+ assertType<typeof error, ModelError>();
3600
+ return "Invalid model.";
3601
+ });
3602
+ assertType<typeof Model.Input, typeof Syntax.Output>();
3603
+ assertType<typeof Model.Output, typeof Syntax.Output>();
3604
+ assertType<typeof Model.CanonicalInput, typeof Syntax.Output>();
3605
+ assertType<InferErrors<typeof Model>, ModelError>();
3606
+ assertType<typeof Model.parent, null>();
3607
+ assertSame(Model.parent, null);
3608
+
3609
+ const valid = { name: "Evolu", count: 1 };
3610
+ for (const result of [Model.fromUnknown(valid), Model.from(valid)]) {
3611
+ assertOk(result);
3612
+ assertSame(result.value, valid);
3613
+ }
3614
+ assertSame(Model.to(valid), valid);
3615
+ assertSame(Model.orThrow(valid), valid);
3616
+ assertSame(Model.orNull(valid), valid);
3617
+ assertTrue(Model.is(valid));
3618
+ assertType(Model, valid);
3619
+ assertEqual(Model["~standard"].validate(valid), { value: valid });
3620
+ assertSame(mapError.mock.callCount(), 0);
3621
+
3622
+ const invalid = { name: 1, count: "bad" };
3623
+ for (const options of [
3624
+ undefined,
3625
+ { errors: "first" },
3626
+ { errors: "all" },
3627
+ ] as const) {
3628
+ const result = Model.fromUnknown(invalid, options);
3629
+ const source = Syntax.fromUnknown(invalid, options);
3630
+ assertErr(result);
3631
+ assertErr(source);
3632
+ assertEqual(result.error, {
3633
+ type: "Model",
3634
+ cause: source.error,
3635
+ value: invalid,
3636
+ });
3637
+ assertSame(result.error.value, invalid);
3638
+ assertEqual(Model.formatError(result.error), "Invalid model.");
3639
+ assertEqual(typeErrorToIssues(Model, result.error), [
3640
+ { path: [], message: "Invalid model." },
3641
+ ]);
3642
+ }
3643
+ assertFalse(Model.is(invalid));
3644
+ const standard = Model["~standard"].validate(invalid);
3645
+ assertEqual(standard, {
3646
+ issues: [{ path: [], message: "Invalid model." }],
3647
+ });
3648
+ const collected = mapError.mock.calls.at(-1)!.arguments[0];
3649
+ assertEqual(collected, {
3650
+ type: "Object",
3651
+ reason: {
3652
+ kind: "Properties",
3653
+ errors: {
3654
+ name: { type: "TypeOf", expected: "String", value: 1 },
3655
+ count: { type: "TypeOf", expected: "Number", value: "bad" },
3656
+ },
3657
+ },
3658
+ });
3659
+ assertAssertionError(
3660
+ () =>
3661
+ Model.from(invalid as unknown as typeof Model.Output, {
3662
+ errors: "all",
3663
+ }),
3664
+ "Expected Model.",
3665
+ { type: "Model", cause: collected, value: invalid },
3666
+ );
3667
+ });
3668
+
3669
+ it("preserves all alternatives inside localized structural Types", () => {
3670
+ const Value = createTypeWithError(
3671
+ "Value",
3672
+ union(String, Number),
3673
+ (cause) => ({ type: "Value" as const, cause }),
3674
+ () => "Invalid value.",
3675
+ );
3676
+ const { custom } = localizeTypes(
3677
+ { Settings: object({ value: Value }) },
3678
+ { custom: { Object: () => "Object.", Value: () => "Custom value." } },
3679
+ );
3680
+ const result = custom.Settings.fromUnknown(
3681
+ { value: false },
3682
+ { errors: "all" },
3683
+ );
3684
+ assertErr(result);
3685
+ assertTrue(result.error.reason.kind === "Properties");
3686
+ const error = result.error.reason.errors.value;
3687
+ assertTrue(error?.type === "Value");
3688
+ assertEqual(error.cause.errors.length, 2);
3689
+ assertEqual(typeErrorToIssues(custom.Settings, result.error), [
3690
+ { path: ["value"], message: "Custom value." },
3691
+ ]);
3692
+ });
3693
+
3694
+ it("rejects transforming sources and incompatible error names", () => {
3695
+ const compileTimeAssertions = (
3696
+ uncertain: typeof String | typeof Number,
3697
+ ) => {
3698
+ createTypeWithError(
3699
+ "Value",
3700
+ // @ts-expect-error Source Type must use identity encoding.
3701
+ BooleanFromString,
3702
+ (cause) => ({ type: "Value" as const, cause }),
3703
+ () => "Invalid value.",
3704
+ );
3705
+ createTypeWithError(
3706
+ "Value",
3707
+ // @ts-expect-error Output Type must be one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
3708
+ uncertain,
3709
+ (cause) => ({ type: "Value" as const, cause }),
3710
+ () => "Invalid value.",
3711
+ );
3712
+ createTypeWithError(
3713
+ "Value",
3714
+ String,
3715
+ // @ts-expect-error The mapped error discriminant must match the Type name "Value".
3716
+ () => ({ type: "Other" as const }),
3717
+ () => "Invalid value.",
3718
+ );
3719
+ };
3720
+ assertType<
3721
+ typeof compileTimeAssertions,
3722
+ (uncertain: typeof String | typeof Number) => void
3723
+ >();
3724
+ });
3725
+ });
3726
+
3194
3727
  describe("transform", () => {
3195
3728
  it("creates a Type between encoded and output Types", () => {
3196
3729
  const NumberFromString = setupNumberFromString();
@@ -4432,7 +4965,47 @@ describe("instanceOf", () => {
4432
4965
  });
4433
4966
  });
4434
4967
 
4968
+ it("ValidateOutput accepts concrete Types and rejects unions of Type nodes", () => {
4969
+ const defineOutput = <T extends AnyType>(type: T & ValidateOutput<T>): T =>
4970
+ type;
4971
+ const Value = union(String, Number);
4972
+ assertSame(defineOutput(Value), Value);
4973
+ assertType<ValidateOutput<typeof String>, typeof String>();
4974
+ assertType<ValidateOutput<typeof Value>, typeof Value>();
4975
+ const uncertain = String as typeof String | typeof Number;
4976
+ const reject = () => {
4977
+ // @ts-expect-error Output Type must be one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
4978
+ defineOutput(uncertain);
4979
+ };
4980
+ assertType<typeof reject, () => void>();
4981
+ });
4982
+
4435
4983
  describe("literal", () => {
4984
+ it("exposes its compile-time literal guard for other factories", () => {
4985
+ assertType<ValidateLiteral<"APP_">, "APP_">();
4986
+ assertType<ValidateLiteral<42>, 42>();
4987
+ assertType<ValidateLiteral<42n>, 42n>();
4988
+ assertType<ValidateLiteral<true>, true>();
4989
+ assertType<ValidateLiteral<null>, null>();
4990
+ assertType<ValidateLiteral<undefined>, undefined>();
4991
+
4992
+ const definePrefix = <Prefix extends string>(
4993
+ prefix: Prefix & ValidateLiteral<Prefix>,
4994
+ ): Prefix => prefix;
4995
+ assertEqual(definePrefix("APP_"), "APP_");
4996
+ const reject = () => {
4997
+ // @ts-expect-error Expected must be one concrete literal value.
4998
+ definePrefix("APP_" as string);
4999
+ // @ts-expect-error Expected must be one concrete literal value.
5000
+ definePrefix("APP_" as "APP_" | "OTHER_");
5001
+ // @ts-expect-error Expected must be one concrete literal value.
5002
+ definePrefix(TrimmedString.orThrow("APP_"));
5003
+ // @ts-expect-error Expected must be one concrete literal value.
5004
+ definePrefix("APP_" as `APP_${string}`);
5005
+ };
5006
+ assertType<typeof reject, () => void>();
5007
+ });
5008
+
4436
5009
  const Hello = literal("Hello");
4437
5010
 
4438
5011
  it("creates a Literal Type with its primitive Type as parent", () => {
@@ -4494,7 +5067,10 @@ describe("literal", () => {
4494
5067
 
4495
5068
  assertEqual(Hello.orThrow("Hello"), "Hello");
4496
5069
  const error = assertThrowsInstanceOf(() => Hello.orThrow("World"), Error);
4497
- assertTrue(error.message.includes("getOrThrow"));
5070
+ assertEqual(
5071
+ error.message,
5072
+ Hello.formatError({ type: "Literal", expected: "Hello", value: "World" }),
5073
+ );
4498
5074
  assertEqual(Hello.orNull("Hello"), "Hello");
4499
5075
  assertSame(Hello.orNull("World"), null);
4500
5076
  });
@@ -5094,7 +5670,7 @@ describe("union", () => {
5094
5670
  }
5095
5671
  });
5096
5672
 
5097
- it("formats one message without enumerating member errors", () => {
5673
+ it("formats retained member errors in one message", () => {
5098
5674
  const result = StringOrNumber.fromUnknown(true, { errors: "all" });
5099
5675
 
5100
5676
  assertErr(result, {
@@ -5113,7 +5689,11 @@ describe("union", () => {
5113
5689
 
5114
5690
  assertEqual(
5115
5691
  StringOrNumber.formatError(result.error),
5116
- "A value does not match any allowed variant.",
5692
+ [
5693
+ "A value does not match any allowed variant.",
5694
+ "- 0: String: A value true is not a string.",
5695
+ "- 1: Number: A value true is not a number.",
5696
+ ].join("\n"),
5117
5697
  );
5118
5698
  assertType<
5119
5699
  Parameters<typeof StringOrNumber.formatError>[0],
@@ -5121,6 +5701,120 @@ describe("union", () => {
5121
5701
  >();
5122
5702
  });
5123
5703
 
5704
+ describe("error reporting", () => {
5705
+ it("formats only retained failures without validating again", () => {
5706
+ let calls = 0;
5707
+ const Counted = brand(
5708
+ "Counted",
5709
+ String,
5710
+ (): Result<void, TypeError<"Counted">> => {
5711
+ calls++;
5712
+ return err({ type: "Counted" });
5713
+ },
5714
+ () => "Counted failure.",
5715
+ );
5716
+ const Value = undefinedOr(Counted);
5717
+ const result = Value.fromUnknown("invalid");
5718
+ assertErr(result);
5719
+ const expected =
5720
+ "A value does not match any allowed variant.\n- 0: Counted: Counted failure.";
5721
+
5722
+ assertEqual(Value.formatError(result.error), expected);
5723
+ assertEqual(typeErrorToIssues(Value, result.error), [
5724
+ { path: [], message: expected },
5725
+ ]);
5726
+ assertEqual(calls, 1);
5727
+
5728
+ const thrown = assertThrowsInstanceOf(
5729
+ () => Value.orThrow("invalid"),
5730
+ Error,
5731
+ );
5732
+ assertEqual(thrown.message, expected);
5733
+ assertEqual(thrown.cause, result.error);
5734
+ assertEqual(calls, 2);
5735
+ });
5736
+
5737
+ it("keeps nested alternatives inside one issue at the enclosing path", async () => {
5738
+ const Value = object({
5739
+ config: union(
5740
+ object({ ports: array(undefinedOr(PortFromString)) }),
5741
+ Null,
5742
+ ),
5743
+ });
5744
+ const input = { config: { ports: ["65536"] } };
5745
+ const result = Value.fromUnknown(input, { errors: "all" });
5746
+ assertErr(result);
5747
+ const issues = typeErrorToIssues(Value, result.error);
5748
+ assertLength(issues, 1);
5749
+ assertEqual(issues[0].path, ["config"]);
5750
+ assertTrue(
5751
+ issues[0].message.includes(
5752
+ '- 0: Object["ports"][0]: A value does not match any allowed variant.\n - 0: PortFromString:',
5753
+ ),
5754
+ );
5755
+ assertTrue(
5756
+ issues[0].message.includes(
5757
+ "The value 65536 must be less than or equal to 65535.",
5758
+ ),
5759
+ );
5760
+ assertEqual(Value.formatError(result.error), issues[0].message);
5761
+ assertEqual(await Value["~standard"].validate(input), { issues });
5762
+ });
5763
+
5764
+ it("renders symbol member paths without adding them to the union path", () => {
5765
+ const Value = union(object({}), Null);
5766
+ const result = Value.fromUnknown({
5767
+ [globalThis.Symbol.for("setting")]: "bad",
5768
+ });
5769
+ assertErr(result);
5770
+ const issues = typeErrorToIssues(Value, result.error);
5771
+ assertLength(issues, 1);
5772
+ assertEqual(issues[0].path, []);
5773
+ assertTrue(issues[0].message.includes("- 0: Object[Symbol(setting)]:"));
5774
+ });
5775
+
5776
+ it("uses the correct member pipeline at parent boundaries", () => {
5777
+ const Value = undefinedOr(PortFromString);
5778
+ const input = Value.parent.fromUnknown(42);
5779
+ assertErr(input);
5780
+ assertEqual(
5781
+ Value.parent.formatError(input.error),
5782
+ [
5783
+ "A value does not match any allowed variant.",
5784
+ "- 0: String: A value 42 is not a string.",
5785
+ ].join("\n"),
5786
+ );
5787
+
5788
+ const remaining = Value.from.parent("65536", { errors: "all" });
5789
+ assertErr(remaining);
5790
+ assertEqual(
5791
+ Value.formatError(remaining.error),
5792
+ [
5793
+ "A value does not match any allowed variant.",
5794
+ "- 0: PortFromString: The value 65536 must be less than or equal to 65535.",
5795
+ ].join("\n"),
5796
+ );
5797
+ });
5798
+
5799
+ it("preserves specific failures through nullish defaults", () => {
5800
+ for (const Value of [
5801
+ withDefault(nullOr(PortFromString), Port.orThrow(4000)),
5802
+ withDefault(undefinedOr(PortFromString), Port.orThrow(4000)),
5803
+ withDefault(nullishOr(PortFromString), Port.orThrow(4000)),
5804
+ ]) {
5805
+ const error = assertThrowsInstanceOf(
5806
+ () => Value.orThrow("65536"),
5807
+ Error,
5808
+ );
5809
+ assertTrue(
5810
+ error.message.includes(
5811
+ "The value 65536 must be less than or equal to 65535.",
5812
+ ),
5813
+ );
5814
+ }
5815
+ });
5816
+ });
5817
+
5124
5818
  it("cannot fail when one member is infallible", () => {
5125
5819
  const StringOrUnknown = union(String, Unknown);
5126
5820
  const result = StringOrUnknown.fromUnknown(true);
@@ -5378,7 +6072,13 @@ describe("union", () => {
5378
6072
  );
5379
6073
  assertSame(Value.orNull(input), null);
5380
6074
  const error = assertThrowsInstanceOf(() => Value.orThrow(input), Error);
5381
- assertTrue(error.message.includes("getOrThrow"));
6075
+ assertEqual(
6076
+ error.message,
6077
+ [
6078
+ "A value does not match any allowed variant.",
6079
+ '- 0: Literal: The value "World" is not strictly equal to the expected literal: Hello.',
6080
+ ].join("\n"),
6081
+ );
5382
6082
  });
5383
6083
 
5384
6084
  it("validates only the remaining Type after a validated Union through from.parent", () => {
@@ -7080,7 +7780,7 @@ describe("brand", () => {
7080
7780
  assertType<typeof value, typeof Label.Output>();
7081
7781
  assertType<Parameters<typeof Label.orThrow>[0], typeof Label.Input>();
7082
7782
  validations.length = 0;
7083
- assertAssertionError(() => Label.orThrow(" value "), "getOrThrow", {
7783
+ assertAssertionError(() => Label.orThrow(" value "), "TrimmedString", {
7084
7784
  type: "TrimmedString",
7085
7785
  value: " value ",
7086
7786
  });
@@ -7357,22 +8057,301 @@ describe("brand", () => {
7357
8057
  });
7358
8058
  });
7359
8059
 
7360
- describe("BrandFactory", () => {
7361
- interface NonEmptyError extends TypeError<"NonEmpty"> {
7362
- readonly value: { readonly length: number };
8060
+ describe("Text casing", () => {
8061
+ const cases = [
8062
+ {
8063
+ type: CapitalizedString,
8064
+ convert: capitalize,
8065
+ input: "hello WORLD",
8066
+ expected: "Hello WORLD",
8067
+ description: "capitalized",
8068
+ },
8069
+ {
8070
+ type: UncapitalizedString,
8071
+ convert: uncapitalize,
8072
+ input: "Hello WORLD",
8073
+ expected: "hello WORLD",
8074
+ description: "uncapitalized",
8075
+ },
8076
+ {
8077
+ type: UppercasedString,
8078
+ convert: uppercase,
8079
+ input: "hello World",
8080
+ expected: "HELLO WORLD",
8081
+ description: "uppercased",
8082
+ },
8083
+ {
8084
+ type: LowercasedString,
8085
+ convert: lowercase,
8086
+ input: "Hello WORLD",
8087
+ expected: "hello world",
8088
+ description: "lowercased",
8089
+ },
8090
+ ] as const;
8091
+
8092
+ for (const { type, convert, input, expected, description } of cases) {
8093
+ it(`validates ${description} text without changing it`, () => {
8094
+ assertOk(type.fromUnknown(expected), expected);
8095
+ assertOk(type.from.parent(expected), expected);
8096
+ assertEqual(type.to("123"), "123");
8097
+ assertErr(type.fromUnknown(input), { type: type.name, value: input });
8098
+ assertErr(type.fromUnknown(123), {
8099
+ type: "TypeOf",
8100
+ expected: "String",
8101
+ value: 123,
8102
+ });
8103
+ for (const value of ["", "123", "😀", " ", "\uD800", "\uDC00"]) {
8104
+ assertOk(type.fromUnknown(value), value);
8105
+ assertEqual(convert(value), value);
8106
+ }
8107
+ assertEqual(convert(input), expected);
8108
+ assertEqual(convert(expected), expected);
8109
+ });
7363
8110
  }
7364
8111
 
7365
- const createNonEmpty = () => {
7366
- const validations: Array<{ readonly length: number }> = [];
7367
- const nonEmpty: BrandFactory<
7368
- "NonEmpty",
7369
- { readonly length: number },
7370
- NonEmptyError
7371
- > = (parent) =>
7372
- brand(
7373
- "NonEmpty",
7374
- parent,
7375
- (value) => {
8112
+ it("uses intrinsic types and accepts known literals directly", () => {
8113
+ assertType<CapitalizedString, Capitalize<string>>();
8114
+ assertType<UncapitalizedString, Uncapitalize<string>>();
8115
+ assertType<UppercasedString, Uppercase<string>>();
8116
+ assertType<LowercasedString, Lowercase<string>>();
8117
+ assertType<ReturnType<typeof capitalize>, Capitalize<string>>();
8118
+ assertType<ReturnType<typeof uncapitalize>, Uncapitalize<string>>();
8119
+ assertType<ReturnType<typeof uppercase>, Uppercase<string>>();
8120
+ assertType<ReturnType<typeof lowercase>, Lowercase<string>>();
8121
+ const title: CapitalizedString = "Hello world";
8122
+ const sentence: UncapitalizedString = "hello WORLD";
8123
+ const shout: UppercasedString = "HELLO WORLD";
8124
+ const quiet: LowercasedString = "hello world";
8125
+ assertOk(CapitalizedString.fromUnknown(title), title);
8126
+ assertOk(UncapitalizedString.fromUnknown(sentence), sentence);
8127
+ assertOk(UppercasedString.fromUnknown(shout), shout);
8128
+ assertOk(LowercasedString.fromUnknown(quiet), quiet);
8129
+ // @ts-expect-error A lowercase initial does not satisfy Capitalize<string>.
8130
+ const _title: CapitalizedString = "hello";
8131
+ // @ts-expect-error An uppercase initial does not satisfy Uncapitalize<string>.
8132
+ const _sentence: UncapitalizedString = "Hello";
8133
+ // @ts-expect-error A lowercase character does not satisfy Uppercase<string>.
8134
+ const _shout: UppercasedString = "HELLo";
8135
+ // @ts-expect-error An uppercase character does not satisfy Lowercase<string>.
8136
+ const _quiet: LowercasedString = "hellO";
8137
+ });
8138
+
8139
+ it("infers converted literals, unions, and runtime strings", () => {
8140
+ const capital = capitalize("hello");
8141
+ const uncapital = uncapitalize("Hello");
8142
+ const upper = uppercase("hello");
8143
+ const lower = lowercase("HELLO");
8144
+ assertType<typeof capital, "Hello">();
8145
+ assertType<typeof uncapital, "hello">();
8146
+ assertType<typeof upper, "HELLO">();
8147
+ assertType<typeof lower, "hello">();
8148
+ assertEqual(
8149
+ [capital, uncapital, upper, lower],
8150
+ ["Hello", "hello", "HELLO", "hello"],
8151
+ );
8152
+
8153
+ for (const value of ["hello", "WORLD"] as const) {
8154
+ const capital = capitalize(value);
8155
+ const uncapital = uncapitalize(value);
8156
+ const upper = uppercase(value);
8157
+ const lower = lowercase(value);
8158
+ assertType<typeof capital, "Hello" | "WORLD">();
8159
+ assertType<typeof uncapital, "hello" | "wORLD">();
8160
+ assertType<typeof upper, "HELLO" | "WORLD">();
8161
+ assertType<typeof lower, "hello" | "world">();
8162
+ }
8163
+
8164
+ const value = String.orThrow("Hello");
8165
+ const runtimeCapital = capitalize(value);
8166
+ const runtimeUncapital = uncapitalize(value);
8167
+ const runtimeUpper = uppercase(value);
8168
+ const runtimeLower = lowercase(value);
8169
+ assertType<typeof runtimeCapital, Capitalize<string>>();
8170
+ assertType<typeof runtimeUncapital, Uncapitalize<string>>();
8171
+ assertType<typeof runtimeUpper, Uppercase<string>>();
8172
+ assertType<typeof runtimeLower, Lowercase<string>>();
8173
+
8174
+ const astralCapital = capitalize("𐐨x");
8175
+ const astralUncapital = uncapitalize("𐐀X");
8176
+ const expandedUpper = uppercase("ß");
8177
+ const expandedLower = lowercase("İ");
8178
+ assertType<typeof astralCapital, "𐐀x">();
8179
+ assertType<typeof astralUncapital, "𐐨X">();
8180
+ assertType<typeof expandedUpper, "SS">();
8181
+ assertType<typeof expandedLower, "i\u0307">();
8182
+ assertEqual(
8183
+ [astralCapital, astralUncapital, expandedUpper, expandedLower],
8184
+ ["𐐀x", "𐐨X", "SS", "i\u0307"],
8185
+ );
8186
+ });
8187
+
8188
+ it("matches TS7 Unicode mappings and handles expansions and uncased initials", () => {
8189
+ assertType<Capitalize<"𐐨x">, "𐐀x">();
8190
+ assertType<Uncapitalize<"𐐀X">, "𐐨X">();
8191
+ assertType<Uppercase<"ß">, "SS">();
8192
+ assertType<Lowercase<"İ">, "i\u0307">();
8193
+ // @ts-expect-error A lowercase astral initial does not satisfy Capitalize<string>.
8194
+ const _lowerAstral: CapitalizedString = "𐐨x";
8195
+ // @ts-expect-error An uppercase astral initial does not satisfy Uncapitalize<string>.
8196
+ const _upperAstral: UncapitalizedString = "𐐀x";
8197
+ assertEqual(capitalize("𐐨x"), "𐐀x");
8198
+ assertEqual(uncapitalize("𐐀X"), "𐐨X");
8199
+ assertEqual(capitalize("ßabc"), "SSabc");
8200
+ assertEqual(uncapitalize("İABC"), "i\u0307ABC");
8201
+ assertEqual(uppercase("Straße"), "STRASSE");
8202
+ assertEqual(lowercase("ΟΣ"), "ος");
8203
+ assertEqual(capitalize("e\u0301clair"), "E\u0301clair");
8204
+ assertEqual(uncapitalize("E\u0301CLAIR"), "e\u0301CLAIR");
8205
+ for (const { type, convert } of cases) {
8206
+ for (const input of [
8207
+ "𐐨𐐀",
8208
+ "ßİ",
8209
+ "ffile",
8210
+ "Džuro",
8211
+ "ΟΣ",
8212
+ "😀hello",
8213
+ " hello",
8214
+ "1ABC",
8215
+ "\uD800x",
8216
+ "\uDC00X",
8217
+ ]) {
8218
+ const output = convert(input);
8219
+ assertTrue(type.is(output));
8220
+ assertEqual(convert(output), output);
8221
+ }
8222
+ }
8223
+ assertOk(CapitalizedString.fromUnknown("😀hello"), "😀hello");
8224
+ assertOk(UncapitalizedString.fromUnknown("😀HELLO"), "😀HELLO");
8225
+ assertErr(CapitalizedString.fromUnknown("𐐨x"));
8226
+ assertErr(UncapitalizedString.fromUnknown("𐐀x"));
8227
+ });
8228
+
8229
+ it("preserves parent constraints during validation and drops them during conversion", () => {
8230
+ const Short = maxLength(1)(String);
8231
+ const Capitalized = capitalized(Short);
8232
+ const Uncapitalized = uncapitalized(Short);
8233
+ const Uppercased = uppercased(Short);
8234
+ const Lowercased = lowercased(Short);
8235
+ assertType<
8236
+ typeof Capitalized.Output,
8237
+ string & Brand<"MaxLength1"> & Capitalize<string>
8238
+ >();
8239
+ assertType<
8240
+ typeof Uncapitalized.Output,
8241
+ string & Brand<"MaxLength1"> & Uncapitalize<string>
8242
+ >();
8243
+ assertType<
8244
+ typeof Uppercased.Output,
8245
+ string & Brand<"MaxLength1"> & Uppercase<string>
8246
+ >();
8247
+ assertType<
8248
+ typeof Lowercased.Output,
8249
+ string & Brand<"MaxLength1"> & Lowercase<string>
8250
+ >();
8251
+ assertType<
8252
+ InferErrors<typeof Capitalized>,
8253
+ typeof Short.Error | typeof String.Error | typeof Capitalized.Error
8254
+ >();
8255
+ for (const [type, valid] of [
8256
+ [Capitalized, "A"],
8257
+ [Uncapitalized, "a"],
8258
+ [Uppercased, "A"],
8259
+ [Lowercased, "a"],
8260
+ ] as const) {
8261
+ assertSame(type.parent, Short);
8262
+ assertOk(type.fromUnknown(valid), valid);
8263
+ assertErr(type.fromUnknown(valid.repeat(2)), {
8264
+ type: "MaxLength1",
8265
+ value: valid.repeat(2),
8266
+ max: 1,
8267
+ });
8268
+ }
8269
+ const sharpS = Short.orThrow("ß");
8270
+ const dottedI = Short.orThrow("İ");
8271
+ const capital = capitalize(sharpS);
8272
+ const uncapital = uncapitalize(dottedI);
8273
+ const upper = uppercase(sharpS);
8274
+ const lower = lowercase(dottedI);
8275
+ assertEqual(capital, "SS");
8276
+ assertEqual(uncapital, "i\u0307");
8277
+ assertEqual(upper, "SS");
8278
+ assertEqual(lower, "i\u0307");
8279
+ // @ts-expect-error Capitalization does not retain the MaxLength1 brand.
8280
+ const _capital: typeof sharpS = capital;
8281
+ // @ts-expect-error Uncapitalization does not retain the MaxLength1 brand.
8282
+ const _uncapital: typeof dottedI = uncapital;
8283
+ // @ts-expect-error Uppercasing does not retain the MaxLength1 brand.
8284
+ const _upper: typeof sharpS = upper;
8285
+ // @ts-expect-error Lowercasing does not retain the MaxLength1 brand.
8286
+ const _lower: typeof dottedI = lower;
8287
+ });
8288
+
8289
+ it("preserves a parent's decoding and encoding", () => {
8290
+ const PrefixedString = transform("PrefixedString", String, String, {
8291
+ from: (value) => ok(value.slice(1)),
8292
+ to: (value) => `!${value}`,
8293
+ });
8294
+ for (const [type, value] of [
8295
+ [capitalized(PrefixedString), "Hello"],
8296
+ [uncapitalized(PrefixedString), "hello"],
8297
+ [uppercased(PrefixedString), "HELLO"],
8298
+ [lowercased(PrefixedString), "hello"],
8299
+ ] as const) {
8300
+ const result = type.fromUnknown(`!${value}`);
8301
+ assertOk(result);
8302
+ assertEqual(result.value, value);
8303
+ assertEqual(type.to("123"), "!123");
8304
+ assertErr(type.fromUnknown(null));
8305
+ }
8306
+ });
8307
+
8308
+ it("formats casing errors and matches the default English messages", () => {
8309
+ assertEqual(
8310
+ formatUncapitalizedError({ type: "Uncapitalized", value: "Hello" }),
8311
+ 'The value "Hello" must not start with an uppercase letter.',
8312
+ );
8313
+ assertEqual(
8314
+ cs.formatUncapitalizedError({ type: "Uncapitalized", value: "Hello" }),
8315
+ 'Hodnota "Hello" nesmí začínat velkým písmenem.',
8316
+ );
8317
+ assertEqual(
8318
+ formatCapitalizedError({ type: "Capitalized", value: "hello" }),
8319
+ CapitalizedString.formatError({ type: "Capitalized", value: "hello" }),
8320
+ );
8321
+ assertEqual(
8322
+ formatUncapitalizedError({ type: "Uncapitalized", value: "Hello" }),
8323
+ UncapitalizedString.formatError({
8324
+ type: "Uncapitalized",
8325
+ value: "Hello",
8326
+ }),
8327
+ );
8328
+ assertEqual(
8329
+ formatUppercasedError({ type: "Uppercased", value: "Hello" }),
8330
+ UppercasedString.formatError({ type: "Uppercased", value: "Hello" }),
8331
+ );
8332
+ assertEqual(
8333
+ formatLowercasedError({ type: "Lowercased", value: "Hello" }),
8334
+ LowercasedString.formatError({ type: "Lowercased", value: "Hello" }),
8335
+ );
8336
+ });
8337
+ });
8338
+
8339
+ describe("BrandFactory", () => {
8340
+ interface NonEmptyError extends TypeError<"NonEmpty"> {
8341
+ readonly value: { readonly length: number };
8342
+ }
8343
+
8344
+ const createNonEmpty = () => {
8345
+ const validations: Array<{ readonly length: number }> = [];
8346
+ const nonEmpty: BrandFactory<
8347
+ "NonEmpty",
8348
+ { readonly length: number },
8349
+ NonEmptyError
8350
+ > = (parent) =>
8351
+ brand(
8352
+ "NonEmpty",
8353
+ parent,
8354
+ (value) => {
7376
8355
  validations.push(value);
7377
8356
 
7378
8357
  return value.length > 0 ? ok() : err({ type: "NonEmpty", value });
@@ -7613,81 +8592,1048 @@ describe("BrandFactory", () => {
7613
8592
  });
7614
8593
 
7615
8594
  describe("Type Factory", () => {
7616
- describe("capitalized", () => {
8595
+ describe("identifier", () => {
8596
+ const formats = [
8597
+ {
8598
+ type: CamelCaseIdentifier,
8599
+ casing: "camelCase",
8600
+ valid: [
8601
+ "a",
8602
+ "a0",
8603
+ "http2Port",
8604
+ "v2beta",
8605
+ "v2Beta",
8606
+ "maxOwnerBytes",
8607
+ "httpUrl",
8608
+ "httpURL",
8609
+ "aBC",
8610
+ ],
8611
+ invalid: ["Http2Port", "HTTP2_PORT", "http_port", "http-port"],
8612
+ },
8613
+ {
8614
+ type: PascalCaseIdentifier,
8615
+ casing: "PascalCase",
8616
+ valid: [
8617
+ "A",
8618
+ "A0",
8619
+ "Http2Port",
8620
+ "V2beta",
8621
+ "V2Beta",
8622
+ "MaxOwnerBytes",
8623
+ "HttpUrl",
8624
+ "HttpURL",
8625
+ "ABC",
8626
+ ],
8627
+ invalid: ["http2Port", "HTTP2_PORT", "http_port", "http-port"],
8628
+ },
8629
+ {
8630
+ type: SnakeCaseIdentifier,
8631
+ casing: "snake_case",
8632
+ valid: [
8633
+ "a",
8634
+ "a0",
8635
+ "http2_port",
8636
+ "v2beta",
8637
+ "v2_beta",
8638
+ "max_owner_bytes",
8639
+ "http_url",
8640
+ "http_u_r_l",
8641
+ "a_b_c",
8642
+ ],
8643
+ invalid: [
8644
+ "HTTP2_PORT",
8645
+ "httpPort",
8646
+ "http-port",
8647
+ "_port",
8648
+ "port_",
8649
+ "http__port",
8650
+ "http_2_port",
8651
+ ],
8652
+ },
8653
+ {
8654
+ type: KebabCaseIdentifier,
8655
+ casing: "kebab-case",
8656
+ valid: [
8657
+ "a",
8658
+ "a0",
8659
+ "http2-port",
8660
+ "v2beta",
8661
+ "v2-beta",
8662
+ "max-owner-bytes",
8663
+ "http-url",
8664
+ "http-u-r-l",
8665
+ "a-b-c",
8666
+ ],
8667
+ invalid: [
8668
+ "HTTP2-PORT",
8669
+ "httpPort",
8670
+ "http_port",
8671
+ "-port",
8672
+ "port-",
8673
+ "http--port",
8674
+ "http-2-port",
8675
+ ],
8676
+ },
8677
+ {
8678
+ type: ConstantCaseIdentifier,
8679
+ casing: "CONSTANT_CASE",
8680
+ valid: [
8681
+ "A",
8682
+ "A0",
8683
+ "HTTP2_PORT",
8684
+ "V2BETA",
8685
+ "V2_BETA",
8686
+ "MAX_OWNER_BYTES",
8687
+ "HTTP_URL",
8688
+ "HTTP_U_R_L",
8689
+ "A_B_C",
8690
+ ],
8691
+ invalid: [
8692
+ "http2_port",
8693
+ "httpPort",
8694
+ "HTTP-PORT",
8695
+ "_PORT",
8696
+ "PORT_",
8697
+ "HTTP__PORT",
8698
+ "HTTP_2_PORT",
8699
+ ],
8700
+ },
8701
+ ] as const;
8702
+
8703
+ for (const { type, casing, valid, invalid } of formats) {
8704
+ it(`${type.name} accepts only its complete ASCII grammar`, () => {
8705
+ for (const value of valid) {
8706
+ assertOk(type.fromUnknown(value), value);
8707
+ assertTrue(type.is(value));
8708
+ }
8709
+ for (const value of [
8710
+ ...invalid,
8711
+ "",
8712
+ "0",
8713
+ "2FA",
8714
+ "2fa",
8715
+ " hello",
8716
+ "hello ",
8717
+ "hello world",
8718
+ "HELLO WORLD",
8719
+ "hello.world",
8720
+ "HELLO.WORLD",
8721
+ "déjà",
8722
+ "DÉJÀ",
8723
+ "𐐀x",
8724
+ "😀",
8725
+ "a\u0301",
8726
+ "A\u0301",
8727
+ ...valid.flatMap((value) =>
8728
+ ["\n", "\r", "\r\n", "\t", "\0", "\u2028", "\u2029"].map(
8729
+ (suffix) => value + suffix,
8730
+ ),
8731
+ ),
8732
+ ]) {
8733
+ assertErr(type.fromUnknown(value), {
8734
+ type: type.name,
8735
+ casing,
8736
+ value,
8737
+ });
8738
+ assertFalse(type.is(value));
8739
+ }
8740
+ assertErr(type.fromUnknown(123), {
8741
+ type: "TypeOf",
8742
+ expected: "String",
8743
+ value: 123,
8744
+ });
8745
+ const empty = type.fromUnknown("");
8746
+ assertErr(empty);
8747
+ assertEqual(
8748
+ typeErrorToIssues(type, empty.error)[0]?.message,
8749
+ `The value "" is not a ${casing} identifier.`,
8750
+ );
8751
+ });
8752
+ }
8753
+
8754
+ it("exposes distinct brands and composes validation constraints", () => {
8755
+ assertType<
8756
+ CamelCaseIdentifier,
8757
+ string & Brand<"CamelCaseIdentifier">
8758
+ >();
8759
+ assertType<
8760
+ PascalCaseIdentifier,
8761
+ string & Brand<"PascalCaseIdentifier">
8762
+ >();
8763
+ assertType<
8764
+ SnakeCaseIdentifier,
8765
+ string & Brand<"SnakeCaseIdentifier">
8766
+ >();
8767
+ assertType<
8768
+ KebabCaseIdentifier,
8769
+ string & Brand<"KebabCaseIdentifier">
8770
+ >();
8771
+ assertType<
8772
+ ConstantCaseIdentifier,
8773
+ string & Brand<"ConstantCaseIdentifier">
8774
+ >();
8775
+ assertType<
8776
+ IdentifierError<"camelCase">["type"],
8777
+ "CamelCaseIdentifier"
8778
+ >();
8779
+ assertType<IdentifierError["casing"], IdentifierCasing>();
8780
+
8781
+ const ShortCamel = identifier("camelCase")(maxLength(3)(String));
8782
+ assertType<
8783
+ typeof ShortCamel.Output,
8784
+ string & Brand<"MaxLength3"> & Brand<"CamelCaseIdentifier">
8785
+ >();
8786
+ assertOk(ShortCamel.fromUnknown("aBC"), "aBC");
8787
+ assertErr(ShortCamel.fromUnknown("long"), {
8788
+ type: "MaxLength3",
8789
+ value: "long",
8790
+ max: 3,
8791
+ });
8792
+ assertErr(ShortCamel.fromUnknown("Ab"), {
8793
+ type: "CamelCaseIdentifier",
8794
+ casing: "camelCase",
8795
+ value: "Ab",
8796
+ });
8797
+ assertOk(
8798
+ identifier("PascalCase")(maxLength(3)(String)).fromUnknown("ABC"),
8799
+ "ABC",
8800
+ );
8801
+ assertOk(
8802
+ identifier("snake_case")(maxLength(3)(String)).fromUnknown("a_b"),
8803
+ "a_b",
8804
+ );
8805
+ assertOk(
8806
+ identifier("kebab-case")(maxLength(3)(String)).fromUnknown("a-b"),
8807
+ "a-b",
8808
+ );
8809
+ assertOk(
8810
+ identifier("CONSTANT_CASE")(maxLength(3)(String)).fromUnknown("A_B"),
8811
+ "A_B",
8812
+ );
8813
+ });
8814
+ });
8815
+
8816
+ describe("naming conversions", () => {
8817
+ const conversions = [
8818
+ {
8819
+ from: "CamelCase",
8820
+ to: "PascalCase",
8821
+ convert: (value: string) =>
8822
+ camelCaseToPascalCase(CamelCaseIdentifier.orThrow(value)),
8823
+ },
8824
+ {
8825
+ from: "CamelCase",
8826
+ to: "SnakeCase",
8827
+ convert: (value: string) =>
8828
+ camelCaseToSnakeCase(CamelCaseIdentifier.orThrow(value)),
8829
+ },
8830
+ {
8831
+ from: "CamelCase",
8832
+ to: "KebabCase",
8833
+ convert: (value: string) =>
8834
+ camelCaseToKebabCase(CamelCaseIdentifier.orThrow(value)),
8835
+ },
8836
+ {
8837
+ from: "CamelCase",
8838
+ to: "ConstantCase",
8839
+ convert: (value: string) =>
8840
+ camelCaseToConstantCase(CamelCaseIdentifier.orThrow(value)),
8841
+ },
8842
+ {
8843
+ from: "PascalCase",
8844
+ to: "CamelCase",
8845
+ convert: (value: string) =>
8846
+ pascalCaseToCamelCase(PascalCaseIdentifier.orThrow(value)),
8847
+ },
8848
+ {
8849
+ from: "PascalCase",
8850
+ to: "SnakeCase",
8851
+ convert: (value: string) =>
8852
+ pascalCaseToSnakeCase(PascalCaseIdentifier.orThrow(value)),
8853
+ },
8854
+ {
8855
+ from: "PascalCase",
8856
+ to: "KebabCase",
8857
+ convert: (value: string) =>
8858
+ pascalCaseToKebabCase(PascalCaseIdentifier.orThrow(value)),
8859
+ },
8860
+ {
8861
+ from: "PascalCase",
8862
+ to: "ConstantCase",
8863
+ convert: (value: string) =>
8864
+ pascalCaseToConstantCase(PascalCaseIdentifier.orThrow(value)),
8865
+ },
8866
+ {
8867
+ from: "SnakeCase",
8868
+ to: "CamelCase",
8869
+ convert: (value: string) =>
8870
+ snakeCaseToCamelCase(SnakeCaseIdentifier.orThrow(value)),
8871
+ },
8872
+ {
8873
+ from: "SnakeCase",
8874
+ to: "PascalCase",
8875
+ convert: (value: string) =>
8876
+ snakeCaseToPascalCase(SnakeCaseIdentifier.orThrow(value)),
8877
+ },
8878
+ {
8879
+ from: "SnakeCase",
8880
+ to: "KebabCase",
8881
+ convert: (value: string) =>
8882
+ snakeCaseToKebabCase(SnakeCaseIdentifier.orThrow(value)),
8883
+ },
8884
+ {
8885
+ from: "SnakeCase",
8886
+ to: "ConstantCase",
8887
+ convert: (value: string) =>
8888
+ snakeCaseToConstantCase(SnakeCaseIdentifier.orThrow(value)),
8889
+ },
8890
+ {
8891
+ from: "KebabCase",
8892
+ to: "CamelCase",
8893
+ convert: (value: string) =>
8894
+ kebabCaseToCamelCase(KebabCaseIdentifier.orThrow(value)),
8895
+ },
8896
+ {
8897
+ from: "KebabCase",
8898
+ to: "PascalCase",
8899
+ convert: (value: string) =>
8900
+ kebabCaseToPascalCase(KebabCaseIdentifier.orThrow(value)),
8901
+ },
8902
+ {
8903
+ from: "KebabCase",
8904
+ to: "SnakeCase",
8905
+ convert: (value: string) =>
8906
+ kebabCaseToSnakeCase(KebabCaseIdentifier.orThrow(value)),
8907
+ },
8908
+ {
8909
+ from: "KebabCase",
8910
+ to: "ConstantCase",
8911
+ convert: (value: string) =>
8912
+ kebabCaseToConstantCase(KebabCaseIdentifier.orThrow(value)),
8913
+ },
8914
+ {
8915
+ from: "ConstantCase",
8916
+ to: "CamelCase",
8917
+ convert: (value: string) =>
8918
+ constantCaseToCamelCase(ConstantCaseIdentifier.orThrow(value)),
8919
+ },
8920
+ {
8921
+ from: "ConstantCase",
8922
+ to: "PascalCase",
8923
+ convert: (value: string) =>
8924
+ constantCaseToPascalCase(ConstantCaseIdentifier.orThrow(value)),
8925
+ },
8926
+ {
8927
+ from: "ConstantCase",
8928
+ to: "SnakeCase",
8929
+ convert: (value: string) =>
8930
+ constantCaseToSnakeCase(ConstantCaseIdentifier.orThrow(value)),
8931
+ },
8932
+ {
8933
+ from: "ConstantCase",
8934
+ to: "KebabCase",
8935
+ convert: (value: string) =>
8936
+ constantCaseToKebabCase(ConstantCaseIdentifier.orThrow(value)),
8937
+ },
8938
+ ] as const;
8939
+
8940
+ it("preserves the exact spelling for every pair of formats", () => {
8941
+ const spellings = [
8942
+ {
8943
+ CamelCase: "maxOwnerBytes",
8944
+ PascalCase: "MaxOwnerBytes",
8945
+ SnakeCase: "max_owner_bytes",
8946
+ KebabCase: "max-owner-bytes",
8947
+ ConstantCase: "MAX_OWNER_BYTES",
8948
+ },
8949
+ {
8950
+ CamelCase: "http2Port",
8951
+ PascalCase: "Http2Port",
8952
+ SnakeCase: "http2_port",
8953
+ KebabCase: "http2-port",
8954
+ ConstantCase: "HTTP2_PORT",
8955
+ },
8956
+ {
8957
+ CamelCase: "httpUrl",
8958
+ PascalCase: "HttpUrl",
8959
+ SnakeCase: "http_url",
8960
+ KebabCase: "http-url",
8961
+ ConstantCase: "HTTP_URL",
8962
+ },
8963
+ {
8964
+ CamelCase: "httpURL",
8965
+ PascalCase: "HttpURL",
8966
+ SnakeCase: "http_u_r_l",
8967
+ KebabCase: "http-u-r-l",
8968
+ ConstantCase: "HTTP_U_R_L",
8969
+ },
8970
+ {
8971
+ CamelCase: "aBC",
8972
+ PascalCase: "ABC",
8973
+ SnakeCase: "a_b_c",
8974
+ KebabCase: "a-b-c",
8975
+ ConstantCase: "A_B_C",
8976
+ },
8977
+ {
8978
+ CamelCase: "v2Beta",
8979
+ PascalCase: "V2Beta",
8980
+ SnakeCase: "v2_beta",
8981
+ KebabCase: "v2-beta",
8982
+ ConstantCase: "V2_BETA",
8983
+ },
8984
+ {
8985
+ CamelCase: "v2beta",
8986
+ PascalCase: "V2beta",
8987
+ SnakeCase: "v2beta",
8988
+ KebabCase: "v2beta",
8989
+ ConstantCase: "V2BETA",
8990
+ },
8991
+ {
8992
+ CamelCase: "a",
8993
+ PascalCase: "A",
8994
+ SnakeCase: "a",
8995
+ KebabCase: "a",
8996
+ ConstantCase: "A",
8997
+ },
8998
+ {
8999
+ CamelCase: "a0B2c",
9000
+ PascalCase: "A0B2c",
9001
+ SnakeCase: "a0_b2c",
9002
+ KebabCase: "a0-b2c",
9003
+ ConstantCase: "A0_B2C",
9004
+ },
9005
+ {
9006
+ CamelCase: "max2faAttempts",
9007
+ PascalCase: "Max2faAttempts",
9008
+ SnakeCase: "max2fa_attempts",
9009
+ KebabCase: "max2fa-attempts",
9010
+ ConstantCase: "MAX2FA_ATTEMPTS",
9011
+ },
9012
+ ];
9013
+ for (const values of spellings) {
9014
+ for (const { from, to, convert } of conversions) {
9015
+ assertEqual(convert(values[from]), values[to]);
9016
+ }
9017
+ }
9018
+ });
9019
+
9020
+ it("round-trips generated word sequences through every format", () => {
9021
+ const words = ["a", "b", "z", "a0", "b2", "ab", "z9a", "http2", "url"];
9022
+ const formats = {
9023
+ CamelCase: CamelCaseIdentifier,
9024
+ PascalCase: PascalCaseIdentifier,
9025
+ SnakeCase: SnakeCaseIdentifier,
9026
+ KebabCase: KebabCaseIdentifier,
9027
+ ConstantCase: ConstantCaseIdentifier,
9028
+ };
9029
+ for (const first of words) {
9030
+ for (const second of words) {
9031
+ for (const third of words) {
9032
+ const parts = [first, second, third];
9033
+ const titled = parts.map(
9034
+ (word) => word.charAt(0).toUpperCase() + word.slice(1),
9035
+ );
9036
+ const spellings = {
9037
+ CamelCase: first + titled.slice(1).join(""),
9038
+ PascalCase: titled.join(""),
9039
+ SnakeCase: parts.join("_"),
9040
+ KebabCase: parts.join("-"),
9041
+ ConstantCase: parts.join("_").toUpperCase(),
9042
+ };
9043
+ for (const { from, to, convert } of conversions) {
9044
+ const converted = convert(spellings[from]);
9045
+ assertTrue(formats[to].is(converted));
9046
+ assertEqual(converted, spellings[to]);
9047
+ const reverse = conversions.find(
9048
+ (conversion) =>
9049
+ conversion.from === to && conversion.to === from,
9050
+ );
9051
+ nodeAssert.ok(reverse);
9052
+ assertEqual(reverse.convert(converted), spellings[from]);
9053
+ }
9054
+ }
9055
+ }
9056
+ }
9057
+ });
9058
+
9059
+ it("requires the source brand and returns only the destination brand", () => {
9060
+ assertType<
9061
+ Parameters<typeof camelCaseToPascalCase>[0],
9062
+ CamelCaseIdentifier
9063
+ >();
9064
+ assertType<
9065
+ ReturnType<typeof camelCaseToPascalCase>,
9066
+ PascalCaseIdentifier
9067
+ >();
9068
+ assertType<
9069
+ Parameters<typeof camelCaseToSnakeCase>[0],
9070
+ CamelCaseIdentifier
9071
+ >();
9072
+ assertType<
9073
+ ReturnType<typeof camelCaseToSnakeCase>,
9074
+ SnakeCaseIdentifier
9075
+ >();
9076
+ assertType<
9077
+ Parameters<typeof camelCaseToKebabCase>[0],
9078
+ CamelCaseIdentifier
9079
+ >();
9080
+ assertType<
9081
+ ReturnType<typeof camelCaseToKebabCase>,
9082
+ KebabCaseIdentifier
9083
+ >();
9084
+ assertType<
9085
+ Parameters<typeof camelCaseToConstantCase>[0],
9086
+ CamelCaseIdentifier
9087
+ >();
9088
+ assertType<
9089
+ ReturnType<typeof camelCaseToConstantCase>,
9090
+ ConstantCaseIdentifier
9091
+ >();
9092
+ assertType<
9093
+ Parameters<typeof pascalCaseToCamelCase>[0],
9094
+ PascalCaseIdentifier
9095
+ >();
9096
+ assertType<
9097
+ ReturnType<typeof pascalCaseToCamelCase>,
9098
+ CamelCaseIdentifier
9099
+ >();
9100
+ assertType<
9101
+ Parameters<typeof pascalCaseToSnakeCase>[0],
9102
+ PascalCaseIdentifier
9103
+ >();
9104
+ assertType<
9105
+ ReturnType<typeof pascalCaseToSnakeCase>,
9106
+ SnakeCaseIdentifier
9107
+ >();
9108
+ assertType<
9109
+ Parameters<typeof pascalCaseToKebabCase>[0],
9110
+ PascalCaseIdentifier
9111
+ >();
9112
+ assertType<
9113
+ ReturnType<typeof pascalCaseToKebabCase>,
9114
+ KebabCaseIdentifier
9115
+ >();
9116
+ assertType<
9117
+ Parameters<typeof pascalCaseToConstantCase>[0],
9118
+ PascalCaseIdentifier
9119
+ >();
9120
+ assertType<
9121
+ ReturnType<typeof pascalCaseToConstantCase>,
9122
+ ConstantCaseIdentifier
9123
+ >();
9124
+ assertType<
9125
+ Parameters<typeof snakeCaseToCamelCase>[0],
9126
+ SnakeCaseIdentifier
9127
+ >();
9128
+ assertType<
9129
+ ReturnType<typeof snakeCaseToCamelCase>,
9130
+ CamelCaseIdentifier
9131
+ >();
9132
+ assertType<
9133
+ Parameters<typeof snakeCaseToPascalCase>[0],
9134
+ SnakeCaseIdentifier
9135
+ >();
9136
+ assertType<
9137
+ ReturnType<typeof snakeCaseToPascalCase>,
9138
+ PascalCaseIdentifier
9139
+ >();
9140
+ assertType<
9141
+ Parameters<typeof snakeCaseToKebabCase>[0],
9142
+ SnakeCaseIdentifier
9143
+ >();
9144
+ assertType<
9145
+ ReturnType<typeof snakeCaseToKebabCase>,
9146
+ KebabCaseIdentifier
9147
+ >();
9148
+ assertType<
9149
+ Parameters<typeof snakeCaseToConstantCase>[0],
9150
+ SnakeCaseIdentifier
9151
+ >();
9152
+ assertType<
9153
+ ReturnType<typeof snakeCaseToConstantCase>,
9154
+ ConstantCaseIdentifier
9155
+ >();
9156
+ assertType<
9157
+ Parameters<typeof kebabCaseToCamelCase>[0],
9158
+ KebabCaseIdentifier
9159
+ >();
9160
+ assertType<
9161
+ ReturnType<typeof kebabCaseToCamelCase>,
9162
+ CamelCaseIdentifier
9163
+ >();
9164
+ assertType<
9165
+ Parameters<typeof kebabCaseToPascalCase>[0],
9166
+ KebabCaseIdentifier
9167
+ >();
9168
+ assertType<
9169
+ ReturnType<typeof kebabCaseToPascalCase>,
9170
+ PascalCaseIdentifier
9171
+ >();
9172
+ assertType<
9173
+ Parameters<typeof kebabCaseToSnakeCase>[0],
9174
+ KebabCaseIdentifier
9175
+ >();
9176
+ assertType<
9177
+ ReturnType<typeof kebabCaseToSnakeCase>,
9178
+ SnakeCaseIdentifier
9179
+ >();
9180
+ assertType<
9181
+ Parameters<typeof kebabCaseToConstantCase>[0],
9182
+ KebabCaseIdentifier
9183
+ >();
9184
+ assertType<
9185
+ ReturnType<typeof kebabCaseToConstantCase>,
9186
+ ConstantCaseIdentifier
9187
+ >();
9188
+ assertType<
9189
+ Parameters<typeof constantCaseToCamelCase>[0],
9190
+ ConstantCaseIdentifier
9191
+ >();
9192
+ assertType<
9193
+ ReturnType<typeof constantCaseToCamelCase>,
9194
+ CamelCaseIdentifier
9195
+ >();
9196
+ assertType<
9197
+ Parameters<typeof constantCaseToPascalCase>[0],
9198
+ ConstantCaseIdentifier
9199
+ >();
9200
+ assertType<
9201
+ ReturnType<typeof constantCaseToPascalCase>,
9202
+ PascalCaseIdentifier
9203
+ >();
9204
+ assertType<
9205
+ Parameters<typeof constantCaseToSnakeCase>[0],
9206
+ ConstantCaseIdentifier
9207
+ >();
9208
+ assertType<
9209
+ ReturnType<typeof constantCaseToSnakeCase>,
9210
+ SnakeCaseIdentifier
9211
+ >();
9212
+ assertType<
9213
+ Parameters<typeof constantCaseToKebabCase>[0],
9214
+ ConstantCaseIdentifier
9215
+ >();
9216
+ assertType<
9217
+ ReturnType<typeof constantCaseToKebabCase>,
9218
+ KebabCaseIdentifier
9219
+ >();
9220
+ const short = maxLength(3)(CamelCaseIdentifier).orThrow("aBC");
9221
+ const constant = camelCaseToConstantCase(short);
9222
+ assertEqual(constant, "A_B_C");
9223
+ assertType<typeof constant, ConstantCaseIdentifier>();
9224
+ const compileTimeAssertions = () => {
9225
+ // @ts-expect-error Requires the CamelCaseIdentifier brand.
9226
+ camelCaseToPascalCase("example");
9227
+ // @ts-expect-error Requires the CamelCaseIdentifier brand.
9228
+ camelCaseToSnakeCase("example");
9229
+ // @ts-expect-error Requires the CamelCaseIdentifier brand.
9230
+ camelCaseToKebabCase("example");
9231
+ // @ts-expect-error Requires the CamelCaseIdentifier brand.
9232
+ camelCaseToConstantCase("example");
9233
+ // @ts-expect-error Requires the PascalCaseIdentifier brand.
9234
+ pascalCaseToCamelCase("example");
9235
+ // @ts-expect-error Requires the PascalCaseIdentifier brand.
9236
+ pascalCaseToSnakeCase("example");
9237
+ // @ts-expect-error Requires the PascalCaseIdentifier brand.
9238
+ pascalCaseToKebabCase("example");
9239
+ // @ts-expect-error Requires the PascalCaseIdentifier brand.
9240
+ pascalCaseToConstantCase("example");
9241
+ // @ts-expect-error Requires the SnakeCaseIdentifier brand.
9242
+ snakeCaseToCamelCase("example");
9243
+ // @ts-expect-error Requires the SnakeCaseIdentifier brand.
9244
+ snakeCaseToPascalCase("example");
9245
+ // @ts-expect-error Requires the SnakeCaseIdentifier brand.
9246
+ snakeCaseToKebabCase("example");
9247
+ // @ts-expect-error Requires the SnakeCaseIdentifier brand.
9248
+ snakeCaseToConstantCase("example");
9249
+ // @ts-expect-error Requires the KebabCaseIdentifier brand.
9250
+ kebabCaseToCamelCase("example");
9251
+ // @ts-expect-error Requires the KebabCaseIdentifier brand.
9252
+ kebabCaseToPascalCase("example");
9253
+ // @ts-expect-error Requires the KebabCaseIdentifier brand.
9254
+ kebabCaseToSnakeCase("example");
9255
+ // @ts-expect-error Requires the KebabCaseIdentifier brand.
9256
+ kebabCaseToConstantCase("example");
9257
+ // @ts-expect-error Requires the ConstantCaseIdentifier brand.
9258
+ constantCaseToCamelCase("example");
9259
+ // @ts-expect-error Requires the ConstantCaseIdentifier brand.
9260
+ constantCaseToPascalCase("example");
9261
+ // @ts-expect-error Requires the ConstantCaseIdentifier brand.
9262
+ constantCaseToSnakeCase("example");
9263
+ // @ts-expect-error Requires the ConstantCaseIdentifier brand.
9264
+ constantCaseToKebabCase("example");
9265
+ // @ts-expect-error ConstantCaseIdentifier does not prove the CamelCaseIdentifier brand.
9266
+ camelCaseToSnakeCase(constant);
9267
+ // @ts-expect-error Conversion does not retain the input's MaxLength3 brand.
9268
+ const _short: string & Brand<"MaxLength3"> = constant;
9269
+ // @ts-expect-error Uppercase text does not prove the ConstantCaseIdentifier grammar.
9270
+ const _constant: ConstantCaseIdentifier =
9271
+ "HTTP PORT" as Uppercase<string>;
9272
+ };
9273
+ assertType<typeof compileTimeAssertions, () => void>();
9274
+ });
9275
+ });
9276
+
9277
+ describe("trimmed", () => {
7617
9278
  it("is a reusable Brand Factory", () => {
7618
9279
  assertType<
7619
- typeof capitalized,
7620
- BrandFactory<"Capitalized", string, CapitalizedError>
9280
+ typeof trimmed,
9281
+ BrandFactory<"Trimmed", string, TrimmedError>
9282
+ >();
9283
+ });
9284
+
9285
+ describe("Type", () => {
9286
+ describe("TrimmedString", () => {
9287
+ it("accepts only strings without surrounding whitespace", () => {
9288
+ assertEqual(TrimmedString.from.parent("Evolu"), ok("Evolu"));
9289
+ assertEqual(
9290
+ TrimmedString.from.parent(" Evolu "),
9291
+ err({ type: "Trimmed", value: " Evolu " }),
9292
+ );
9293
+ assertEqual(
9294
+ TrimmedString.formatError({
9295
+ type: "Trimmed",
9296
+ value: " Evolu ",
9297
+ }),
9298
+ 'The value " Evolu " must be trimmed.',
9299
+ );
9300
+ assertType<
9301
+ typeof TrimmedString.Output,
9302
+ string & Brand<"Trimmed">
9303
+ >();
9304
+ });
9305
+ });
9306
+ });
9307
+ });
9308
+
9309
+ describe("trim", () => {
9310
+ it("returns a TrimmedString", () => {
9311
+ const value = trim(" Evolu ");
9312
+
9313
+ assertEqual(value, "Evolu");
9314
+ assertType<typeof value, TrimmedString>();
9315
+ });
9316
+ });
9317
+
9318
+ describe("startsWith", () => {
9319
+ it("preserves the full string and parent constraints", () => {
9320
+ const Parent = maxLength(12)(TrimmedString);
9321
+ const AppName = startsWith("APP_")(Parent);
9322
+ const input = Parent.orThrow("APP_PORT");
9323
+ const result = AppName.from.parent(input);
9324
+
9325
+ assertOk(result, input);
9326
+ assertSame(result.value, input);
9327
+ assertSame(AppName.to(result.value), input);
9328
+ assertSame(AppName.parent, Parent);
9329
+ assertEqual(AppName.name, "StartsWithAPP_");
9330
+ assertType<typeof AppName.name, "StartsWithAPP_">();
9331
+ assertType<typeof AppName.parent, typeof Parent>();
9332
+ assertType<
9333
+ typeof AppName.Output,
9334
+ typeof Parent.Output & Brand<"StartsWithAPP_">
9335
+ >();
9336
+ assertType<typeof AppName.Error, StartsWithError<"APP_">>();
9337
+ assertType<
9338
+ Parameters<typeof AppName.from.parent>[0],
9339
+ typeof Parent.Output
9340
+ >();
9341
+ assertErr(AppName.fromUnknown(" APP_PORT"), {
9342
+ type: "Trimmed",
9343
+ value: " APP_PORT",
9344
+ });
9345
+ assertErr(AppName.fromUnknown("APP_LONG_NAME"), {
9346
+ type: "MaxLength12",
9347
+ value: "APP_LONG_NAME",
9348
+ max: 12,
9349
+ });
9350
+ assertErr(AppName.fromUnknown(1), {
9351
+ type: "TypeOf",
9352
+ expected: "String",
9353
+ value: 1,
9354
+ });
9355
+ });
9356
+
9357
+ it("matches the exact prefix at the start without removing it", () => {
9358
+ const AppName = startsWith("APP_")(String);
9359
+ for (const value of ["APP_", "APP_PORT", "APP_APP_PORT", "APP_\n"]) {
9360
+ assertOk(AppName.fromUnknown(value), value);
9361
+ }
9362
+ for (const value of ["", "APP", "app_PORT", "XAPP_PORT"]) {
9363
+ assertErr(AppName.fromUnknown(value), {
9364
+ type: "StartsWithAPP_",
9365
+ value,
9366
+ prefix: "APP_",
9367
+ });
9368
+ }
9369
+ assertEqual(
9370
+ AppName.formatError({
9371
+ type: "StartsWithAPP_",
9372
+ value: "PORT",
9373
+ prefix: "APP_",
9374
+ }),
9375
+ 'The value "PORT" must start with "APP_".',
9376
+ );
9377
+ });
9378
+
9379
+ it("accepts an empty prefix and matches punctuation and Unicode literally", () => {
9380
+ const AnyString = startsWith("")(String);
9381
+ for (const value of ["", "PORT", "\n", "😀"]) {
9382
+ assertOk(AnyString.fromUnknown(value), value);
9383
+ }
9384
+ assertErr(startsWith("")(TrimmedString).fromUnknown(" "));
9385
+ assertOk(
9386
+ startsWith("[a].*")(String).fromUnknown("[a].*value"),
9387
+ "[a].*value",
9388
+ );
9389
+ assertErr(startsWith("[a].*")(String).fromUnknown("abc"));
9390
+ assertOk(startsWith("😀:")(String).fromUnknown("😀:value"), "😀:value");
9391
+ assertErr(startsWith("é")(String).fromUnknown("e\u0301"));
9392
+ });
9393
+
9394
+ it("validates transformed parent output and preserves its encoder", () => {
9395
+ const Parent = transform("LowercasePrefixInput", String, String, {
9396
+ from: (value) => ok(value.toLowerCase()),
9397
+ to: (value) => value.toUpperCase(),
9398
+ });
9399
+ const AppName = startsWith("app_")(Parent);
9400
+ const result = AppName.fromUnknown("APP_PORT");
9401
+
9402
+ assertOk(result, "app_port");
9403
+ assertEqual(AppName.to(result.value), "APP_PORT");
9404
+ assertErr(AppName.fromUnknown("OTHER_PORT"), {
9405
+ type: "StartsWithapp_",
9406
+ value: "other_port",
9407
+ prefix: "app_",
9408
+ });
9409
+ });
9410
+
9411
+ it("requires a concrete prefix and keeps different prefix brands distinct", () => {
9412
+ const AppName = startsWith("APP_")(String);
9413
+ const OtherName = startsWith("OTHER_")(String);
9414
+ const otherName = OtherName.orThrow("OTHER_PORT");
9415
+ const widened = globalThis.String("APP_");
9416
+ const unionPrefix = "APP_" as "APP_" | "OTHER_";
9417
+ const branded = TrimmedString.orThrow("APP_");
9418
+ const templatePrefix = "APP_" as `APP_${string}`;
9419
+ const compileTimeAssertions = () => {
9420
+ // @ts-expect-error Expected must be one concrete literal value.
9421
+ startsWith(widened);
9422
+ // @ts-expect-error Expected must be one concrete literal value.
9423
+ startsWith(unionPrefix);
9424
+ // @ts-expect-error Expected must be one concrete literal value.
9425
+ startsWith(branded);
9426
+ // @ts-expect-error Expected must be one concrete literal value.
9427
+ startsWith(templatePrefix);
9428
+ // @ts-expect-error The parent Type must output strings.
9429
+ startsWith("APP_")(Number);
9430
+ // @ts-expect-error Error type must not duplicate an error inherited from the parent Type.
9431
+ startsWith("APP_")(AppName);
9432
+ // @ts-expect-error OTHER_ values do not carry the StartsWithAPP_ brand.
9433
+ AppName.from(otherName);
9434
+ };
9435
+
9436
+ assertType<
9437
+ typeof compileTimeAssertions extends (
9438
+ ...args: Array<never>
9439
+ ) => unknown
9440
+ ? true
9441
+ : false,
9442
+ true
9443
+ >();
9444
+ });
9445
+ });
9446
+
9447
+ describe("prefixed", () => {
9448
+ it("preserves the suffix Type and applies its constraints after stripping", () => {
9449
+ const Suffix = maxLength(4)(ConstantCaseIdentifier);
9450
+ const Key = prefixed("app:")(Suffix);
9451
+ const result = Key.fromUnknown("app:PORT");
9452
+
9453
+ assertOk(result, "PORT");
9454
+ assertEqual(Key.to(result.value), "app:PORT");
9455
+ assertSame(Key.output, Suffix);
9456
+ assertEqual(Key.name, "Prefixedapp:");
9457
+ assertEqual(Key.parent.name, "StartsWithapp:");
9458
+ assertType<typeof Key.Output, typeof Suffix.Output>();
9459
+ assertType<typeof Key.output, typeof Suffix>();
9460
+ assertType<typeof Key.name, "Prefixedapp:">();
9461
+ assertType<
9462
+ typeof Key.Error,
9463
+ TransformOutputError<
9464
+ "Prefixedapp:",
9465
+ IdentifierError<"CONSTANT_CASE"> | MaxLengthError<4>
9466
+ >
9467
+ >();
9468
+ assertType<
9469
+ Parameters<typeof Key.from.parent>[0],
9470
+ string & Brand<"StartsWithapp:">
9471
+ >();
9472
+ assertType<Parameters<typeof Key.from.parent.parent>[0], string>();
9473
+ assertType<Parameters<typeof Key.from>[0], typeof Suffix.Output>();
9474
+ assertType<
9475
+ ReturnType<typeof Key.to>,
9476
+ string & Brand<"StartsWithapp:">
7621
9477
  >();
9478
+ assertOk(Key.from.parent(Key.parent.orThrow("app:PORT")), "PORT");
9479
+ assertOk(Key.from.parent.parent("app:PORT"), "PORT");
9480
+ assertOk(Key.from(result.value), result.value);
9481
+ assertTrue(Key.is("PORT"));
9482
+ assertFalse(Key.is("app:PORT"));
9483
+ assertErr(Key.fromUnknown("app:PORTS"), {
9484
+ type: "Prefixedapp:",
9485
+ outputError: { type: "MaxLength4", value: "PORTS", max: 4 },
9486
+ });
7622
9487
  });
7623
9488
 
7624
- describe("Type", () => {
7625
- describe("CapitalizedString", () => {
7626
- it("accepts only capitalized strings", () => {
7627
- assertEqual(CapitalizedString.from.parent("Evolu"), ok("Evolu"));
7628
- assertEqual(
7629
- CapitalizedString.from.parent("evolu"),
7630
- err({ type: "Capitalized", value: "evolu" }),
7631
- );
7632
- assertEqual(CapitalizedString.from.parent("𐐀x"), ok("𐐀x"));
7633
- assertEqual(
7634
- CapitalizedString.from.parent("𐐨x"),
7635
- err({ type: "Capitalized", value: "𐐨x" }),
7636
- );
7637
- assertEqual(
7638
- CapitalizedString.formatError({
7639
- type: "Capitalized",
7640
- value: "evolu",
7641
- }),
7642
- 'The value "evolu" must be capitalized.',
7643
- );
7644
- assertType<
7645
- typeof CapitalizedString.Output,
7646
- string & Brand<"Capitalized">
7647
- >();
7648
- });
9489
+ it("reports prefix and suffix errors through their existing formatters", () => {
9490
+ const Key = prefixed("APP_")(ConstantCaseIdentifier);
9491
+ for (const value of ["PORT", "app_PORT", "XAPP_PORT"]) {
9492
+ const result = Key.fromUnknown(value);
9493
+ assertErr(result, { type: "StartsWithAPP_", value, prefix: "APP_" });
9494
+ assertEqual(
9495
+ Key.formatError(result.error),
9496
+ `The value "${value}" must start with "APP_".`,
9497
+ );
9498
+ }
9499
+ assertErr(Key.fromUnknown(1), {
9500
+ type: "TypeOf",
9501
+ expected: "String",
9502
+ value: 1,
9503
+ });
9504
+ const invalid = Key.fromUnknown("APP_port");
9505
+ assertErr(invalid, {
9506
+ type: "PrefixedAPP_",
9507
+ outputError: {
9508
+ type: "ConstantCaseIdentifier",
9509
+ value: "port",
9510
+ casing: "CONSTANT_CASE",
9511
+ },
7649
9512
  });
9513
+ assertEqual(typeErrorToIssues(Key, invalid.error), [
9514
+ {
9515
+ path: [],
9516
+ message: 'The value "port" is not a CONSTANT_CASE identifier.',
9517
+ },
9518
+ ]);
9519
+ assertErr(Key.fromUnknown("APP_"));
7650
9520
  });
7651
- });
7652
9521
 
7653
- describe("trimmed", () => {
7654
- it("is a reusable Brand Factory", () => {
7655
- assertType<
7656
- typeof trimmed,
7657
- BrandFactory<"Trimmed", string, TrimmedError>
7658
- >();
7659
- });
9522
+ it("runs the wrapped decoder and canonical encoder for non-string Outputs", () => {
9523
+ const Setting = prefixed("port:")(PortFromString);
9524
+ const result = Setting.fromUnknown("port:04000");
7660
9525
 
7661
- describe("Type", () => {
7662
- describe("TrimmedString", () => {
7663
- it("accepts only strings without surrounding whitespace", () => {
7664
- assertEqual(TrimmedString.from.parent("Evolu"), ok("Evolu"));
7665
- assertEqual(
7666
- TrimmedString.from.parent(" Evolu "),
7667
- err({ type: "Trimmed", value: " Evolu " }),
7668
- );
7669
- assertEqual(
7670
- TrimmedString.formatError({
7671
- type: "Trimmed",
7672
- value: " Evolu ",
7673
- }),
7674
- 'The value " Evolu " must be trimmed.',
7675
- );
7676
- assertType<
7677
- typeof TrimmedString.Output,
7678
- string & Brand<"Trimmed">
7679
- >();
7680
- });
9526
+ assertOk(result, 4000);
9527
+ assertType<typeof result.value, Port>();
9528
+ assertEqual(Setting.to(result.value), "port:4000");
9529
+ assertOk(Setting.fromUnknown(Setting.to(result.value)), result.value);
9530
+ assertErr(Setting.fromUnknown("port:65536"), {
9531
+ type: "Prefixedport:",
9532
+ outputError: {
9533
+ type: "PortFromString",
9534
+ outputError: {
9535
+ type: "LessThanOrEqualTo65535",
9536
+ value: 65536,
9537
+ max: 65535,
9538
+ },
9539
+ },
7681
9540
  });
9541
+ assertTrue(Setting.is(4000));
9542
+ assertFalse(Setting.is("port:4000"));
7682
9543
  });
7683
- });
7684
9544
 
7685
- describe("trim", () => {
7686
- it("returns a TrimmedString", () => {
7687
- const value = trim(" Evolu ");
9545
+ it("removes one literal prefix, including empty and Unicode prefixes", () => {
9546
+ const Empty = prefixed("")(String);
9547
+ for (const value of ["", "PORT", "\n"]) {
9548
+ assertOk(Empty.fromUnknown(value), value);
9549
+ assertEqual(Empty.to(value), value);
9550
+ }
9551
+ const App = prefixed("APP_")(String);
9552
+ assertOk(App.fromUnknown("APP_"), "");
9553
+ assertOk(App.fromUnknown("APP_APP_PORT"), "APP_PORT");
9554
+ assertEqual(App.to("APP_PORT"), "APP_APP_PORT");
9555
+ const Unicode = prefixed("😀:")(String);
9556
+ assertOk(Unicode.fromUnknown("😀:PORT"), "PORT");
9557
+ assertEqual(Unicode.to("PORT"), "😀:PORT");
9558
+ const Punctuation = prefixed("[a].*")(String);
9559
+ assertOk(Punctuation.fromUnknown("[a].*PORT"), "PORT");
9560
+ assertEqual(Punctuation.to("PORT"), "[a].*PORT");
9561
+ assertErr(Punctuation.fromUnknown("abcPORT"));
9562
+ });
7688
9563
 
7689
- assertEqual(value, "Evolu");
7690
- assertType<typeof value, TrimmedString>();
9564
+ it("composes nested prefixes in both directions", () => {
9565
+ const Nested = prefixed("outer/")(prefixed("inner/")(PortFromString));
9566
+ const result = Nested.fromUnknown("outer/inner/04000");
9567
+ assertOk(result, 4000);
9568
+ assertEqual(Nested.to(result.value), "outer/inner/4000");
9569
+ assertErr(Nested.fromUnknown("outer/4000"));
9570
+
9571
+ const Repeated = prefixed("APP_")(prefixed("APP_")(String));
9572
+ assertOk(Repeated.fromUnknown("APP_APP_PORT"), "PORT");
9573
+ assertEqual(Repeated.to("PORT"), "APP_APP_PORT");
9574
+
9575
+ const Literal = prefixed("APP_")(literal("PORT"));
9576
+ assertOk(Literal.fromUnknown("APP_PORT"), "PORT");
9577
+ assertErr(Literal.fromUnknown("APP_OTHER"));
9578
+ assertEqual(Literal.to("PORT"), "APP_PORT");
9579
+ assertType<typeof Literal.Output, "PORT">();
9580
+ });
9581
+
9582
+ it("decodes and encodes record keys without filtering mismatched prefixes", () => {
9583
+ const Settings = record(
9584
+ prefixed("APP_")(ConstantCaseIdentifier),
9585
+ String,
9586
+ );
9587
+ const result = Settings.fromUnknown({ APP_PORT: "4000" });
9588
+ assertOk(result, { PORT: "4000" });
9589
+ assertEqual(Settings.to(result.value), { APP_PORT: "4000" });
9590
+ assertErr(Settings.fromUnknown({ PORT: "4000" }));
9591
+ });
9592
+
9593
+ it("rejects uncertain prefixes and Types without a string input boundary", () => {
9594
+ const widened = globalThis.String("APP_");
9595
+ const unionPrefix = "APP_" as "APP_" | "OTHER_";
9596
+ const branded = TrimmedString.orThrow("APP_");
9597
+ const templatePrefix = "APP_" as `APP_${string}`;
9598
+ const uncertain = String as
9599
+ typeof String | typeof ConstantCaseIdentifier;
9600
+ const Setting = prefixed("port:")(PortFromString);
9601
+ const LiteralRoot = createType(
9602
+ "LiteralRoot",
9603
+ (value): Result<"PORT", TypeError<"LiteralRoot">> =>
9604
+ value === "PORT" ? ok(value) : err({ type: "LiteralRoot" }),
9605
+ () => "Expected PORT.",
9606
+ );
9607
+ const compileTimeAssertions = () => {
9608
+ // @ts-expect-error Expected must be one concrete literal value.
9609
+ prefixed(widened);
9610
+ // @ts-expect-error Expected must be one concrete literal value.
9611
+ prefixed(unionPrefix);
9612
+ // @ts-expect-error Expected must be one concrete literal value.
9613
+ prefixed(branded);
9614
+ // @ts-expect-error Expected must be one concrete literal value.
9615
+ prefixed(templatePrefix);
9616
+ // @ts-expect-error The wrapped Type must have string Input and CanonicalInput.
9617
+ prefixed("APP_")(Number);
9618
+ // @ts-expect-error The wrapped Type must have string Input and CanonicalInput.
9619
+ prefixed("APP_")(object({ port: Port }));
9620
+ // @ts-expect-error Output Type must be one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
9621
+ prefixed("APP_")(uncertain);
9622
+ // @ts-expect-error Prefixed Type Input must accept every string.
9623
+ prefixed("APP_")(LiteralRoot);
9624
+ // @ts-expect-error The typed parent boundary requires the StartsWithport: brand.
9625
+ Setting.from.parent("port:4000");
9626
+ // @ts-expect-error Encoding requires a validated Port, not a plain number.
9627
+ Setting.to(4000);
9628
+ };
9629
+ assertType<
9630
+ typeof compileTimeAssertions extends (
9631
+ ...args: Array<never>
9632
+ ) => unknown
9633
+ ? true
9634
+ : false,
9635
+ true
9636
+ >();
7691
9637
  });
7692
9638
  });
7693
9639
 
@@ -8869,6 +10815,97 @@ describe("BrandFactory", () => {
8869
10815
  });
8870
10816
  });
8871
10817
 
10818
+ describe("Port", () => {
10819
+ it("accepts integer ports including zero and both range boundaries", () => {
10820
+ for (const value of [0, 1, 4000, 65535]) {
10821
+ assertOk(Port.fromUnknown(value), value);
10822
+ const port = Port.orThrow(value);
10823
+ assertType<typeof port, Port>();
10824
+ assertSame(Port.to(port), value);
10825
+ }
10826
+ assertSame(Port.orThrow(-0), -0);
10827
+ assertType<
10828
+ Port,
10829
+ NonNegativeInt & Brand<"LessThanOrEqualTo65535"> & Brand<"Port">
10830
+ >();
10831
+ // @ts-expect-error A number does not carry the Port brand.
10832
+ const _port: Port = 4000;
10833
+ });
10834
+
10835
+ it("rejects values outside the port domain", () => {
10836
+ assertErr(Port.fromUnknown(-1), { type: "NonNegative", value: -1 });
10837
+ assertErr(Port.fromUnknown(65536), {
10838
+ type: "LessThanOrEqualTo65535",
10839
+ value: 65536,
10840
+ max: 65535,
10841
+ });
10842
+ assertErr(Port.fromUnknown(4000.5), { type: "Int", value: 4000.5 });
10843
+ for (const value of ["4000", null, NaN, Infinity, -Infinity]) {
10844
+ assertErr(Port.fromUnknown(value));
10845
+ }
10846
+ });
10847
+ });
10848
+
10849
+ describe("PortFromString", () => {
10850
+ it("reuses decimal integer parsing and encodes validated ports", () => {
10851
+ for (const [input, output] of [
10852
+ ["0", 0],
10853
+ ["04000", 4000],
10854
+ ["65535", 65535],
10855
+ ] as const) {
10856
+ assertOk(PortFromString.fromUnknown(input), output);
10857
+ const port = PortFromString.orThrow(input);
10858
+ assertType<typeof port, Port>();
10859
+ assertEqual(PortFromString.to(port), globalThis.String(output));
10860
+ }
10861
+ assertOk(PortFromString.from.parent(Int.orThrow(4000)), 4000);
10862
+ assertEqual(PortFromString.to(Port.orThrow(4000)), "4000");
10863
+ assertSame(PortFromString.orThrow("-0"), -0);
10864
+ assertEqual(PortFromString.to(Port.orThrow(-0)), "-0");
10865
+ });
10866
+
10867
+ it("preserves parsing errors and formats port range errors", () => {
10868
+ for (const value of [
10869
+ "",
10870
+ "http",
10871
+ " 4000",
10872
+ "+4000",
10873
+ "4000.5",
10874
+ "4e3",
10875
+ "0x10",
10876
+ ]) {
10877
+ assertErr(PortFromString.fromUnknown(value), {
10878
+ type: "IntFromString",
10879
+ value,
10880
+ });
10881
+ }
10882
+ assertErr(PortFromString.fromUnknown(4000));
10883
+ assertErr(PortFromString.fromUnknown("9007199254740992"));
10884
+ const negative = PortFromString.fromUnknown("-1");
10885
+ assertErr(negative, {
10886
+ type: "PortFromString",
10887
+ outputError: { type: "NonNegative", value: -1 },
10888
+ });
10889
+ assertEqual(
10890
+ PortFromString.formatError(negative.error),
10891
+ "The value -1 must be non-negative (>= 0).",
10892
+ );
10893
+ const tooLarge = PortFromString.fromUnknown("65536");
10894
+ assertErr(tooLarge, {
10895
+ type: "PortFromString",
10896
+ outputError: {
10897
+ type: "LessThanOrEqualTo65535",
10898
+ value: 65536,
10899
+ max: 65535,
10900
+ },
10901
+ });
10902
+ assertEqual(
10903
+ PortFromString.formatError(tooLarge.error),
10904
+ "The value 65536 must be less than or equal to 65535.",
10905
+ );
10906
+ });
10907
+ });
10908
+
8872
10909
  describe("Ratio", () => {
8873
10910
  it("accepts finite numbers from zero to one", () => {
8874
10911
  assertEqual(Ratio.from.parent.parent.parent.parent.parent(0), ok(0));
@@ -11053,7 +13090,7 @@ describe("array", () => {
11053
13090
  const value: ReadonlyArray<number> = [1, 2];
11054
13091
 
11055
13092
  assertSame(UserIds.orThrow(value), value);
11056
- assertAssertionError(() => UserIds.orThrow([1, -2, 3]), "getOrThrow", {
13093
+ assertAssertionError(() => UserIds.orThrow([1, -2, 3]), "PositiveInt", {
11057
13094
  type: "Array",
11058
13095
  reason: {
11059
13096
  kind: "Items",
@@ -11072,7 +13109,7 @@ describe("array", () => {
11072
13109
  const { UserIds } = setupUserIds();
11073
13110
  assertAssertionError(
11074
13111
  () => UserIds.orThrow([0, -1], { errors: "all" }),
11075
- "getOrThrow",
13112
+ "PositiveInt",
11076
13113
  {
11077
13114
  type: "Array",
11078
13115
  reason: {
@@ -13731,26 +15768,541 @@ describe("record", () => {
13731
15768
  kind: "Entries",
13732
15769
  issues: [{ kind: "NonEnumerable", key: "allowed" }],
13733
15770
  },
13734
- }),
13735
- 'A Record property "allowed" must be enumerable.',
15771
+ }),
15772
+ 'A Record property "allowed" must be enumerable.',
15773
+ );
15774
+ assertEqual(
15775
+ Transformed.formatError({
15776
+ type: "Record",
15777
+ reason: {
15778
+ kind: "Entries",
15779
+ issues: [
15780
+ {
15781
+ kind: "Collision",
15782
+ key: "a",
15783
+ previousKey: "A",
15784
+ outputKey: "a",
15785
+ },
15786
+ ],
15787
+ },
15788
+ }),
15789
+ 'Record keys "A" and "a" decode to the same key "a".',
15790
+ );
15791
+ });
15792
+ });
15793
+
15794
+ describe("withDefault", () => {
15795
+ it("accepts nullish defaults when they belong to the wrapped Output", () => {
15796
+ const Nullable = withDefault(nullOr(Boolean), null);
15797
+ assertType<typeof Nullable.Output, boolean | null>();
15798
+ assertOk(Nullable.fromUnknown(null), null);
15799
+ assertEqual(Nullable.to(null), null);
15800
+ assertOk(Nullable.fromUnknown(Nullable.to(false)), false);
15801
+
15802
+ const UndefinedDefault = withDefault(undefinedOr(Boolean), undefined, {
15803
+ strategy: "preserve",
15804
+ });
15805
+ assertType<
15806
+ typeof UndefinedDefault.Output,
15807
+ Defaulted<boolean, undefined, "undefined">
15808
+ >();
15809
+ const decoded = UndefinedDefault.fromUnknown(undefined);
15810
+ assertOk(decoded, {
15811
+ value: undefined,
15812
+ defaultUsed: true,
15813
+ original: "undefined",
15814
+ });
15815
+ assertSame(UndefinedDefault.to(decoded.value), undefined);
15816
+ assertOk(
15817
+ UndefinedDefault.fromUnknown(UndefinedDefault.to(decoded.value)),
15818
+ decoded.value,
15819
+ );
15820
+
15821
+ const Model = object({
15822
+ value: withDefault(optional(nullOr(Boolean)), null, {
15823
+ strategy: "preserve",
15824
+ }),
15825
+ });
15826
+ const missing = Model.fromUnknown({});
15827
+ assertOk(missing, {
15828
+ value: { value: null, defaultUsed: true, original: "missing" },
15829
+ });
15830
+ assertEqual(Model.to(missing.value), {});
15831
+ assertOk(Model.fromUnknown({ value: null }), {
15832
+ value: { value: null, defaultUsed: true, original: "null" },
15833
+ });
15834
+ });
15835
+
15836
+ it("reuses configured object defaults by reference", () => {
15837
+ const defaultValue = { enabled: true };
15838
+ const Value = nullOr(object({ enabled: Boolean }));
15839
+ const Replaced = withDefault(Value, defaultValue);
15840
+ const Preserved = withDefault(Value, defaultValue, {
15841
+ strategy: "preserve",
15842
+ });
15843
+
15844
+ assertSame(Replaced.orThrow(null), defaultValue);
15845
+ assertSame(Replaced.orThrow(null), defaultValue);
15846
+ assertSame(Preserved.orThrow(null).value, defaultValue);
15847
+ assertSame(Preserved.orThrow(null).value, defaultValue);
15848
+ });
15849
+
15850
+ describe("replace", () => {
15851
+ it("accepts object defaults directly, including objects with a value property", () => {
15852
+ const Value = withDefault(nullOr(object({ value: Boolean })), {
15853
+ value: true,
15854
+ });
15855
+
15856
+ assertType<typeof Value.Output.value, boolean>();
15857
+ assertOk(Value.fromUnknown(null), { value: true });
15858
+ assertOk(Value.fromUnknown({ value: false }), { value: false });
15859
+ assertEqual(Value.to({ value: true }), { value: true });
15860
+ });
15861
+
15862
+ it("defaults only decoded absence and emits supplied canonical data", () => {
15863
+ const Value = withDefault(nullishOr(BooleanFromString), true);
15864
+
15865
+ assertType<typeof Value.Output, boolean>();
15866
+ assertType<typeof Value.Input, string | null | undefined>();
15867
+ for (const input of [null, undefined, "true"]) {
15868
+ assertOk(Value.fromUnknown(input), true);
15869
+ }
15870
+ assertOk(Value.fromUnknown("false"), false);
15871
+ assertErr(Value.fromUnknown("wrong"));
15872
+ assertEqual(Value.to(true), "true");
15873
+ assertEqual(Value.to(false), "false");
15874
+ assertOk(Value.fromUnknown(Value.to(false)), false);
15875
+ assertOk(Value.from.parent(null), true);
15876
+ assertEqual(Value.to.parent(true), true);
15877
+ assertFalse(Value.is(null));
15878
+ assertThrowsInstanceOf(() => Value.to(null as never), Error);
15879
+ });
15880
+
15881
+ it("makes optional input required in the output without accepting undefined", () => {
15882
+ const Model = object({
15883
+ enabled: withDefault(optional(Boolean), true),
15884
+ });
15885
+
15886
+ assertType<typeof Model.Input.enabled, boolean | undefined>();
15887
+ assertType<typeof Model.Output.enabled, boolean>();
15888
+ assertOk(Model.fromUnknown({}), { enabled: true });
15889
+ assertOk(Model.from.parent({}), { enabled: true });
15890
+ assertOk(Model.parent.fromUnknown({}), {});
15891
+ assertErr(Model.fromUnknown({ enabled: undefined }));
15892
+ assertErr(Model.fromUnknown({ enabled: null }));
15893
+ assertFalse(Model.is({}));
15894
+ assertTrue(Model.is({ enabled: false }));
15895
+ assertEqual(Model.to({ enabled: true }), { enabled: true });
15896
+ assertOk(Model.fromUnknown(Model.to({ enabled: false })), {
15897
+ enabled: false,
15898
+ });
15899
+ });
15900
+ });
15901
+
15902
+ describe("preserve", () => {
15903
+ it("distinguishes all absence states and explicit values equal to the default", () => {
15904
+ const Model = object({
15905
+ enabled: withDefault(optional(nullishOr(BooleanFromString)), true, {
15906
+ strategy: "preserve",
15907
+ }),
15908
+ });
15909
+ assertType<typeof Model.Output.enabled, Defaulted<boolean, true>>();
15910
+ const cases = [
15911
+ [{}, { value: true, defaultUsed: true, original: "missing" }],
15912
+ [
15913
+ { enabled: null },
15914
+ { value: true, defaultUsed: true, original: "null" },
15915
+ ],
15916
+ [
15917
+ { enabled: undefined },
15918
+ { value: true, defaultUsed: true, original: "undefined" },
15919
+ ],
15920
+ [{ enabled: "true" }, { value: true, defaultUsed: false }],
15921
+ [{ enabled: "false" }, { value: false, defaultUsed: false }],
15922
+ ] as const;
15923
+
15924
+ for (const [input, enabled] of cases) {
15925
+ const result = Model.fromUnknown(input);
15926
+ assertOk(result, { enabled });
15927
+ assertTrue(Model.is(result.value));
15928
+ const encoded = Model.to(result.value);
15929
+ assertEqual(encoded, input);
15930
+ assertOk(Model.fromUnknown(encoded), result.value);
15931
+ assertEqual(
15932
+ globalThis.Object.hasOwn(encoded, "enabled"),
15933
+ globalThis.Object.hasOwn(input, "enabled"),
15934
+ );
15935
+ }
15936
+ assertOk(Model.from.parent({ enabled: "false" }), {
15937
+ enabled: { value: false, defaultUsed: false },
15938
+ });
15939
+ });
15940
+
15941
+ it("narrows original states and rejects impossible output shapes", () => {
15942
+ const Value = withDefault(nullOr(Boolean), true, {
15943
+ strategy: "preserve",
15944
+ });
15945
+ assertType<typeof Value.Output, Defaulted<boolean, true, "null">>();
15946
+ assertOk(Value.fromUnknown(null), {
15947
+ value: true,
15948
+ defaultUsed: true,
15949
+ original: "null",
15950
+ });
15951
+ assertEqual(
15952
+ Value.to({ value: true, defaultUsed: true, original: "null" }),
15953
+ null,
15954
+ );
15955
+ assertFalse(
15956
+ Value.is({ value: false, defaultUsed: true, original: "null" }),
15957
+ );
15958
+ assertFalse(
15959
+ Value.is({ value: true, defaultUsed: true, original: "undefined" }),
15960
+ );
15961
+ assertFalse(
15962
+ Value.is({ value: true, defaultUsed: true, original: "missing" }),
15963
+ );
15964
+ assertFalse(Value.is({ value: null, defaultUsed: false }));
15965
+ assertFalse(
15966
+ Value.is({ value: true, defaultUsed: false, original: "null" }),
15967
+ );
15968
+ const error = assertThrowsInstanceOf(
15969
+ () =>
15970
+ Value.to({
15971
+ value: false,
15972
+ defaultUsed: true,
15973
+ original: "null",
15974
+ } as never),
15975
+ Error,
15976
+ );
15977
+ assertTrue(error.message.length > 0);
15978
+ assertEqual(
15979
+ Value.formatError(error.cause as never),
15980
+ "A preserved default must equal the configured default value.",
15981
+ );
15982
+ for (const [output, message] of [
15983
+ [
15984
+ { value: null, defaultUsed: false },
15985
+ "The value must be a non-nullish Output of its Type or, in replacement mode, the configured default.",
15986
+ ],
15987
+ [
15988
+ { value: true, defaultUsed: true, original: "missing" },
15989
+ "The original absence must be handled by this default declaration.",
15990
+ ],
15991
+ ] as const) {
15992
+ const invalid = assertThrowsInstanceOf(
15993
+ () => Value.to(output as never),
15994
+ Error,
15995
+ );
15996
+ assertEqual(Value.formatError(invalid.cause as never), message);
15997
+ }
15998
+ });
15999
+
16000
+ it("compares structured defaults by value", () => {
16001
+ const Value = withDefault(
16002
+ nullOr(object({ enabled: Boolean })),
16003
+ { enabled: true },
16004
+ { strategy: "preserve" },
16005
+ );
16006
+ const output = {
16007
+ value: { enabled: true },
16008
+ defaultUsed: true,
16009
+ original: "null",
16010
+ } as const;
16011
+ assertTrue(Value.is(output));
16012
+ assertEqual(Value.to(output), null);
16013
+ assertOk(Value.fromUnknown(null), output);
16014
+ assertFalse(Value.is({ ...output, value: { enabled: false } }));
16015
+ });
16016
+
16017
+ it("also validates preserved default values against the wrapped Type", () => {
16018
+ const selected = { enabled: true };
16019
+ const Selected = createType(
16020
+ "Selected",
16021
+ (value): Result<typeof selected, TypeError<"Selected">> =>
16022
+ value === selected ? ok(selected) : err({ type: "Selected" }),
16023
+ () => "Expected the selected object.",
16024
+ );
16025
+ const Value = withDefault(nullOr(Selected), selected, {
16026
+ strategy: "preserve",
16027
+ });
16028
+ assertTrue(
16029
+ Value.is({ value: selected, defaultUsed: true, original: "null" }),
16030
+ );
16031
+ assertFalse(
16032
+ Value.is({
16033
+ value: { ...selected },
16034
+ defaultUsed: true,
16035
+ original: "null",
16036
+ }),
16037
+ );
16038
+ });
16039
+
16040
+ it("uses decoded absence rather than raw input spelling", () => {
16041
+ const Input = transform("AbsentString", String, nullOr(Boolean), {
16042
+ from: (value) => ok(value === "absent" ? null : value === "true"),
16043
+ to: (value) => (value === null ? "absent" : value ? "true" : "false"),
16044
+ });
16045
+ const Value = withDefault(Input, false, { strategy: "preserve" });
16046
+ const decoded = Value.fromUnknown("absent");
16047
+ assertOk(decoded, { value: false, defaultUsed: true, original: "null" });
16048
+ assertEqual(Value.to(decoded.value), "absent");
16049
+ assertOk(Value.fromUnknown("other"), {
16050
+ value: false,
16051
+ defaultUsed: false,
16052
+ });
16053
+ });
16054
+ });
16055
+
16056
+ describe("composition", () => {
16057
+ it("round-trips through arrays and JSON", () => {
16058
+ const Values = array(
16059
+ withDefault(undefinedOr(BooleanFromString), false, {
16060
+ strategy: "preserve",
16061
+ }),
16062
+ );
16063
+ const values = Values.fromUnknown([undefined, "true"]);
16064
+ assertOk(values, [
16065
+ { value: false, defaultUsed: true, original: "undefined" },
16066
+ { value: true, defaultUsed: false },
16067
+ ]);
16068
+ assertEqual(Values.to(values.value), [undefined, "true"]);
16069
+ assertOk(Values.from.parent([undefined, true]), values.value);
16070
+
16071
+ const Model = object({
16072
+ enabled: withDefault(optional(Boolean), true, { strategy: "preserve" }),
16073
+ });
16074
+ const [ModelJson, modelToJson, jsonToModel] = json(
16075
+ Model,
16076
+ "DefaultSettingsJson",
16077
+ );
16078
+ const missing = jsonToModel(ModelJson.orThrow("{}"));
16079
+ assertEqual(missing, {
16080
+ enabled: { value: true, defaultUsed: true, original: "missing" },
16081
+ });
16082
+ assertEqual(modelToJson(missing), "{}");
16083
+ assertEqual(jsonToModel(modelToJson(missing)), missing);
16084
+ assertFalse(
16085
+ Model.is({
16086
+ enabled: { value: false, defaultUsed: true, original: "missing" },
16087
+ }),
16088
+ );
16089
+ });
16090
+
16091
+ it("composes nested objects, ordinary optional fields, and rest records", () => {
16092
+ const Model = object({
16093
+ inner: object({
16094
+ enabled: withDefault(optional(BooleanFromString), true, {
16095
+ strategy: "preserve",
16096
+ }),
16097
+ count: Int64FromInt64String,
16098
+ note: optional(String),
16099
+ }),
16100
+ });
16101
+ const decoded = Model.fromUnknown({ inner: { count: "42" } });
16102
+ assertOk(decoded, {
16103
+ inner: {
16104
+ enabled: { value: true, defaultUsed: true, original: "missing" },
16105
+ count: 42n,
16106
+ },
16107
+ });
16108
+ assertEqual(Model.to(decoded.value), { inner: { count: "42" } });
16109
+
16110
+ const Open = object(
16111
+ {
16112
+ enabled: withDefault(optional(Boolean), true),
16113
+ },
16114
+ record(String, Boolean),
16115
+ );
16116
+ assertOk(Open.fromUnknown({ other: false }), {
16117
+ enabled: true,
16118
+ other: false,
16119
+ });
16120
+ assertEqual(Open.to({ enabled: true, other: false }), {
16121
+ enabled: true,
16122
+ other: false,
16123
+ });
16124
+ assertErr(Open.fromUnknown({ other: "false" }));
16125
+ });
16126
+
16127
+ it("partial disables missing defaults for non-nullish Types in both strategies", () => {
16128
+ const Replaced = partial({
16129
+ enabled: withDefault(optional(Boolean), true),
16130
+ });
16131
+ const Preserved = partial({
16132
+ enabled: withDefault(optional(Boolean), true, { strategy: "preserve" }),
16133
+ });
16134
+
16135
+ assertType<typeof Replaced.Output.enabled, boolean | undefined>();
16136
+ assertType<
16137
+ typeof Preserved.Output.enabled,
16138
+ Defaulted<boolean, true, never> | undefined
16139
+ >();
16140
+ assertOk(Replaced.fromUnknown({}), {});
16141
+ assertOk(Preserved.fromUnknown({}), {});
16142
+ assertOk(Replaced.fromUnknown({ enabled: false }), { enabled: false });
16143
+ const supplied = Preserved.orThrow({ enabled: false });
16144
+ assertEqual(supplied, { enabled: { value: false, defaultUsed: false } });
16145
+ assertEqual(Preserved.to(supplied), { enabled: false });
16146
+ assertEqual(Replaced.to({}), {});
16147
+ assertEqual(Preserved.to({}), {});
16148
+ assertErr(Replaced.fromUnknown({ enabled: undefined }));
16149
+ assertErr(Preserved.fromUnknown({ enabled: null }));
16150
+ });
16151
+
16152
+ it("partial makes missing outputs optional and retains defaults for present null", () => {
16153
+ const Model = partial({
16154
+ enabled: withDefault(optional(nullOr(Boolean)), true, {
16155
+ strategy: "preserve",
16156
+ }),
16157
+ });
16158
+ assertType<
16159
+ typeof Model.Output.enabled,
16160
+ Defaulted<boolean, true, "null"> | undefined
16161
+ >();
16162
+ assertOk(Model.fromUnknown({}), {});
16163
+ const decoded = Model.fromUnknown({ enabled: null });
16164
+ assertOk(decoded, {
16165
+ enabled: { value: true, defaultUsed: true, original: "null" },
16166
+ });
16167
+ assertEqual(Model.to(decoded.value), { enabled: null });
16168
+ });
16169
+
16170
+ it("partial retains replacement defaults for present null", () => {
16171
+ const Model = partial({
16172
+ enabled: withDefault(optional(nullOr(Boolean)), true),
16173
+ });
16174
+
16175
+ assertType<typeof Model.Output.enabled, boolean | undefined>();
16176
+ assertOk(Model.fromUnknown({}), {});
16177
+ assertOk(Model.fromUnknown({ enabled: null }), { enabled: true });
16178
+ assertOk(Model.fromUnknown({ enabled: false }), { enabled: false });
16179
+ assertEqual(Model.to({ enabled: true }), { enabled: true });
16180
+ assertEqual(Model.to({}), {});
16181
+ });
16182
+
16183
+ it("retains error paths, all errors, and never reads accessors", () => {
16184
+ const Model = object({
16185
+ enabled: withDefault(optional(BooleanFromString), true, {
16186
+ strategy: "preserve",
16187
+ }),
16188
+ count: Int64FromInt64String,
16189
+ });
16190
+ const failed = Model.fromUnknown(
16191
+ { enabled: "bad", count: "bad" },
16192
+ { errors: "all" },
16193
+ );
16194
+ assertErr(failed);
16195
+ const issues = typeErrorToIssues(Model, failed.error);
16196
+ assertEqual(
16197
+ issues.map((issue) => issue.path),
16198
+ [["enabled"], ["count"]],
16199
+ );
16200
+ assertTrue(Model.formatError(failed.error).length > 0);
16201
+ const invalidUnion = withDefault(nullOr(BooleanFromString), true, {
16202
+ strategy: "preserve",
16203
+ });
16204
+ const result = invalidUnion.fromUnknown("bad");
16205
+ assertErr(result);
16206
+ assertTrue(invalidUnion.formatError(result.error).length > 0);
16207
+
16208
+ let reads = 0;
16209
+ const input = {
16210
+ get enabled() {
16211
+ reads++;
16212
+ return "true";
16213
+ },
16214
+ count: "42",
16215
+ };
16216
+ assertErr(Model.fromUnknown(input));
16217
+ assertEqual(reads, 0);
16218
+ assertErr(Model.fromUnknown({ extra: 1, count: "42" }));
16219
+ assertErr(Model.fromUnknown(null));
16220
+ assertErr(Model.fromUnknown([]));
16221
+ assertThrowsInstanceOf(
16222
+ () =>
16223
+ Model.to({
16224
+ enabled: { value: true, defaultUsed: false },
16225
+ count: 0 as never,
16226
+ }),
16227
+ Error,
16228
+ );
16229
+ });
16230
+ });
16231
+
16232
+ it("rejects invalid declarations and explicit replacement strategies", () => {
16233
+ assertThrowsInstanceOf(
16234
+ () => withDefault(optional(Boolean), null as never),
16235
+ Error,
13736
16236
  );
13737
- assertEqual(
13738
- Transformed.formatError({
13739
- type: "Record",
13740
- reason: {
13741
- kind: "Entries",
13742
- issues: [
13743
- {
13744
- kind: "Collision",
13745
- key: "a",
13746
- previousKey: "A",
13747
- outputKey: "a",
13748
- },
13749
- ],
13750
- },
13751
- }),
13752
- 'Record keys "A" and "a" decode to the same key "a".',
16237
+ assertThrowsInstanceOf(
16238
+ () => withDefault(nullOr(Boolean), "true" as never),
16239
+ Error,
13753
16240
  );
16241
+ for (const strategy of ["replace", "other"]) {
16242
+ for (const property of [Boolean, optional(Boolean)]) {
16243
+ const error = assertThrowsInstanceOf(
16244
+ () =>
16245
+ withDefault(property as never, true, {
16246
+ strategy: strategy as never,
16247
+ }),
16248
+ Error,
16249
+ );
16250
+ assertEqual(
16251
+ error.message,
16252
+ 'withDefault strategy must be omitted or "preserve".',
16253
+ );
16254
+ }
16255
+ }
16256
+ if (false as boolean) {
16257
+ // @ts-expect-error withDefault requires an optional property or a Type whose Output includes null or undefined.
16258
+ withDefault(Boolean, true);
16259
+ // @ts-expect-error withDefault requires an optional property or a Type whose Output includes null or undefined.
16260
+ withDefault(Boolean, true, { strategy: "preserve" });
16261
+ // @ts-expect-error A Boolean default must be a decoded boolean.
16262
+ withDefault(nullOr(BooleanFromString), "true");
16263
+ // @ts-expect-error A Boolean default cannot be null.
16264
+ withDefault(optional(Boolean), null);
16265
+ // @ts-expect-error The default value is required.
16266
+ withDefault(optional(Boolean));
16267
+ // @ts-expect-error A Boolean default must be passed directly, not wrapped in options.
16268
+ withDefault(optional(Boolean), { value: true });
16269
+ // @ts-expect-error The third argument must explicitly request preservation.
16270
+ withDefault(optional(Boolean), true, {});
16271
+ // @ts-expect-error Explicit replacement strategies are not accepted.
16272
+ withDefault(optional(Boolean), true, { strategy: "replace" });
16273
+ // @ts-expect-error Explicit replacement strategies are not accepted.
16274
+ withDefault(nullOr(Boolean), true, { strategy: "replace" });
16275
+ const replacement = { strategy: "replace" } as const;
16276
+ // @ts-expect-error Explicit replacement strategies are not accepted, including in variables.
16277
+ withDefault(optional(Boolean), true, replacement);
16278
+ // @ts-expect-error Explicit replacement strategies are not accepted, including in variables.
16279
+ withDefault(nullOr(Boolean), true, replacement);
16280
+ const uncertain = optional(Boolean) as OptionalProperty<
16281
+ typeof Boolean | typeof String
16282
+ >;
16283
+ // @ts-expect-error Property must use one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
16284
+ withDefault(uncertain, true);
16285
+ const erased = optional(Boolean) as OptionalProperty<TypeNode>;
16286
+ // @ts-expect-error Property must use one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
16287
+ withDefault(erased, true);
16288
+ const Conflicting = createType(
16289
+ "WithDefault",
16290
+ (_value): Result<boolean, TypeError<"WithDefault">> =>
16291
+ err({ type: "WithDefault" }),
16292
+ () => "Invalid flag.",
16293
+ );
16294
+ // @ts-expect-error Error type must not duplicate an error inherited from the parent Type.
16295
+ withDefault(Conflicting, true);
16296
+ // @ts-expect-error Error type must not duplicate an error inherited from the parent Type.
16297
+ withDefault(optional(Conflicting), true, { strategy: "preserve" });
16298
+ // @ts-expect-error Output properties with defaults are required.
16299
+ const invalid: typeof objectWithDefault.Output = {};
16300
+ void invalid;
16301
+ }
16302
+ const objectWithDefault = object({
16303
+ value: withDefault(optional(Boolean), true),
16304
+ });
16305
+ assertOk(objectWithDefault.fromUnknown({}), { value: true });
13754
16306
  });
13755
16307
  });
13756
16308
 
@@ -16223,60 +18775,126 @@ describe("object", () => {
16223
18775
  >();
16224
18776
  });
16225
18777
 
16226
- it("omit removes declared properties and preserves an Object Record", () => {
16227
- const User = object({ name: String, age: Number });
16228
- const WithoutAge = omit(User, "age");
16229
- const WithoutNameAndAge = omit(User, "name", "age");
16230
- const keys = ["name", "age"] as const;
16231
- const Empty = omit(User, ...keys);
18778
+ describe("omit", () => {
18779
+ it("removes declared properties and preserves an Object Record", () => {
18780
+ const User = object({ name: String, age: Number });
18781
+ const WithoutAge = omit(User, "age");
18782
+ const WithoutNameAndAge = omit(User, "name", "age");
18783
+ const keys = ["name", "age"] as const;
18784
+ const Empty = omit(User, ...keys);
16232
18785
 
16233
- assertEqual(WithoutAge.props, { name: String });
16234
- assertOk(WithoutAge.fromUnknown({ name: "Ada" }), { name: "Ada" });
16235
- assertFalse(WithoutAge.fromUnknown({ name: "Ada", age: 1 }).ok);
16236
- assertType<
16237
- typeof WithoutAge,
16238
- ObjectType<{ readonly name: typeof String }>
16239
- >();
16240
- assertEqual(WithoutNameAndAge.props, {});
16241
- assertOk(WithoutNameAndAge.fromUnknown({}), {});
16242
- assertType<typeof WithoutNameAndAge, ObjectType<{}>>();
16243
- assertEqual(Empty.props, {});
16244
- assertType<typeof Empty, ObjectType<{}>>();
16245
-
16246
- const compileTimeAssertions = (
16247
- key: "name" | "age",
16248
- keys: ReadonlyArray<"name" | "age">,
16249
- ) => {
16250
- // @ts-expect-error A runtime key must identify one statically known property.
16251
- omit(User, key);
16252
- // @ts-expect-error A runtime array does not guarantee which properties are omitted.
16253
- omit(User, ...keys);
16254
- };
18786
+ assertEqual(WithoutAge.props, { name: String });
18787
+ assertOk(WithoutAge.fromUnknown({ name: "Ada" }), { name: "Ada" });
18788
+ assertFalse(WithoutAge.fromUnknown({ name: "Ada", age: 1 }).ok);
18789
+ assertType<
18790
+ typeof WithoutAge,
18791
+ ObjectType<{ readonly name: typeof String }>
18792
+ >();
18793
+ assertEqual(WithoutNameAndAge.props, {});
18794
+ assertOk(WithoutNameAndAge.fromUnknown({}), {});
18795
+ assertType<typeof WithoutNameAndAge, ObjectType<{}>>();
18796
+ assertEqual(Empty.props, {});
18797
+ assertType<typeof Empty, ObjectType<{}>>();
18798
+
18799
+ const compileTimeAssertions = (
18800
+ key: "name" | "age",
18801
+ keys: ReadonlyArray<"name" | "age">,
18802
+ ) => {
18803
+ // @ts-expect-error A runtime key must identify one statically known property.
18804
+ omit(User, key);
18805
+ // @ts-expect-error A runtime array does not guarantee which properties are omitted.
18806
+ omit(User, ...keys);
18807
+ };
16255
18808
 
16256
- const Metadata = object(
16257
- { name: String, age: Number },
16258
- record(String, Unknown),
16259
- );
16260
- const MetadataWithoutAge = omit(Metadata, "age");
18809
+ const Metadata = object(
18810
+ { name: String, age: Number },
18811
+ record(String, Unknown),
18812
+ );
18813
+ const MetadataWithoutAge = omit(Metadata, "age");
16261
18814
 
16262
- assertSame(MetadataWithoutAge.record, Metadata.record);
16263
- assertType<
16264
- typeof MetadataWithoutAge,
16265
- ObjectType<
16266
- { readonly name: typeof String },
16267
- RecordType<typeof String, typeof Unknown>
16268
- >
16269
- >();
16270
- assertOk(
16271
- MetadataWithoutAge.fromUnknown({ name: "Ada", age: "unknown" }),
16272
- { name: "Ada", age: "unknown" },
16273
- );
16274
- assertType<
16275
- typeof compileTimeAssertions extends (...args: Array<never>) => unknown
16276
- ? true
16277
- : false,
16278
- true
16279
- >();
18815
+ assertSame(MetadataWithoutAge.record, Metadata.record);
18816
+ assertType<
18817
+ typeof MetadataWithoutAge,
18818
+ ObjectType<
18819
+ { readonly name: typeof String },
18820
+ RecordType<typeof String, typeof Unknown>
18821
+ >
18822
+ >();
18823
+ assertOk(
18824
+ MetadataWithoutAge.fromUnknown({ name: "Ada", age: "unknown" }),
18825
+ { name: "Ada", age: "unknown" },
18826
+ );
18827
+ assertType<
18828
+ typeof compileTimeAssertions extends (
18829
+ ...args: Array<never>
18830
+ ) => unknown
18831
+ ? true
18832
+ : false,
18833
+ true
18834
+ >();
18835
+ });
18836
+
18837
+ it("retains defaulted fields and can remove every default", () => {
18838
+ const Model = object({
18839
+ enabled: withDefault(optional(Boolean), true),
18840
+ tracked: withDefault(optional(Boolean), false, {
18841
+ strategy: "preserve",
18842
+ }),
18843
+ name: String,
18844
+ });
18845
+ const Defaults = omit(Model, "name");
18846
+ assertType<typeof Defaults.Output.enabled, boolean>();
18847
+ assertType<
18848
+ typeof Defaults.Output.tracked,
18849
+ Defaulted<boolean, false, "missing">
18850
+ >();
18851
+ const decoded = Defaults.orThrow({});
18852
+ assertEqual(decoded, {
18853
+ enabled: true,
18854
+ tracked: { value: false, defaultUsed: true, original: "missing" },
18855
+ });
18856
+ assertEqual(Defaults.to(decoded), { enabled: true });
18857
+ const Named = omit(Model, "enabled", "tracked");
18858
+ assertType<
18859
+ typeof Named,
18860
+ ObjectType<{ readonly name: typeof String }>
18861
+ >();
18862
+ assertOk(Named.fromUnknown({ name: "Ada" }), { name: "Ada" });
18863
+ assertErr(Named.fromUnknown({ name: "Ada", enabled: true }));
18864
+ const Empty = omit(Model, "enabled", "tracked", "name");
18865
+ assertType<typeof Empty, ObjectType<{}>>();
18866
+ assertOk(Empty.fromUnknown({}), {});
18867
+ });
18868
+
18869
+ it("preserves rest record inference when retaining or removing a default", () => {
18870
+ const Model = object(
18871
+ {
18872
+ enabled: withDefault(optional(Boolean), true),
18873
+ name: String,
18874
+ },
18875
+ record(String, Unknown),
18876
+ );
18877
+ const Defaults = omit(Model, "name");
18878
+ assertSame(Defaults.record, Model.record);
18879
+ assertType<typeof Defaults.Output.enabled, boolean>();
18880
+ assertOk(Defaults.fromUnknown({ other: "value" }), {
18881
+ enabled: true,
18882
+ other: "value",
18883
+ });
18884
+ const Named = omit(Model, "enabled");
18885
+ assertType<
18886
+ typeof Named,
18887
+ ObjectType<
18888
+ { readonly name: typeof String },
18889
+ RecordType<typeof String, typeof Unknown>
18890
+ >
18891
+ >();
18892
+ assertSame(Named.record, Model.record);
18893
+ assertOk(Named.fromUnknown({ name: "Ada", enabled: "rest" }), {
18894
+ name: "Ada",
18895
+ enabled: "rest",
18896
+ });
18897
+ });
16280
18898
  });
16281
18899
  });
16282
18900
  });
@@ -19753,6 +22371,39 @@ describe("json", () => {
19753
22371
  }
19754
22372
  });
19755
22373
 
22374
+ it("formats plain and localized orThrow failures without parsing twice", () => {
22375
+ const [ValueJson] = json(String, "ValueJson");
22376
+ const LocalizedValueJson = localizeTypes(
22377
+ { ValueJson },
22378
+ {
22379
+ test: {
22380
+ Json: () => "Localized Json.",
22381
+ String: () => "Localized String.",
22382
+ },
22383
+ },
22384
+ ).test.ValueJson;
22385
+
22386
+ for (const type of [ValueJson, LocalizedValueJson]) {
22387
+ for (const input of ["1", "{"]) {
22388
+ const result = type.fromUnknown(input);
22389
+ assertErr(result);
22390
+ const message = type.formatError(result.error);
22391
+ const parse = mock.method(JSON, "parse");
22392
+
22393
+ try {
22394
+ assertAssertionError(
22395
+ () => type.orThrow(input),
22396
+ message,
22397
+ result.error,
22398
+ );
22399
+ assertEqual(parse.mock.callCount(), 1);
22400
+ } finally {
22401
+ parse.mock.restore();
22402
+ }
22403
+ }
22404
+ }
22405
+ });
22406
+
19756
22407
  it("accepts only Types with a JSON-compatible canonical Input", () => {
19757
22408
  const compileTimeAssertions = () => {
19758
22409
  interface StringTree {
@@ -20463,3 +23114,309 @@ describe("design decisions", () => {
20463
23114
  });
20464
23115
  });
20465
23116
  });
23117
+
23118
+ describe("BooleanFromString", () => {
23119
+ it("parses exactly true and false", () => {
23120
+ assertOk(BooleanFromString.fromUnknown("true"), true);
23121
+ assertOk(BooleanFromString.fromUnknown("false"), false);
23122
+
23123
+ assertEqual(BooleanFromString.to(true), "true");
23124
+ assertEqual(BooleanFromString.to(false), "false");
23125
+ });
23126
+
23127
+ it("rejects other strings and non-strings", () => {
23128
+ for (const value of [
23129
+ "TRUE",
23130
+ "False",
23131
+ "1",
23132
+ "0",
23133
+ "yes",
23134
+ "no",
23135
+ "on",
23136
+ "off",
23137
+ " true",
23138
+ "",
23139
+ ]) {
23140
+ const invalid = BooleanFromString.fromUnknown(value);
23141
+ assertErr(invalid, { type: "BooleanFromString", value });
23142
+ assertEqual(
23143
+ BooleanFromString.formatError(invalid.error),
23144
+ `The value ${JSON.stringify(value)} is not a boolean. Use true or false.`,
23145
+ );
23146
+ }
23147
+
23148
+ assertErr(BooleanFromString.fromUnknown(true), {
23149
+ type: "TypeOf",
23150
+ expected: "String",
23151
+ value: true,
23152
+ });
23153
+ });
23154
+ });
23155
+
23156
+ describe("IntFromString", () => {
23157
+ it("parses decimal integers", () => {
23158
+ assertOk(IntFromString.fromUnknown("4000"), 4000);
23159
+ assertOk(IntFromString.fromUnknown("-1"), -1);
23160
+ assertOk(IntFromString.fromUnknown("007"), 7);
23161
+ assertOk(
23162
+ IntFromString.fromUnknown("9007199254740991"),
23163
+ globalThis.Number.MAX_SAFE_INTEGER,
23164
+ );
23165
+ assertOk(
23166
+ IntFromString.fromUnknown("-9007199254740991"),
23167
+ globalThis.Number.MIN_SAFE_INTEGER,
23168
+ );
23169
+ assertEqual(IntFromString.to(IntFromString.orThrow("42")), "42");
23170
+
23171
+ const negativeZero = IntFromString.orThrow("-0");
23172
+ assertSame(negativeZero, -0);
23173
+ assertEqual(IntFromString.to(negativeZero), "-0");
23174
+
23175
+ {
23176
+ const value = IntFromString.orThrow("1");
23177
+ assertType<typeof value, Int>();
23178
+ }
23179
+ });
23180
+
23181
+ it("rejects non-integer text", () => {
23182
+ for (const value of ["", " 1", "1 ", "+1", "1.5", "1e3", "0x10", "NaN"]) {
23183
+ const invalid = IntFromString.fromUnknown(value);
23184
+ assertErr(invalid, { type: "IntFromString", value });
23185
+ assertEqual(
23186
+ IntFromString.formatError(invalid.error),
23187
+ `The value ${JSON.stringify(value)} is not a decimal integer.`,
23188
+ );
23189
+ }
23190
+ });
23191
+
23192
+ it("validates parsed numbers with Int", () => {
23193
+ for (const value of [
23194
+ "9007199254740992",
23195
+ "9007199254740993",
23196
+ "-9007199254740992",
23197
+ "-9007199254740993",
23198
+ ]) {
23199
+ const number = globalThis.Number(value);
23200
+ const invalid = IntFromString.fromUnknown(value);
23201
+ assertErr(invalid, {
23202
+ type: "IntFromString",
23203
+ outputError: { type: "Int", value: number },
23204
+ });
23205
+ assertEqual(
23206
+ IntFromString.formatError(invalid.error),
23207
+ `The value ${number} must be a safe integer.`,
23208
+ );
23209
+ }
23210
+
23211
+ const value = "9".repeat(309);
23212
+ const invalid = IntFromString.fromUnknown(value);
23213
+ assertErr(invalid, {
23214
+ type: "IntFromString",
23215
+ outputError: {
23216
+ type: "Finite",
23217
+ value: globalThis.Number.POSITIVE_INFINITY,
23218
+ },
23219
+ });
23220
+ assertEqual(
23221
+ IntFromString.formatError(invalid.error),
23222
+ "The value Infinity must be finite.",
23223
+ );
23224
+ });
23225
+ });
23226
+
23227
+ describe("objectKeys", () => {
23228
+ it("includes missing and excess properties in the error type", () => {
23229
+ const Model = objectKeys(String)(object({ port: Number }));
23230
+ const result = Model.fromUnknown({ extra: 0 }, { errors: "all" });
23231
+ assertErr(result);
23232
+ assertTrue("error" in result.error);
23233
+ const error = result.error.error;
23234
+ assertTrue(error.reason.kind === "Properties");
23235
+ const errors = error.reason.errors;
23236
+
23237
+ assertType<
23238
+ (typeof errors)[string],
23239
+ | TypeOfError<"Number">
23240
+ | ObjectPropertyAccessError
23241
+ | ObjectMissingPropertyError
23242
+ | ObjectExcessPropertyError
23243
+ | undefined
23244
+ >();
23245
+ assertEqual(errors.port, { type: "ObjectMissingProperty" });
23246
+ assertEqual(errors.extra, { type: "ObjectExcessProperty" });
23247
+ });
23248
+
23249
+ it("uses canonical key encodings and retains field codecs and optionality", () => {
23250
+ const Key = prefixed("APP_")(CamelCaseIdentifierFromConstantCaseIdentifier);
23251
+ const Output = object({
23252
+ http2Port: PortFromString,
23253
+ displayName: optional(String),
23254
+ });
23255
+ const Env = objectKeys(Key)(Output);
23256
+ const result = Env.fromUnknown({ APP_HTTP2_PORT: "04000" });
23257
+ assertOk(result, { http2Port: 4000 });
23258
+ assertType<typeof result.value, typeof Output.Output>();
23259
+ assertType(Env, result.value);
23260
+ assertSame(Env.key, Key);
23261
+ assertSame(Env.output, Output);
23262
+ assertEqual(Env.to(result.value), { APP_HTTP2_PORT: "4000" });
23263
+ assertOk(Env.from.parent({ APP_HTTP2_PORT: "4000" }), result.value);
23264
+ assertOk(Env.from(result.value), result.value);
23265
+ assertTrue(Env.is(result.value));
23266
+ assertFalse(Env.is({ http2Port: "4000" }));
23267
+ assertEqual(Env.orNull({ APP_HTTP2_PORT: "invalid" }), null);
23268
+ assertOk(
23269
+ Env.fromUnknown({ APP_HTTP2_PORT: "4000", APP_DISPLAY_NAME: "" }),
23270
+ { http2Port: 4000, displayName: "" },
23271
+ );
23272
+ for (const value of [
23273
+ null,
23274
+ [],
23275
+ { http2Port: "4000" },
23276
+ { APP_HTTP2_PORT: "4000", HOME: "" },
23277
+ { APP_HTTP2_PORT: "4000", APP_DISPLAY_NAME: undefined },
23278
+ ]) {
23279
+ assertErr(Env.fromUnknown(value));
23280
+ }
23281
+ nodeAssert.throws(() => Env.to({ http2Port: 65536 as Port }));
23282
+ const outputError: TransformOutputError<
23283
+ "ObjectKeys",
23284
+ InferErrors<typeof Output>
23285
+ > = {
23286
+ type: "ObjectKeys",
23287
+ outputError: {
23288
+ type: "Object",
23289
+ reason: { kind: "NotObject", value: null },
23290
+ },
23291
+ };
23292
+ assertEqual(typeErrorToIssues(Env, outputError)[0]?.path, []);
23293
+ assertEqual(objectKeys(Key)(object({})).to({}), {});
23294
+ });
23295
+
23296
+ it("preserves external paths, nested paths, descriptors, and unknown-key errors", () => {
23297
+ const [Payload] = json(object({ items: array(Int) }), "ObjectKeysPayload");
23298
+ const Key = prefixed("APP_")(CamelCaseIdentifierFromConstantCaseIdentifier);
23299
+ const Env = objectKeys(Key)(
23300
+ object({ payload: Payload, port: PortFromString }),
23301
+ );
23302
+ const result = Env.fromUnknown(
23303
+ { APP_PAYLOAD: '{"items":["bad"]}', APP_EXTRA: "value" },
23304
+ { errors: "all" },
23305
+ );
23306
+ assertErr(result);
23307
+ assertEqual(
23308
+ typeErrorToIssues(Env, result.error).map(({ path }) => path),
23309
+ [["APP_PAYLOAD", "items", 0], ["APP_PORT"], ["APP_EXTRA"]],
23310
+ );
23311
+ let reads = 0;
23312
+ const accessor = globalThis.Object.defineProperty(
23313
+ { APP_PORT: "4000" },
23314
+ "APP_PAYLOAD",
23315
+ {
23316
+ enumerable: true,
23317
+ get: () => {
23318
+ reads++;
23319
+ return "{}";
23320
+ },
23321
+ },
23322
+ );
23323
+ assertErr(Env.fromUnknown(accessor));
23324
+ assertEqual(reads, 0);
23325
+ assertErr(
23326
+ Env.fromUnknown(
23327
+ globalThis.Object.defineProperty({ APP_PORT: "4000" }, "APP_PAYLOAD", {
23328
+ value: "{}",
23329
+ }),
23330
+ ),
23331
+ );
23332
+ const SpecialKey = transform("SpecialObjectKey", String, String, {
23333
+ from: (value) => ok(value === "__proto__" ? "proto" : value),
23334
+ to: (value) => (value === "proto" ? "__proto__" : value),
23335
+ });
23336
+ const Special = objectKeys(SpecialKey)(
23337
+ object({ proto: String, constructor: optional(String) }),
23338
+ );
23339
+ const special = Special.orThrow({ ["__proto__"]: "safe" });
23340
+ assertEqual(special, { proto: "safe" });
23341
+ assertEqual(Special.to(special), { ["__proto__"]: "safe" });
23342
+ const source = globalThis.Object.assign(globalThis.Object.create(null), {
23343
+ ["__proto__"]: "safe",
23344
+ });
23345
+ assertOk(Special.fromUnknown(source), special);
23346
+ });
23347
+
23348
+ it("rejects invalid keys, duplicate encodings, and broken key codecs at construction", () => {
23349
+ nodeAssert.throws(() =>
23350
+ objectKeys(CamelCaseIdentifierFromConstantCaseIdentifier)(
23351
+ object({ BadKey: String }),
23352
+ ),
23353
+ );
23354
+ const Broken = transform("BrokenObjectKey", String, String, {
23355
+ from: (value) => ok(value),
23356
+ to: () => "first",
23357
+ });
23358
+ nodeAssert.throws(
23359
+ () => objectKeys(Broken)(object({ first: String, second: String })),
23360
+ /Duplicate encoded object key/u,
23361
+ );
23362
+ nodeAssert.throws(
23363
+ () => objectKeys(Broken)(object({ second: String })),
23364
+ /decode its encoding/u,
23365
+ );
23366
+ const Rejecting = transform(
23367
+ "RejectingObjectKey",
23368
+ String,
23369
+ String,
23370
+ {
23371
+ from: () => err({ type: "RejectingObjectKey" as const }),
23372
+ to: (value) => value,
23373
+ },
23374
+ () => "Rejected key.",
23375
+ );
23376
+ nodeAssert.throws(
23377
+ () => objectKeys(Rejecting)(object({ key: String })),
23378
+ /decode its encoding/u,
23379
+ );
23380
+ });
23381
+
23382
+ it("requires string key codecs and a concrete strict object", () => {
23383
+ const reject = () => {
23384
+ // @ts-expect-error Key Output must be string.
23385
+ objectKeys(PortFromString);
23386
+ // @ts-expect-error objectKeys requires a strict Object Type without a record rest.
23387
+ objectKeys(String)(object({}, record(String, String)));
23388
+ // @ts-expect-error objectKeys requires an Object Type.
23389
+ objectKeys(String)(String);
23390
+ const objectType = (true as boolean)
23391
+ ? object({ name: String })
23392
+ : object({ age: Number });
23393
+ // @ts-expect-error Output Type must be one concrete Type node. Pass a Union Type node instead of a union of Type nodes.
23394
+ objectKeys(String)(objectType);
23395
+ };
23396
+ assertType<typeof reject, () => void>();
23397
+ });
23398
+ });
23399
+
23400
+ describe("CamelCaseIdentifierFromConstantCaseIdentifier", () => {
23401
+ it("preserves identifier word boundaries in both directions", () => {
23402
+ for (const [encoded, decoded] of [
23403
+ ["PORT", "port"],
23404
+ ["HTTP2_PORT", "http2Port"],
23405
+ ["H_T_T_P", "hTTP"],
23406
+ ]) {
23407
+ const result =
23408
+ CamelCaseIdentifierFromConstantCaseIdentifier.fromUnknown(encoded);
23409
+ assertOk(result, decoded);
23410
+ assertEqual(
23411
+ CamelCaseIdentifierFromConstantCaseIdentifier.to(result.value),
23412
+ encoded,
23413
+ );
23414
+ }
23415
+ assertErr(
23416
+ CamelCaseIdentifierFromConstantCaseIdentifier.fromUnknown("HTTP__PORT"),
23417
+ );
23418
+ assertErr(
23419
+ CamelCaseIdentifierFromConstantCaseIdentifier.fromUnknown("http_PORT"),
23420
+ );
23421
+ });
23422
+ });