@maroonedog/luq 2.2.0 → 2.3.1

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 (144) hide show
  1. package/README.md +47 -602
  2. package/dist/builder/compile-declarations.d.ts +7 -1
  3. package/dist/builder/compile-declarations.js +18 -8
  4. package/dist/builder/compile-declarations.mjs +18 -8
  5. package/dist/builder/create-builder.js +2 -3
  6. package/dist/builder/create-builder.mjs +2 -3
  7. package/dist/builder/create-field-builder.js +13 -1
  8. package/dist/builder/create-field-builder.mjs +13 -1
  9. package/dist/builder/declared-calls-store.d.ts +9 -0
  10. package/dist/builder/declared-calls-store.js +19 -0
  11. package/dist/builder/declared-calls-store.mjs +15 -0
  12. package/dist/builder/field-declared-calls.types.d.ts +6 -0
  13. package/dist/builder/field-declared-calls.types.js +2 -0
  14. package/dist/builder/field-declared-calls.types.mjs +1 -0
  15. package/dist/builder/field-entry.types.d.ts +9 -3
  16. package/dist/builder/field-options.types.d.ts +26 -0
  17. package/dist/chain/bundle-paths.types.d.ts +9 -12
  18. package/dist/chain/chain-node-store.d.ts +5 -0
  19. package/dist/chain/chain-node-store.js +15 -0
  20. package/dist/chain/chain-node-store.mjs +11 -0
  21. package/dist/chain/collect-field-rules.d.ts +13 -2
  22. package/dist/chain/collect-field-rules.js +10 -3
  23. package/dist/chain/collect-field-rules.mjs +10 -3
  24. package/dist/chain/create-chain-node.d.ts +10 -0
  25. package/dist/chain/create-chain-node.js +19 -9
  26. package/dist/chain/create-chain-node.mjs +19 -9
  27. package/dist/chain/create-field-slots.js +9 -1
  28. package/dist/chain/create-field-slots.mjs +9 -1
  29. package/dist/chain/declaration-recorder.port.d.ts +31 -0
  30. package/dist/chain/declaration-recorder.port.js +17 -0
  31. package/dist/chain/declaration-recorder.port.mjs +13 -0
  32. package/dist/chain/declared-call.types.d.ts +15 -0
  33. package/dist/chain/declared-call.types.js +2 -0
  34. package/dist/chain/declared-call.types.mjs +1 -0
  35. package/dist/chain/index.d.ts +3 -1
  36. package/dist/chain/slot-type-guard.d.ts +10 -0
  37. package/dist/chain/slot-type-guard.js +37 -0
  38. package/dist/chain/slot-type-guard.mjs +34 -0
  39. package/dist/compile/compile-array-node.js +1 -0
  40. package/dist/compile/compile-array-node.mjs +1 -0
  41. package/dist/compile/compile-field.d.ts +1 -0
  42. package/dist/compile/compile-field.js +8 -6
  43. package/dist/compile/compile-field.mjs +8 -6
  44. package/dist/compile/compile-schema.js +4 -0
  45. package/dist/compile/compile-schema.mjs +4 -0
  46. package/dist/compile/group-array-fields.d.ts +1 -0
  47. package/dist/compile/split-rules-by-kind.js +10 -14
  48. package/dist/compile/split-rules-by-kind.mjs +10 -14
  49. package/dist/compile/validation-plan.types.d.ts +19 -6
  50. package/dist/core/type-erasure.d.ts +36 -30
  51. package/dist/core/type-erasure.js +36 -30
  52. package/dist/core/type-erasure.mjs +36 -30
  53. package/dist/json-schema/build-from-schema.js +8 -1
  54. package/dist/json-schema/build-from-schema.mjs +8 -1
  55. package/dist/json-schema/create-structural-context.js +3 -0
  56. package/dist/json-schema/create-structural-context.mjs +3 -0
  57. package/dist/json-schema/declare-additional-properties.d.ts +7 -7
  58. package/dist/json-schema/declare-additional-properties.js +7 -7
  59. package/dist/json-schema/declare-additional-properties.mjs +7 -7
  60. package/dist/json-schema/declare-object-keywords.js +4 -4
  61. package/dist/json-schema/declare-object-keywords.mjs +4 -4
  62. package/dist/json-schema/follow-json-pointer.d.ts +7 -6
  63. package/dist/json-schema/follow-json-pointer.js +24 -24
  64. package/dist/json-schema/follow-json-pointer.mjs +24 -24
  65. package/dist/json-schema/ref-resolution-error.js +3 -3
  66. package/dist/json-schema/ref-resolution-error.mjs +3 -3
  67. package/dist/json-schema/schema-registry.js +12 -11
  68. package/dist/json-schema/schema-registry.mjs +12 -11
  69. package/dist/json-schema/uri-reference.js +12 -12
  70. package/dist/json-schema/uri-reference.mjs +12 -12
  71. package/dist/path/create-value-writer.js +12 -12
  72. package/dist/path/create-value-writer.mjs +12 -12
  73. package/dist/path/reserved-segment.d.ts +16 -16
  74. package/dist/path/reserved-segment.js +17 -21
  75. package/dist/path/reserved-segment.mjs +17 -21
  76. package/dist/plugins/index.generated.js +2 -2
  77. package/dist/plugins/index.generated.mjs +2 -2
  78. package/dist/plugins/manifest.generated.js +2 -2
  79. package/dist/plugins/manifest.generated.mjs +2 -2
  80. package/dist/plugins/object/object.d.ts +6 -0
  81. package/dist/plugins/object/object.js +9 -11
  82. package/dist/plugins/object/object.mjs +10 -12
  83. package/dist/plugins/object-additional-properties/select-additional-keys.d.ts +8 -8
  84. package/dist/plugins/object-additional-properties/select-additional-keys.js +16 -16
  85. package/dist/plugins/object-additional-properties/select-additional-keys.mjs +16 -16
  86. package/dist/plugins/stitch/stitch.d.ts +10 -14
  87. package/dist/plugins/stitch-with/stitch-with.d.ts +1 -1
  88. package/dist/plugins/stitch-with/stitch-with.js +22 -24
  89. package/dist/plugins/stitch-with/stitch-with.mjs +22 -24
  90. package/dist/plugins/string-min/string-min.js +6 -8
  91. package/dist/plugins/string-min/string-min.mjs +6 -8
  92. package/dist/presets/presets.d.ts +10 -11
  93. package/dist/presets/presets.js +22 -23
  94. package/dist/presets/presets.mjs +22 -23
  95. package/dist/runtime/create-field-validator.js +4 -6
  96. package/dist/runtime/create-field-validator.mjs +4 -6
  97. package/dist/runtime/create-validator.js +11 -11
  98. package/dist/runtime/create-validator.mjs +11 -11
  99. package/dist/runtime/output-writer.js +5 -1
  100. package/dist/runtime/output-writer.mjs +5 -1
  101. package/dist/runtime/run-array-node.js +6 -6
  102. package/dist/runtime/run-array-node.mjs +6 -6
  103. package/dist/runtime/run-field.js +18 -19
  104. package/dist/runtime/run-field.mjs +18 -19
  105. package/dist/standard-schema/assemble-json-schema.d.ts +4 -0
  106. package/dist/standard-schema/assemble-json-schema.js +95 -0
  107. package/dist/standard-schema/assemble-json-schema.mjs +92 -0
  108. package/dist/standard-schema/declaration-recorder.d.ts +6 -0
  109. package/dist/standard-schema/declaration-recorder.js +30 -0
  110. package/dist/standard-schema/declaration-recorder.mjs +27 -0
  111. package/dist/standard-schema/declarations-unavailable-error.d.ts +4 -0
  112. package/dist/standard-schema/declarations-unavailable-error.js +32 -0
  113. package/dist/standard-schema/declarations-unavailable-error.mjs +28 -0
  114. package/dist/standard-schema/emit-field-schema.d.ts +9 -0
  115. package/dist/standard-schema/emit-field-schema.js +68 -0
  116. package/dist/standard-schema/emit-field-schema.mjs +65 -0
  117. package/dist/standard-schema/index.d.ts +5 -0
  118. package/dist/standard-schema/index.js +9 -1
  119. package/dist/standard-schema/index.mjs +4 -0
  120. package/dist/standard-schema/json-schema-target.d.ts +6 -0
  121. package/dist/standard-schema/json-schema-target.js +44 -0
  122. package/dist/standard-schema/json-schema-target.mjs +39 -0
  123. package/dist/standard-schema/plugin-keyword-map.d.ts +3 -0
  124. package/dist/standard-schema/plugin-keyword-map.js +93 -0
  125. package/dist/standard-schema/plugin-keyword-map.mjs +90 -0
  126. package/dist/standard-schema/split-issue-path.d.ts +6 -4
  127. package/dist/standard-schema/split-issue-path.js +15 -13
  128. package/dist/standard-schema/split-issue-path.mjs +15 -13
  129. package/dist/standard-schema/standard-schema.types.d.ts +8 -7
  130. package/dist/standard-schema/standard-schema.types.js +6 -6
  131. package/dist/standard-schema/standard-schema.types.mjs +6 -6
  132. package/dist/standard-schema/to-standard-json-schema.d.ts +19 -0
  133. package/dist/standard-schema/to-standard-json-schema.js +36 -0
  134. package/dist/standard-schema/to-standard-json-schema.mjs +33 -0
  135. package/dist/standard-schema/to-standard-schema.d.ts +16 -15
  136. package/dist/standard-schema/to-standard-schema.js +15 -22
  137. package/dist/standard-schema/to-standard-schema.mjs +15 -22
  138. package/dist/standard-schema/unrepresentable-rule-error.d.ts +15 -0
  139. package/dist/standard-schema/unrepresentable-rule-error.js +43 -0
  140. package/dist/standard-schema/unrepresentable-rule-error.mjs +38 -0
  141. package/dist/types/index.d.ts +12 -12
  142. package/dist/types/index.js +7 -7
  143. package/dist/types/index.mjs +7 -7
  144. package/package.json +1 -1
package/README.md CHANGED
@@ -8,8 +8,12 @@
8
8
 
9
9
  **A TypeScript validation library where the wrong rule does not compile.**
10
10
 
11
+ [Documentation](https://luq.dev) · [Getting started](https://luq.dev/docs/getting-started) · [Plugins](https://luq.dev/plugins) · [JSON Schema](https://luq.dev/json-schema)
12
+
11
13
  </div>
12
14
 
15
+ ## Why this exists
16
+
13
17
  Your types were probably not written by you. `openapi-typescript` generates
14
18
  them from a spec you do not own. Prisma and Drizzle generate them from the
15
19
  schema of record. protobuf and GraphQL codegen generate them for services in
@@ -17,20 +21,19 @@ four languages at once. Increasingly, a model generates the code that uses them.
17
21
 
18
22
  A validator whose schema is the source of truth assumes you are the one who
19
23
  decides the shape. When that assumption holds, it is the better arrangement and
20
- this README will say so again below. When it does not, you end up maintaining a
21
- second description of a shape you did not choose. You can have the compiler check
22
- the copy against the original — zod's `satisfies z.ZodType<Order>` does exactly
23
- that — but you still author it, update it, and remember to write the check.
24
+ this page says so again below. When it does not, you end up maintaining a second
25
+ description of a shape you did not choose. You can have the compiler check the
26
+ copy against the original — zod's `satisfies z.ZodType<Order>` does exactly that
27
+ — but you still author it, update it, and remember to write the check.
24
28
 
25
29
  Luq runs the other way. It takes the type you already have and lets you declare
26
- rules against its field paths. What makes those declarations worth writing is
27
- that the compiler checks them against the type: a rule that does not apply to
28
- the field it is written on is a compile error, not a rule that quietly never
29
- fires.
30
+ rules against its field paths. **Your type definitions do not change**: not
31
+ re-authored as a schema, not replaced by an inferred one, not moved. `.for<Order>()`
32
+ takes the `Order` you already have, exactly as it is.
30
33
 
31
- That matters most when the code calling this library is generated rather than
32
- typed by hand. A generator that picks the wrong rule, misspells a path or drops
33
- an array wildcard gets a red squiggle, not a validator that passes everything.
34
+ What makes those declarations worth writing is that the compiler checks them
35
+ against the type: a rule that does not apply to the field it is written on is a
36
+ compile error, not a rule that quietly never fires.
34
37
 
35
38
  | Mistake | Result |
36
39
  |---|---|
@@ -41,194 +44,20 @@ an array wildcard gets a red squiggle, not a validator that passes everything.
41
44
  | A JSON Schema keyword bound to a chain method that does not exist | compile error |
42
45
  | A documented example drifting from the API | fails CI |
43
46
 
44
- Every rule you can call is a plugin you imported by name, so the bundle contains
45
- what you used and nothing else.
46
-
47
- Every number on this page was measured on this repository, and the file it came
48
- from is named next to it. Where a measurement is worse than 1.x — and some are —
49
- it is written down as worse.
50
-
51
- ## Install
52
-
53
- ```bash
54
- npm install @maroonedog/luq
55
- ```
56
-
57
- ## Quick start
58
-
59
- ```ts
60
- import { Builder } from "@maroonedog/luq";
61
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
62
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
63
- import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
64
- import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
65
-
66
- type User = {
67
- name: string;
68
- age: number;
69
- email: string;
70
- };
71
-
72
- const userValidator = Builder()
73
- .use(requiredPlugin)
74
- .use(stringMinPlugin)
75
- .use(stringEmailPlugin)
76
- .use(numberMinPlugin)
77
- .for<User>()
78
- .v("name", (b) => b.string.required().min(3))
79
- .v("age", (b) => b.number.required().min(18))
80
- .v("email", (b) => b.string.required().email())
81
- .build();
82
-
83
- const result = userValidator.validate({
84
- name: "Jo",
85
- age: 25,
86
- email: "jo@example.com",
87
- });
88
-
89
- if (result.valid) {
90
- // `data` exists only on this branch — no cast, no non-null assertion.
91
- console.error(result.data.name);
92
- } else {
93
- for (const issue of result.issues) {
94
- console.error(`${issue.path}: ${issue.message} (${issue.code})`);
95
- }
96
- }
97
- ```
98
-
99
- `build()` returns an **object**, not a function. It has four members:
100
-
101
- | Member | What it gives back |
102
- |---|---|
103
- | `validate(value, options?)` | `ValidationResult<T>` holding the **original** value |
104
- | `parse(value, options?)` | `ValidationResult<TParsed>` holding the value **after transforms** |
105
- | `pick(path)` | a single-field validator for one declared path |
106
- | `pickAll(paths)` | a validator returning exactly those paths, keyed by the path string |
107
-
108
- A `ValidationResult<T>` is a discriminated union on `valid`: the success branch
109
- carries `data`, both branches carry `issues`, and each issue is
110
- `{ path, code, message, severity }`.
111
-
112
- > Every code block on this page is extracted and typechecked against the built
113
- > package by `npm run check:docs`. It exists because documentation drifts from
114
- > the API it documents unless something compiles it — 1.x's quick start had drifted
115
- > in three places at once (a `build()` result called as a function, a `result.issues`
116
- > member that did not exist, and a subpath missing from the exports map), and each
117
- > was the readable kind of mistake that nobody reads.
118
-
119
- ## It patches onto the types you already have
120
-
121
- This is the practical consequence of being type-first, and it is the main reason
122
- to reach for Luq: **your type definitions do not change.** Not re-authored as a
123
- schema, not replaced by an inferred one, not moved. `.for<Order>()` takes the
124
- `Order` you already have, exactly as it is, and every rule is declared against
125
- it.
126
-
127
- So Luq asks you to describe the **rules**, not the shape — the shape is already
128
- written down. Adopting a schema-first validator on an existing codebase means
129
- producing a second description of the same shape for every type you cover, and
130
- then keeping the two in agreement; that is per-type work whether you author the
131
- schema alongside the type or switch the type to be inferred from it. Luq skips
132
- that step because it never needs the second description.
133
-
134
- Which is what makes adoption a patch rather than a migration:
135
-
136
- **Declare only the fields you care about.** A path you did not declare is not
137
- validated, not required, and not read. There is no "unknown key" behaviour to
138
- opt out of, so a partly-covered type is a normal state and not a half-finished
139
- one.
140
-
141
- ```ts
142
- import { Builder } from "@maroonedog/luq";
143
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
144
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
145
-
146
- type Order = {
147
- id: string;
148
- customerNote: string;
149
- legacyBlob: unknown;
150
- };
151
-
152
- // One field of three.
153
- const orderValidator = Builder()
154
- .use(requiredPlugin)
155
- .use(stringMinPlugin)
156
- .for<Order>()
157
- .v("id", (b) => b.string.required().min(3))
158
- .build();
159
-
160
- // `customerNote` and `legacyBlob` are never read, so anything goes there —
161
- // including being absent.
162
- console.error(orderValidator.validate({ id: "abc" } as Order).valid); // true
163
- console.error(orderValidator.validate({ id: "ab" } as Order).valid); // false
164
- ```
165
-
166
- **Validate one field at a time.** `pick(path)` gives back a validator for a
167
- single declared path, which is what a form needs on blur. It takes the field's
168
- own value, and optionally its siblings for cross-field rules.
169
-
170
- ```ts
171
- import { Builder } from "@maroonedog/luq";
172
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
173
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
174
-
175
- type Order = { id: string; customerNote: string };
176
-
177
- const id = Builder()
178
- .use(requiredPlugin)
179
- .use(stringMinPlugin)
180
- .for<Order>()
181
- .v("id", (b) => b.string.required().min(3))
182
- .build()
183
- .pick("id");
184
-
185
- console.error(id.validate("ab").valid); // false
186
- console.error(id.validate("abc").valid); // true
187
- ```
188
-
189
- `pickAll(["a", "b"])` does the same for a named subset and hands back exactly
190
- those paths, keyed by the strings you asked for.
191
-
192
- **Keep what you already have.** Luq implements Standard Schema v1, so a Luq
193
- validator and a zod schema are interchangeable at any boundary that accepts one.
194
- Adding Luq to one route does not commit the next one, and does not remove zod
195
- from the routes it is already in.
196
-
197
- None of this needs a migration step, because there is nothing global to migrate:
198
- no registry, no plugin installation, no shared configuration object. A validator
199
- is a value in a module, declared against a type that was there before it.
200
-
201
- **And when you want the opposite, ask for it: `.strict()`.** Partial coverage is
202
- the default because that is what makes a patch possible, but a builder that
203
- declares `.strict()` will not compile until every leaf path of `T` is declared —
204
- and the error names the ones you missed:
205
-
206
- ```ts
207
- import { Builder } from "@maroonedog/luq";
208
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
209
-
210
- type Order = { id: string; customerNote: string; nested: { deep: number } };
211
-
212
- export const incomplete = Builder()
213
- .use(requiredPlugin)
214
- .for<Order>()
215
- .v("id", (b) => b.string.required())
216
- .strict()
217
- // @ts-expect-error strict() returned
218
- // MissingFieldsError<"customerNote" | "nested.deep">, which has no build().
219
- .build();
220
- ```
47
+ That matters most when the code calling this library is generated rather than
48
+ typed by hand. A generator that picks the wrong rule, misspells a path or drops
49
+ an array wildcard gets a red squiggle, not a validator that passes everything.
221
50
 
222
- It counts leaves, so an optional property, a `Date`, an array's elements
223
- (`tags[*]`) and a field inside an array of objects (`items[*].sku`) are each
224
- required in their own right. It has no run-time effect at all — the obligation
225
- is discharged by the compiler.
51
+ Two consequences worth knowing before you read further:
226
52
 
227
- So the choice between "cover one field" and "cover everything" is a single call,
228
- made per builder, and reported at compile time with the missing names rather than
229
- at run time as a value that quietly passed. What `.strict()` does **not** cover is
230
- properties that are not in the type; rejecting those is a run-time rule and
231
- belongs to `additionalProperties(false)`.
53
+ - **Adoption is a patch, not a migration.** A path you did not declare is not
54
+ validated, not required, and not read, so a partly-covered type is a normal
55
+ state rather than a half-finished one. There is nothing global to migrate: no
56
+ registry, no plugin installation, no shared configuration object.
57
+ - **Every rule you can call is a plugin you imported by name**, so the bundle
58
+ contains what you used and nothing else — an unimported plugin's method does
59
+ not even typecheck. What each configuration costs is measured on every build:
60
+ [luq.dev/benchmarks](https://luq.dev/benchmarks).
232
61
 
233
62
  ## When schema-first is the right answer
234
63
 
@@ -260,421 +89,37 @@ If you are starting from nothing and you will own the shape, use zod. It is
260
89
  mature, it is everywhere, and every question you will have is already answered
261
90
  somewhere.
262
91
 
263
- ## Field paths
264
-
265
- A path is a string literal that TypeScript resolves against your type. Nested
266
- fields use dots; array elements use `[*]`, and the issue you get back names the
267
- **real index**.
268
-
269
- ```ts
270
- import { Builder } from "@maroonedog/luq";
271
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
272
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
273
-
274
- type Order = {
275
- customer: { name: string };
276
- items: { productId: string }[];
277
- };
278
-
279
- const orderValidator = Builder()
280
- .use(requiredPlugin)
281
- .use(stringMinPlugin)
282
- .for<Order>()
283
- .v("customer.name", (b) => b.string.required().min(2))
284
- .v("items[*].productId", (b) => b.string.required().min(5))
285
- .build();
286
-
287
- const rejected = orderValidator.validate({
288
- customer: { name: "Acme" },
289
- items: [{ productId: "PROD-1" }, { productId: "X" }],
290
- });
291
-
292
- // -> ["items[1].productId"], never "items[*].productId"
293
- export const failedPaths = rejected.issues.map((issue) => issue.path);
294
- ```
295
-
296
- A path that does not exist on the type is a compile error, not a silent no-op.
297
- So is choosing a slot the field's type cannot be: `b.number` on a `string`
298
- field fails to compile.
299
-
300
- **Cross-field rules read those paths back with their types intact.** `stitch`
301
- takes the paths it needs and hands them over as a bundle keyed by the path
302
- string — each one typed from your type, nested paths included. There is no
303
- `unknown` to narrow and no cast to write.
304
-
305
- ```ts
306
- import { Builder } from "@maroonedog/luq";
307
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
308
- import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
309
- import { stitchPlugin } from "@maroonedog/luq/plugins/stitch";
310
-
311
- type Booking = { seats: number; venue: { capacity: number } };
312
-
313
- const bookingValidator = Builder()
314
- .use(requiredPlugin)
315
- .use(numberMinPlugin)
316
- .use(stitchPlugin)
317
- .for<Booking>()
318
- .v("venue.capacity", (b) => b.number.required().min(1))
319
- .v("seats", (b) =>
320
- b.number.required().stitch(["venue.capacity"], (fieldValues, value) => ({
321
- // fieldValues["venue.capacity"] is number, and value is number.
322
- valid: value <= fieldValues["venue.capacity"],
323
- message: "seats must fit the venue",
324
- }))
325
- )
326
- .build();
327
- ```
328
-
329
- Asking for a path the type does not have is a compile error, and so is using a
330
- bundled value at the wrong type. 1.x passed this bundle as
331
- `Record<string, unknown>`, which meant every cross-field rule opened with a
332
- cast; the paths were already declared, so the types were always knowable.
333
-
334
- ## Plugins are imports
335
-
336
- There is no plugin registry to populate and no barrel you have to pay for.
337
- `.use()` puts a plugin in the builder's bag, and the bag decides which methods
338
- exist on which slots — so an unimported plugin is not merely absent at runtime,
339
- its method does not typecheck.
340
-
341
- ```ts
342
- import { Builder } from "@maroonedog/luq";
343
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
344
-
345
- type Draft = { title: string };
346
-
347
- const draftValidator = Builder()
348
- .use(requiredPlugin)
349
- .for<Draft>()
350
- // `.min(3)` is not offered here: stringMinPlugin was never `use`d.
351
- .v("title", (b) => b.string.required())
352
- .build();
353
-
354
- export const isTitled = draftValidator.validate({ title: "x" }).valid;
355
- ```
356
-
357
- 78 plugin objects ship across 77 subpaths, plus one deprecated alias kept from
358
- 1.x. The complete table — subpath, symbol, chain method, slots — is generated
359
- from the built package: **[docs/guide/plugin-reference.md](docs/guide/plugin-reference.md)**.
360
-
361
- A convenience barrel exists at `@maroonedog/luq/plugins`. It is measurably free
362
- when a bundler can tree-shake (three plugins via the barrel gzip to 7,986 B
363
- against 7,987 B via three subpaths — 0.01%), but the per-plugin subpaths are the
364
- supported route.
365
-
366
- ## JSON Schema
367
-
368
- ```ts
369
- import { fromJsonSchema } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
370
-
371
- type Account = { email: string; age?: number };
372
-
373
- const accountValidator = fromJsonSchema<Account>({
374
- type: "object",
375
- properties: {
376
- email: { type: "string", format: "email" },
377
- age: { type: "number", minimum: 18 },
378
- },
379
- required: ["email"],
380
- });
92
+ ## Install
381
93
 
382
- export const accepted = accountValidator.validate({
383
- email: "a@example.com",
384
- }).valid;
94
+ ```bash
95
+ npm install @maroonedog/luq
385
96
  ```
386
97
 
387
- Measured Draft-07 conformance against the official
388
- [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
389
- (required tests only, skipped cases counted as **failures**):
390
-
391
- **929 / 929 = 100.00%.** Read that against the floor, not against zero: a
392
- validator that returned `true` unconditionally scores 551 / 929 = 59.31% on
393
- this corpus. The skip list is empty, and its `cause` union is `never`, so
394
- excluding a case again means adding a name to a type. Full breakdown, including
395
- what closed each cause and what is still bounded:
396
- [docs/json-schema-conformance.md](docs/json-schema-conformance.md).
397
-
398
- External `$ref` resolves against a map of documents **you** already have —
399
- `jsonSchemaFullFeature(document, { externalDocuments })`. Luq never fetches, so
400
- a URI written in a schema cannot make the process open a socket, conversion
401
- stays synchronous, and nothing is evaluated.
402
-
403
- `jsonSchemaFullFeature` bundles 49 plugins so one import covers a whole
404
- document. `@maroonedog/luq/plugins/jsonSchema` adds a chain method instead, so a
405
- single declared field can be constrained by a document; it takes the plugin bag
406
- explicitly. It is billed as the tree-shakeable half, and measured below, it is
407
- not — use it for the chain method, not to save bytes.
408
-
409
- ## Standard Schema
410
-
411
- Luq implements [Standard Schema v1](https://standardschema.dev). Anything that
412
- accepts a Standard Schema — tRPC, TanStack Form, Hono, t3-env — accepts a Luq
413
- validator wherever it accepts a zod schema.
414
-
415
- ```ts
416
- import { Builder } from "@maroonedog/luq";
417
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
418
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
419
- import { toStandardSchema } from "@maroonedog/luq/standard-schema";
420
-
421
- type Account = { handle: string };
422
-
423
- const standard = toStandardSchema(
424
- Builder()
425
- .use(requiredPlugin)
426
- .use(stringMinPlugin)
427
- .for<Account>()
428
- .v("handle", (b) => b.string.required().min(2))
429
- .build()
430
- );
431
-
432
- // `standard` is still the Validator — `validate`, `parse`, `pick` and `pickAll`
433
- // are all there — and it now also satisfies Standard Schema v1, so it can be
434
- // handed to tRPC, TanStack Form, Hono or t3-env unchanged.
435
- const outcome = standard["~standard"].validate({ handle: "j" });
436
-
437
- if (outcome.issues === undefined) {
438
- console.error(outcome.value.handle);
439
- } else {
440
- for (const issue of outcome.issues) {
441
- console.error(issue.message, issue.path);
442
- }
443
- }
444
- ```
98
+ Zero runtime dependencies. TypeScript 5.0 or later.
445
99
 
446
- Three decisions the spec leaves open, made explicit here:
447
-
448
- - `validate` calls Luq's `parse()`, not `validate()`. The spec's success result
449
- is `{ value: Output }`, and `Output` is the value *after* validation — so a
450
- `transform` has to be applied, and only `parse()` applies it.
451
- - It collects every issue rather than stopping at the first. The consumer of
452
- this seam is a form, and returning one issue at a time produces a UI where
453
- fixing an error reveals the next one. Callers who want the fast path use the
454
- `Validator` directly.
455
- - `InferInput` is the type you wrote in `.for<T>()`, not a type inferred back
456
- out of a schema value.
457
-
458
- It is a subpath, not part of `build()`. Measured on the 2.0.0 core (7,420 B) and
459
- carrying `~standard` on every validator adds 312 B — 4.2% charged to everyone,
460
- including the people who never pass a validator to tRPC. Importing the subpath
461
- costs those 312 B only when you import it, and nothing when you don't.
462
-
463
- ## Your own rules
464
-
465
- ```ts
466
- import { definePlugin, check, PASS, fail } from "@maroonedog/luq/plugin-kit";
467
- import type { Unchanged } from "@maroonedog/luq/plugin-kit";
468
-
469
- export const productCodePlugin = definePlugin<{
470
- args: readonly [];
471
- out: Unchanged;
472
- context: { readonly prefix: string };
473
- }>()({
474
- name: "productCode",
475
- method: "productCode",
476
- slots: ["string"] as const,
477
- build: (ctx) =>
478
- check({
479
- code: ctx.code,
480
- messageFactory: ctx.messageFactory,
481
- severity: ctx.severity,
482
- run: (value) =>
483
- typeof value === "string" && value.startsWith("PROD-")
484
- ? PASS
485
- : fail({ actual: value }),
486
- describe: (_detail, messageContext) =>
487
- `${messageContext.path} must start with PROD-`,
488
- buildMessageContext: () => ({ prefix: "PROD-" }),
489
- }),
490
- });
491
- ```
100
+ ## Documentation
492
101
 
493
- The `out` marker is the whole contract: it decides what `build()` is allowed to
494
- return and what the chain's type becomes afterwards. Eleven argument markers and
495
- four output markers make up the vocabulary — see
496
- **[docs/guide/writing-a-plugin.md](docs/guide/writing-a-plugin.md)**.
497
-
498
- ## Measured numbers
499
-
500
- ### Bundle size
501
-
502
- esbuild 0.25.5, `bundle + minify + esm + es2020 + platform:neutral + treeShaking`,
503
- then `zlib.gzipSync` — the same options 1.x's own `bundle-size-comparison` used,
504
- so the two columns are comparable. Recorded in
505
- [config/size-budget.json](config/size-budget.json) and re-measured by
506
- `npm run check:size` on every build.
507
-
508
- | Entry | gzip | 1.x, same method |
509
- |---|---:|---:|
510
- <!-- generated:bundle-size -->
511
- | `Builder` only, zero plugins | **7,954 B** | 17,423 B |
512
- | + 6 plugins (1.x's "simple" set) | **8,879 B** | 19,562 B |
513
- | all 77 plugins | **25,991 B** | — |
514
- <!-- /generated:bundle-size -->
515
-
516
- Three more entries were measured the same way on 2026-09-07 but are **not** in
517
- `config/size-budget.json`, so nothing re-measures them and they can go stale
518
- without anything noticing. They are kept because the JSON Schema claim needs
519
- evidence, and marked because a figure nobody checks is worth less than one that
520
- is checked:
521
-
522
- | Entry (measured once, not gated) | gzip | 1.x, same method |
523
- |---|---:|---:|
524
- | core + `jsonSchema`, plugin alone (not usable) | 18,992 B | — |
525
- | core + `jsonSchema` + a working 49-plugin bag | 21,371 B | 26.06–29.08 KB |
526
- | core + `jsonSchemaFullFeature` | 21,383 B | 31.75–32.31 KB |
527
-
528
- 1.x published "tree-shakeable, 19–23KB gzipped". Measured the same way, its
529
- core was 17.4 KB **before any plugin was imported** — 89.1% of its "simple"
530
- figure. Here the core is <!-- generated:bundle-core-share -->30.6% of the all-plugins build (7,954 of 25,991 B)<!-- /generated:bundle-core-share -->,
531
- and adding a plugin costs 129–224 B of gzip. Both figures are in the table above;
532
- the difference is where the bytes sit, not which README is right.
533
-
534
- Two lines that are **not** wins:
535
-
536
- - "all 77 plugins at 25,607 B" is larger than the 23,015 B 1.x published for its
537
- `complex` case. The two are not comparable — 1.x's figure was one schema's
538
- plugin set, not its whole catalogue — so it is not counted either way here.
539
- - The 18,992 B for `jsonSchema` measures the plugin **without a bag**, which is
540
- not a configuration you can actually validate with. Supplying a working bag
541
- costs 21,371 B — 12 bytes, 0.06%, **less** than just importing
542
- `jsonSchemaFullFeature`. The tree-shakeable JSON Schema route saves nothing
543
- worth having today; use `jsonSchemaFullFeature` unless you need the chain
544
- method for one field.
545
-
546
- ### Speed
547
-
548
- `npm run bench:record`, recorded verbatim in
549
- [config/perf-baseline.json](config/perf-baseline.json). Machine: AMD Ryzen 7
550
- 5825U, 16 logical cores, Node v23.11.0, Windows. Subject is `src/` transpiled by
551
- ts-node, not the bundle. `abortEarly: true`, input accepted, so no rule is
552
- skipped. Every subject rotates over a pool of at least four distinct values —
553
- one frozen input let V8 delete a subject outright, which is the artefact
554
- described below. Each figure is the median of the fastest half of 9 samples; the
555
- spread quoted alongside is the full range over that figure, and on these ten it
556
- is <!-- generated:perf-spread -->1.2–12.3%<!-- /generated:perf-spread -->.
557
-
558
- | Shape | `validate` ops/sec | `parse` ops/sec |
559
- |---|---:|---:|
560
- <!-- generated:perf-throughput -->
561
- | 1 field, 1 check | 5,581,199 | 5,593,550 |
562
- | 3 fields, 6 plugins | 2,444,308 | 1,933,180 |
563
- | nested, depth 2–3 | 1,722,770 | 1,701,094 |
564
- | array of 50 elements | 90,590 | 90,013 |
565
- | JSON Schema document | 312,630 | 309,502 |
566
- <!-- /generated:perf-throughput -->
567
-
568
- **This rewrite is slower than 1.x on flat and nested shapes.** Measured side by
569
- side, in one process on one machine, 1.x source against this source, sample by
570
- sample interleaved so a drift in the machine hits both halves of every ratio:
571
-
572
- | Shape | 1.x | this | ratio |
573
- |---|---:|---:|---:|
574
- <!-- generated:perf-legacy -->
575
- | 1 field | 26,568,111 | 5,463,953 | **×0.21** |
576
- | 3 fields | 3,082,290 | 2,434,509 | **×0.79** |
577
- | nested | 2,203,633 | 1,842,200 | **×0.83** |
578
- | array of 50 | 19,610 | 84,707 | ×4.30 |
579
- | JSON Schema | 146,283 | 306,778 | ×2.10 |
580
- <!-- /generated:perf-legacy -->
581
-
582
- 1.x carried a directory of specialised fast paths that this implementation has
583
- no equivalent of. The comparison was checked for the ways it could be wrong: 1.x
584
- demonstrably rejects bad values on all five shapes, so it is not winning by
585
- doing less work, and both halves are asserted to accept the accepted pool and
586
- reject the rejected pool before either is timed.
587
-
588
- Also worth stating plainly: **neither figure 1.x's README published reproduces
589
- here.** It claimed 1.2M ops/sec simple and 43K complex; on this machine 1.x
590
- itself does <!-- generated:perf-legacy-simple -->3.08M<!-- /generated:perf-legacy-simple -->
591
- on the shape rebuilt from its own "simple" benchmark source, and "complex" has
592
- no reproducible definition to measure.
593
-
594
- `build()` costs 14–662 µs depending on shape, against sub-microsecond
595
- `validate()` calls — so one `build()` pays for itself after 35–100 `validate()`
596
- calls on four of the five shapes, and after 2 calls on the 50-element array
597
- (where `validate()` itself costs ~33 µs).
598
-
599
- CI does not gate on any absolute number. It gates on the ratio between Luq and a
600
- hand-written validator measured in the same process, so the runner's speed
601
- cancels out. Fifteen pairings are gated: five shapes × `validate` on accepted
602
- input, `parse` on accepted input, and `validate` on rejected input — the
603
- rejected path is a different program under `abortEarly` (early exit, issue
604
- construction, path strings) and was previously not measured at all.
605
-
606
- The gate's resolution is recorded rather than assumed. Slowing every shape's
607
- validator by a fixed factor and re-running (`gateSensitivity` in
608
- config/perf-baseline.json, one run per level): **+35% is caught** on 14 of the
609
- 15 pairings, +25% on 3, and **+15% is missed** on all 15. So the gate sees
610
- roughly a third-slower regression and does not see a sixth-slower one.
611
-
612
- The reference implementations are held to two conditions of their own, both
613
- asserted before any timing. `bench/assert-reference-agreement.ts` requires the
614
- hand-written reference and Luq to agree on every value in both pools, which is
615
- what stops the denominator drifting into a cheaper check than the one Luq
616
- performs — the email, UUID and date-time references were rewritten to the
617
- plugins' own semantics after this was added, and the rejected pool carries one
618
- value per known difference so reverting any of them fails the assertion.
619
- `bench/measure-reference-work.ts` requires each reference to be slower than 0.95
620
- of an empty loop over the same pool, which is how the deleted-subject artefact
621
- is caught: when V8 removes the work the ratio sits at 1.00 or above, and the ten
622
- figures recorded here span 0.01–0.86.
623
-
624
- ### CSP-safe
625
-
626
- No `eval`, no `new Function`. Checked mechanically over all 762 emitted `.js`
627
- and `.mjs` files by `npm run check:no-dynamic-code`, and over `src/` by the
628
- public-API smoke test: **0 occurrences**.
629
-
630
- The check is there because the claim is easy to make and easy to stop being true
631
- — 1.x's README made it while `src/types/array-type-analysis.ts` still carried a
632
- live `new Function`. This is the first release where a script enforces it on
633
- every build rather than a sentence asserting it.
634
-
635
- ### Package
636
-
637
- 86 keys in `exports`, every one resolving to files that exist: 8 fixed keys
638
- (`.`, `./package.json`, `./result`, `./plugin-kit`, `./field-rule`, `./async`,
639
- `./plugins`, `./standard-schema`) and 78 under `./plugins/` — 77 plugins plus one
640
- deprecated alias.
641
- `npm pack --dry-run`: 1,197 files, 376.9 kB packed, 1.4 MB unpacked —
642
- `LICENSE`, `README.md`, `package.json` and `dist/` (398 `.d.ts` + 398 `.js` +
643
- 398 `.mjs`), with nothing from `src/`, `test/`, `scripts/`, `bench/` or `docs/`,
644
- no raw `.ts` and no source maps. A scratch consumer typechecks **every one of
645
- the 86 keys** against the published declarations under **both** `node16` and
646
- `bundler` resolution, and an unpublished subpath is proven to fail.
647
-
648
- 1.x's `createPluginRegistry` / `useField` / `createFieldRule` are published at
649
- `@maroonedog/luq/field-rule`; `useField` is a free function now. See
650
- [docs/migration/breaking-changes.md](docs/migration/breaking-changes.md#15-createpluginregistry-usefield-and-createfieldrule-moved-to-maroonedogluqfield-rule).
102
+ ### **[luq.dev](https://luq.dev)**
651
103
 
652
- ## Documentation
104
+ | | |
105
+ |---|---|
106
+ | [Getting started](https://luq.dev/docs/getting-started) | the builder, defaults, `normalize`, reading a result |
107
+ | [Core concepts](https://luq.dev/docs/core-concepts) | field paths, slots, presence, transforms |
108
+ | [Plugins](https://luq.dev/plugins) | every subpath, symbol, chain method and slot |
109
+ | [JSON Schema](https://luq.dev/json-schema) | reading a document in, writing one back out, measured Draft-07 conformance |
110
+ | [Standard Schema](https://luq.dev/standard-schema) | tRPC, TanStack Form, Hono, react-hook-form — and what does not cross that boundary |
111
+ | [Benchmarks](https://luq.dev/benchmarks) | bundle size and throughput, with the method |
112
+ | [Luq or zod?](https://luq.dev/luq-or-zod) | when schema-first is the better answer |
653
113
 
654
- - **[Getting started](docs/guide/getting-started.md)** the builder, what
655
- `build()` returns, reading a result, `parse` vs `validate`, options, defaults
656
- - **[Field paths](docs/guide/field-paths.md)** — what a path may be, and what
657
- changed from 1.x
658
- - **[Presence and conditionals](docs/guide/presence-and-conditionals.md)** —
659
- `required` / `optional` / `nullable` / `requiredIf`, the order rules run
660
- in, and the cross-field rules (`compareField` / `stitch` / `stitchWith`)
661
- - **[JSON Schema](docs/guide/json-schema.md)** — the two front doors, and the
662
- keywords that are not supported
663
- - **[Writing a plugin](docs/guide/writing-a-plugin.md)** — markers, `out`,
664
- message factories, and the isolation rule
665
- - **[Plugin reference](docs/guide/plugin-reference.md)** — generated table of
666
- every subpath, method and slot
667
- - **[Breaking changes from 1.x](docs/migration/breaking-changes.md)** — every
668
- incompatibility with the fix beside it
669
- - **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 100% and
670
- what closed each of the ten causes that used to fail
114
+ The same documentation ships in `docs/` inside this repository, so the copy at
115
+ any tag describes that release.
671
116
 
672
117
  ## Status, and how this gets changed
673
118
 
674
- The 2.x API is stable and the surface below is gated, but the production track
675
- record is still short. Breaking changes happen in a major and nowhere else, an
676
- API being removed is deprecated one major ahead, and each major ships with the
677
- codemod needed to cross it.
119
+ The 2.x API is stable and gated, but the production track record is still short.
120
+ Breaking changes happen in a major and nowhere else, an API being removed is
121
+ deprecated one major ahead, and each major ships with the codemod needed to
122
+ cross it.
678
123
 
679
124
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** — `npm run verify` is the whole
680
125
  contract; the gates and what each one refuses