schematch 0.0.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.
package/README.md ADDED
@@ -0,0 +1,526 @@
1
+ # schematch
2
+
3
+ Schema-first pattern matching for TypeScript.
4
+
5
+ `schematch` lets you use [Standard Schema](https://standardschema.dev) validators (zod, valibot, arktype et. al.) as matcher clauses, so validation and branching share one source of truth.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add schematch
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```typescript
16
+ import {match} from 'schematch'
17
+ import {z} from 'zod'
18
+
19
+ const output = match(input)
20
+ .case(z.string(), s => `hello ${s.slice(1, 3)}`)
21
+ .case(z.array(z.number()), arr => `got ${arr.length} numbers`)
22
+ .case(z.object({msg: z.string()}), obj => obj.msg)
23
+ .default(() => 'unexpected')
24
+ ```
25
+
26
+ This works with zod, valibot, arktype, and any other standard-schema compatible library. You can even mix and match libraries:
27
+
28
+ ```typescript
29
+ import {match} from 'schematch'
30
+ import {z} from 'zod'
31
+ import * as v from 'valibot'
32
+ import {type} from 'arktype'
33
+
34
+ const output = match(input)
35
+ .case(z.string(), s => `hello ${s.slice(1, 3)}`)
36
+ .case(v.array(v.number()), arr => `got ${arr.length} numbers`)
37
+ .case(type({msg: 'string'}), obj => obj.msg)
38
+ .default(() => 'unexpected')
39
+ ```
40
+
41
+ ## Reusable matcher builders
42
+
43
+ You can prebuild a matcher once into a function, and reuse it across many inputs:
44
+
45
+ ```typescript
46
+ import {match} from 'schematch'
47
+ import {z} from 'zod'
48
+
49
+ const myMatcher = match
50
+ .case(z.string(), s => `hello ${s.slice(1, 3)}`)
51
+ .case(z.array(z.number()), arr => `got ${arr.length} numbers`)
52
+ .case(z.object({msg: z.string()}), obj => obj.msg)
53
+ .default(() => 'unexpected')
54
+
55
+ myMatcher('hello')
56
+ myMatcher([1, 2, 3])
57
+ myMatcher({msg: 'yo'})
58
+ ```
59
+
60
+ This avoids rebuilding the fluent chain for hot paths.
61
+
62
+ You can constrain reusable matcher input types up front:
63
+
64
+ ```typescript
65
+ type Result = {type: 'ok'; value: number} | {type: 'err'; message: string}
66
+
67
+ const TypedMatcher = match
68
+ .input<Result>()
69
+ .case(z.object({type: z.literal('ok'), value: z.number()}), ({value}) => value)
70
+ .default(() => -1)
71
+ ```
72
+
73
+ Similarly, you can constrain the output type with `.output<T>()`:
74
+
75
+ ```typescript
76
+ const TypedMatcher = match
77
+ .input<Result>()
78
+ .output<number>()
79
+ .case(z.object({type: z.literal('ok'), value: z.number()}), ({value}) => value)
80
+ .default(() => -1)
81
+ ```
82
+
83
+ This also works on inline matchers:
84
+
85
+ ```typescript
86
+ const output = match(input)
87
+ .output<string | number>()
88
+ .case(z.number(), n => n + 1)
89
+ .default(() => 'fallback')
90
+ ```
91
+
92
+ ## `.default(mode)` — terminating a match
93
+
94
+ The `.default()` method terminates a match expression. It accepts a handler function or one of three string modes, inspired by [ArkType's `match` API](https://arktype.io/docs/match):
95
+
96
+ | Mode | Input type | On no match | Return type |
97
+ |---|---|---|---|
98
+ | `.default(handler)` | `unknown` (or `.input<T>()`) | Calls handler | `Output \| HandlerReturn` |
99
+ | `.default('assert')` | `unknown` | Throws `NonExhaustiveError` | `Output` |
100
+ | `.default('never')` | Union of case inputs | Throws `NonExhaustiveError` | `Output` |
101
+ | `.default('reject')` | `unknown` | Returns `NonExhaustiveError` | `Output \| NonExhaustiveError` |
102
+
103
+ ### `.default(handler)`
104
+
105
+ A fallback handler — called with the raw input when no case matched:
106
+
107
+ ```typescript
108
+ match(input)
109
+ .case(z.string(), s => s.length)
110
+ .default(() => -1) // -1 when no case matches
111
+ ```
112
+
113
+ ### `.default('assert')`
114
+
115
+ Throws a `NonExhaustiveError` at runtime if no case matched. The input type is unconstrained (`unknown` for reusable matchers):
116
+
117
+ ```typescript
118
+ const fn = match
119
+ .case(z.string(), s => s.length)
120
+ .case(z.number(), n => n + 1)
121
+ .default('assert')
122
+
123
+ fn('hello') // 5
124
+ fn(42) // 43
125
+ fn(true) // throws NonExhaustiveError
126
+ ```
127
+
128
+ ### `.default('never')`
129
+
130
+ Like `'assert'`, but **constrains the input type** at compile time to the union of all case schema input types. Useful when you know the input will always be one of the declared cases:
131
+
132
+ ```typescript
133
+ const fn = match
134
+ .case(z.string(), s => s.length)
135
+ .case(z.number(), n => n + 1)
136
+ .default('never')
137
+
138
+ // fn has type: (input: string | number) => number
139
+ fn('hello') // 5
140
+ fn(42) // 43
141
+ fn(true) // compile-time type error
142
+ ```
143
+
144
+ For inline matchers, `'never'` produces a compile-time error if the input value doesn't extend the case union:
145
+
146
+ ```typescript
147
+ match(42 as number)
148
+ .case(z.number(), n => n + 1)
149
+ .default('never') // ok — number extends number
150
+
151
+ match('hello' as unknown)
152
+ .case(z.number(), n => n + 1)
153
+ .default('never') // type error — unknown doesn't extend number
154
+ ```
155
+
156
+ ### `.default('reject')`
157
+
158
+ Returns a `NonExhaustiveError` instance instead of throwing. Useful in pipelines where you don't want try/catch:
159
+
160
+ ```typescript
161
+ const fn = match
162
+ .case(z.string(), s => s.length)
163
+ .default('reject')
164
+
165
+ const result = fn(42)
166
+ // result has type: number | NonExhaustiveError
167
+
168
+ if (result instanceof NonExhaustiveError) {
169
+ console.log(result.issues) // standard-schema failure issues
170
+ }
171
+ ```
172
+
173
+ ## Matchers as Standard Schemas
174
+
175
+ Reusable matchers (built with `match.case(...)`) are valid [Standard Schema V1](https://standardschema.dev) implementations. They expose a `'~standard'` property with `version: 1`, `vendor: 'schematch'`, and a `validate` function.
176
+
177
+ This means a matcher can be used anywhere a standard-schema is expected — including as a case schema inside another matcher:
178
+
179
+ ```typescript
180
+ import {match} from 'schematch'
181
+ import {z} from 'zod'
182
+ import type {StandardSchemaV1} from 'schematch'
183
+
184
+ // Build a matcher — it's also a StandardSchema
185
+ const Stringify = match
186
+ .case(z.string(), s => s.split(','))
187
+ .case(z.number(), n => Array.from({length: n}, () => 'hi'))
188
+
189
+ Stringify satisfies StandardSchemaV1<string | number, string[]>
190
+
191
+ // Use validate() directly
192
+ Stringify['~standard'].validate('a,b,c') // { value: ['a', 'b', 'c'] }
193
+ Stringify['~standard'].validate(3) // { value: ['hi', 'hi', 'hi'] }
194
+ Stringify['~standard'].validate(null) // { issues: [...] }
195
+
196
+ // Compose — use a matcher as a case schema inside another matcher
197
+ const outer = match
198
+ .case(Stringify, arr => arr.length) // Stringify is the schema here
199
+ .case(z.boolean(), () => -1)
200
+ .default('assert')
201
+
202
+ outer('a,b,c') // 3
203
+ outer(5) // 5
204
+ outer(true) // -1
205
+ ```
206
+
207
+ Type inference works through composition: `StandardSchemaV1.InferInput` gives the union of case input types, and `StandardSchemaV1.InferOutput` gives the union of handler return types.
208
+
209
+ Async matchers (`matchAsync.case(...)`) work the same way — their `validate` function returns a `Promise`.
210
+
211
+ **Note:** Calling `.default()` terminates the matcher and returns a plain function — the returned function is not a StandardSchema. The schema interface lives on the matcher *before* `.default()` is called.
212
+
213
+ ## Why use this
214
+
215
+ - 🔁 Reuse existing runtime schemas for control flow.
216
+ - 🧩 Mix schema libraries in one matcher (via Standard Schema).
217
+ - 🔍 Keep type inference for handler inputs and return unions.
218
+ - 🪓 Avoid duplicating validation logic in `if`/`switch` trees.
219
+
220
+ ## Performance
221
+
222
+ `schematch` includes compiled matcher caching and library-specific fast paths (literals, object/tuple/union/discriminator prechecks). Reusable matchers avoid rebuilding the fluent chain entirely, giving an additional speedup on hot paths.
223
+
224
+ Results from a representative run (ops/sec, higher is better):
225
+
226
+ **Result-style matching** (3 branches, discriminated union):
227
+
228
+ <!-- bench:fullName="tests/bench/match-comparison.bench.ts > result-style docs example" -->
229
+
230
+ | Matcher | ops/sec | vs fastest |
231
+ |---|---|---|
232
+ | schematch arktype | 2,889,271 | fastest |
233
+ | schematch zod-mini | 2,459,148 | 1.17x slower |
234
+ | schematch zod | 2,403,237 | 1.20x slower |
235
+ | schematch valibot | 2,395,803 | 1.21x slower |
236
+ | ts-pattern | 907,255 | 3.18x slower |
237
+
238
+ **Reducer-style matching** (4 branches, tuple state+event):
239
+
240
+ <!-- bench:fullName="tests/bench/match-comparison.bench.ts > reducer-style docs example" -->
241
+
242
+ | Matcher | ops/sec | vs fastest |
243
+ |---|---|---|
244
+ | schematch arktype | 2,470,445 | fastest |
245
+ | schematch zod | 1,896,102 | 1.30x slower |
246
+ | schematch zod-mini | 1,874,122 | 1.32x slower |
247
+ | schematch valibot | 1,857,205 | 1.33x slower |
248
+ | ts-pattern | 406,453 | 6.08x slower |
249
+
250
+ **Inline vs reusable** (result-style):
251
+
252
+ <!-- bench:fullName="tests/bench/reusable-matcher.bench.ts > result matcher (inline vs reusable)" -->
253
+
254
+ | Matcher | ops/sec | vs fastest |
255
+ |---|---|---|
256
+ | schematch arktype (reusable) | 3,595,131 | fastest |
257
+ | schematch zod (reusable) | 3,406,267 | 1.06x slower |
258
+ | schematch zod-mini (reusable) | 3,184,019 | 1.13x slower |
259
+ | schematch valibot (reusable) | 2,970,570 | 1.21x slower |
260
+ | schematch arktype (inline) | 2,949,246 | 1.22x slower |
261
+ | schematch zod (inline) | 2,552,020 | 1.41x slower |
262
+ | schematch zod-mini (inline) | 2,513,358 | 1.43x slower |
263
+ | schematch valibot (inline) | 2,490,268 | 1.44x slower |
264
+ | ts-pattern | 924,386 | 3.89x slower |
265
+
266
+ **Inline vs reusable** (reducer-style):
267
+
268
+ <!-- bench:fullName="tests/bench/reusable-matcher.bench.ts > reducer matcher (inline vs reusable)" -->
269
+
270
+ | Matcher | ops/sec | vs fastest |
271
+ |---|---|---|
272
+ | schematch arktype (reusable) | 3,152,214 | fastest |
273
+ | schematch arktype (inline) | 2,557,790 | 1.23x slower |
274
+ | schematch zod (reusable) | 2,280,499 | 1.38x slower |
275
+ | schematch zod (inline) | 1,975,361 | 1.60x slower |
276
+ | ts-pattern | 406,866 | 7.75x slower |
277
+
278
+ **vs arktype native `match`:**
279
+
280
+ Arktype has its own [`match` API](https://arktype.io/docs/match) that uses set theory to skip unmatched branches. For primitive type discrimination, it's the fastest option. For nested object schemas, `schematch` is faster because it uses arktype's `.allows()` for zero-allocation boolean checks.
281
+
282
+ *Primitive type discrimination* (`string | number | boolean | null`, `bigint`, `object`):
283
+
284
+ <!-- bench:fullName="tests/bench/vs-arktype.bench.ts > vs arktype native: primitive type discrimination" -->
285
+
286
+ | Matcher | ops/sec | vs fastest |
287
+ |---|---|---|
288
+ | arktype native match | 10,390,218 | fastest |
289
+ | schematch arktype (reusable) | 3,420,320 | 3.04x slower |
290
+ | schematch zod (reusable) | 2,861,642 | 3.63x slower |
291
+ | ts-pattern | 668,182 | 15.55x slower |
292
+
293
+ *Nested object matching* (3 branches, discriminated union):
294
+
295
+ <!-- bench:fullName="tests/bench/vs-arktype.bench.ts > vs arktype native: result matching" -->
296
+
297
+ | Matcher | ops/sec | vs fastest |
298
+ |---|---|---|
299
+ | schematch arktype (reusable) | 3,617,913 | fastest |
300
+ | schematch arktype (inline) | 2,994,844 | 1.21x slower |
301
+ | arktype native .at("type") | 236,615 | 15.29x slower |
302
+ | arktype native .case() | 209,913 | 17.24x slower |
303
+
304
+ *Nested tuple matching* (4 branches, tuple state+event):
305
+
306
+ <!-- bench:fullName="tests/bench/vs-arktype.bench.ts > vs arktype native: reducer matching" -->
307
+
308
+ | Matcher | ops/sec | vs fastest |
309
+ |---|---|---|
310
+ | schematch arktype (reusable) | 3,233,544 | fastest |
311
+ | schematch arktype (inline) | 2,520,186 | 1.28x slower |
312
+ | arktype native .case() | 120,772 | 26.77x slower |
313
+
314
+ **Discriminator dispatch** (15 branches, reusable matcher with dispatch table):
315
+
316
+ This benchmark uses 15 object schemas with a shared `kind` discriminator key and 2-4 additional typed fields each — a realistic event-sourcing or webhook scenario. It shows how the dispatch table helps as the branch count grows, especially for late-matching inputs.
317
+
318
+ <!-- bench:fullName="tests/bench/discriminator-dispatch.bench.ts > 15-branch discriminated: mixed inputs (realistic)" -->
319
+
320
+ | Matcher | ops/sec | vs fastest |
321
+ |---|---|---|
322
+ | schematch arktype (reusable + dispatch) | 2,940,143 | fastest |
323
+ | schematch valibot (reusable + dispatch) | 2,485,785 | 1.18x slower |
324
+ | schematch zod (reusable + dispatch) | 2,420,443 | 1.21x slower |
325
+ | schematch zod (inline) | 808,357 | 3.64x slower |
326
+ | ts-pattern | 358,838 | 8.19x slower |
327
+
328
+ The dispatch advantage grows with branch position. For the last branch (worst case for sequential scan):
329
+
330
+ <!-- bench:fullName="tests/bench/discriminator-dispatch.bench.ts > 15-branch discriminated: last branch (worst case)" -->
331
+
332
+ | Matcher | ops/sec | vs fastest |
333
+ |---|---|---|
334
+ | schematch arktype (reusable + dispatch) | 6,959,113 | fastest |
335
+ | schematch zod (reusable + dispatch) | 5,970,836 | 1.17x slower |
336
+ | schematch valibot (reusable + dispatch) | 5,910,174 | 1.18x slower |
337
+ | schematch zod (inline) | 1,460,279 | 4.77x slower |
338
+ | ts-pattern | 632,622 | 11.00x slower |
339
+
340
+ ## How it works
341
+
342
+ Calling `match(value).case(schema, handler)` or building a reusable matcher looks simple, but under the hood schematch compiles each schema into a specialised matcher the first time it's seen, caches it, and then applies a layered series of fast paths before ever falling back to the schema library's own `validate` call. The layers are described below, roughly in the order they're tried.
343
+
344
+ ### Compiled matcher caching
345
+
346
+ Every schema object is compiled into a `{ sync, async }` pair of functions exactly once. The compiled matcher is stored directly on the schema object via a well-known symbol (`Symbol.for('schematch.compiled-matcher')`). If the object is frozen or non-extensible, a `WeakMap` fallback is used instead. Subsequent calls with the same schema instance hit one of these caches and skip compilation entirely.
347
+
348
+ **Tradeoff:** Caching on the schema object itself is the fastest lookup (property access), but mutates the schema. The WeakMap fallback avoids that at the cost of a hash lookup. Both are per-instance, so structurally identical but distinct schema objects compile independently.
349
+
350
+ ### Literal fast path
351
+
352
+ Before trying any library-specific path, the compiler checks whether the schema represents a single literal value (e.g. `z.literal('ok')`, `v.literal(42)`, `type('ok')`). If so, the compiled matcher is a single `Object.is()` comparison — zero allocation, no validation overhead.
353
+
354
+ Detection is duck-typed across libraries: arktype's `unit` property, valibot's `type === 'literal'` with a `.literal` field, and zod's `_def.type === 'literal'` with a single-element `values` array.
355
+
356
+ **Tradeoff:** Relies on internal schema structure rather than a public API. This is fragile across library major versions but turns what would be a full validation call into a single comparison.
357
+
358
+ ### Library-specific compiled matchers
359
+
360
+ When the literal fast path doesn't apply, the compiler detects which library produced the schema by looking for internal properties (`_zod`, `~run`, `.allows`) and generates a tailored matcher:
361
+
362
+ **Zod** (`_zod.run`): Calls zod's internal `run` method directly instead of going through `~standard.validate`. Pre-allocates payload (`{value, issues}`) and context (`{async: false}`) objects and reuses them across calls by mutating `.value` and setting `.issues.length = 0`. This avoids allocating new objects on every match attempt.
363
+
364
+ **Valibot** (`~run`): Similar approach — calls valibot's internal `~run` directly. Pre-allocates a shared `config` object.
365
+
366
+ **Arktype** (`.allows`): Uses arktype's `.allows(value)` method, which returns a boolean without creating result objects or issue arrays — zero allocation per call. When the schema has no transforms, the input value is returned directly without calling the full validation pipeline.
367
+
368
+ **Generic fallback**: For any other Standard Schema V1 implementation, calls `~standard.validate` and inspects the result.
369
+
370
+ **Tradeoff:** Duck-typing library internals provides significant speedups (the zod/valibot paths avoid result object allocation; the arktype path avoids validation entirely for non-transform schemas) but couples schematch to implementation details. A new major version of any library could break detection. The generic fallback ensures correctness regardless.
371
+
372
+ ### Recursive prechecks
373
+
374
+ For zod and valibot, the compiler recursively walks the schema definition tree and builds a lightweight boolean predicate — a "precheck" — that can reject non-matching values cheaply before invoking the library's validation.
375
+
376
+ The precheck handles: literals (`Object.is`), primitives (`typeof`), objects (per-key checks), tuples (per-item checks with length bounds), unions (any-of), discriminated unions/variants (Map lookup on discriminator key), `null`/`undefined`/`Date`/`instanceof`.
377
+
378
+ Each precheck node is classified as **complete** or **partial**:
379
+
380
+ - **Complete**: The precheck fully covers the schema's type constraint (no transforms, refinements, `.pipe()`, `.checks`, or unhandled schema types in the tree). When a precheck is complete, the library's validation is skipped entirely — the precheck result alone determines match/no-match, and the raw input value is returned.
381
+ - **Partial**: The precheck can fast-reject values that definitely don't match (e.g. wrong `typeof`, missing discriminator) but a passing precheck still requires full validation to confirm. This is the common case for schemas with `.min()`, `.regex()`, `.refine()`, etc.
382
+
383
+ For valibot's `variant` type (discriminated union), the precheck builds a `Map<discriminatorValue, check>` for O(1) dispatch on the discriminator field, rather than iterating through union options.
384
+
385
+ **Tradeoff:** Complete prechecks give the biggest speedup (full validation bypass) but return the input value as-is, so they cannot be used with schemas that apply transforms. Partial prechecks still help by avoiding expensive validation calls for obvious mismatches, at the cost of the precheck function call overhead on values that do match. The recursive walk happens once at compile time, not per match.
386
+
387
+ ### Reusable matchers
388
+
389
+ When you write `match.case(...).case(...).default(...)` (without an input value), schematch builds a `ReusableMatcher` that stores the clause list as a plain array at construction time. The returned function iterates the pre-built array on each call — no `new MatchExpression()`, no fluent chain, no per-call allocation of clause structures. Benchmarks show a ~20-40% throughput increase over inline matching.
390
+
391
+ Reusable matchers are also valid Standard Schema V1 implementations (see [Matchers as Standard Schemas](#matchers-as-standard-schemas)), so they can be composed with other matchers or used anywhere a standard-schema is expected.
392
+
393
+ **Tradeoff:** The reusable matcher's clause array is allocated once and shared across calls. This is faster but means the matcher is fixed after construction — you can't add branches dynamically.
394
+
395
+ ### Cross-branch discriminator dispatch
396
+
397
+ When a reusable matcher's `.case()` branches are all object schemas sharing a common literal-typed key (e.g. `type`, `kind`, `status`), schematch automatically builds a dispatch table at construction time. On each match attempt, instead of trying every branch sequentially, it reads the discriminator value from the input and jumps directly to the candidate branch(es).
398
+
399
+ Discriminator extraction is library-specific:
400
+
401
+ - **Zod/zod-mini**: Inspects `_def.shape` for keys whose sub-def has `type === 'literal'` with a single value.
402
+ - **Valibot**: Inspects `schema.entries` for keys where `entry.type === 'literal'`.
403
+ - **Arktype**: Inspects `schema.json.required` for entries where `value.unit` exists.
404
+
405
+ When multiple keys are literal-typed, preferred discriminator names (`type`, `kind`, `status`, `_tag`, `tag`) take priority. Clauses without an extractable discriminator (e.g. `.when()` predicates, non-object schemas) go into a fallback set that's always checked. Original clause ordering is preserved — first-match-wins semantics are maintained.
406
+
407
+ **Tradeoff:** Only applies to reusable matchers (not inline), only works for object schemas with shared literal keys, and adds a small construction-time cost for schema introspection. For non-discriminated schemas or non-object inputs, the dispatch table is skipped and matching falls back to the linear scan.
408
+
409
+ ### Enhanced error messages
410
+
411
+ When `.default('assert')` throws because no branch matched, the error message includes:
412
+
413
+ - **Discriminator info** (reusable matchers): If a dispatch table exists, the error reports the discriminator key, the actual value, and the expected values. For example: `Discriminator 'type' has value "unknown" but expected one of: "ok", "err"`.
414
+ - **Per-schema validation issues**: The error re-validates the input against each candidate schema (or all schemas if no dispatch table exists) and formats the issues. For example: `Case 1: ✖ Expected number → at value`.
415
+
416
+ Re-validation only happens on the error path, so there is no performance impact on successful matches. The `NonExhaustiveError` object also exposes `.schemas`, `.discriminator`, and `.issues` properties for programmatic access.
417
+
418
+ `NonExhaustiveError` implements `StandardSchemaV1.FailureResult`, so its `.issues` array conforms to the standard-schema spec.
419
+
420
+ ### Micro-optimisations
421
+
422
+ A few smaller techniques contribute to throughput:
423
+
424
+ - **Sentinel symbols** (`NO_MATCH`, `ASYNC_REQUIRED`): Using symbols as return values avoids wrapping match results in `{matched: false}` objects. Control flow is a simple reference equality check.
425
+ - **Early short-circuit**: Once a branch matches, all subsequent `.case()` calls are no-ops (`if (this.matched) return this`).
426
+ - **Singleton unmatched state**: A single frozen `{matched: false, value: undefined}` object is shared across all unmatched branches.
427
+ - **Indexed for-loops**: All inner loops use `for (let i = 0; i < n; i += 1)` rather than `for...of` or `.forEach()`, avoiding iterator protocol overhead.
428
+ - **2-argument fast path**: The common `.case(schema, handler)` call skips guard detection, argument slicing, and inner-loop setup.
429
+
430
+ ### Summary
431
+
432
+ | Layer | When it helps | What it skips | Cost |
433
+ |---|---|---|---|
434
+ | Compiled matcher cache | Every call after the first | Recompilation | One symbol/WeakMap lookup |
435
+ | Literal fast path | `z.literal()`, `v.literal()`, `type('x')` | All validation | One `Object.is()` call |
436
+ | Library-specific matcher | zod, valibot, arktype schemas | Generic `~standard.validate` | Duck-typing on internals |
437
+ | Complete precheck | Simple schemas (no transforms/refinements) | Library `run()` entirely | Lightweight boolean function |
438
+ | Partial precheck | Any compiled schema | Full validation on mismatches | Precheck call + full validation on match |
439
+ | Reusable matcher | Hot paths with repeated matching | Fluent chain rebuild | Fixed clause array |
440
+ | Discriminator dispatch | Reusable matchers with shared literal key | Non-matching branches | One property read + Map lookup |
441
+ | Enhanced error messages | `.default('assert')` failures | — | Re-validation on error path only |
442
+
443
+ ## Supported ecosystems
444
+
445
+ - `zod`
446
+ - `zod/mini`
447
+ - `valibot`
448
+ - `arktype`
449
+ - Any Standard Schema V1 implementation (`~standard.validate`)
450
+
451
+ ## API
452
+
453
+ ### `match(value)`
454
+
455
+ Sync matcher builder:
456
+
457
+ - `.output<T>()` — constrain the return type of the matcher
458
+ - `.case(schema, handler)` — try a schema, run handler if it matches
459
+ - `.case(schema, predicate, handler)` — schema + guard
460
+ - `.case(schemaA, schemaB, ..., handler)` — multiple schemas, first match wins
461
+ - `.when(predicate, handler)` — no schema, just a predicate
462
+ - `.default(handler)` — fallback handler for unmatched inputs
463
+ - `.default('assert')` — throw `NonExhaustiveError` if nothing matched
464
+ - `.default('never')` — throw if nothing matched; type error if input doesn't extend case union
465
+ - `.default('reject')` — return `NonExhaustiveError` instead of throwing
466
+
467
+ `handler` receives `(parsedValue, input)` where `parsedValue` is schema output.
468
+
469
+ ### `match.case(...)` — reusable matchers
470
+
471
+ Static builder entrypoints that return reusable functions:
472
+
473
+ - `match.input<T>()` — constrain the input type for a reusable matcher
474
+ - `match.output<T>()` — constrain the output type for a reusable matcher
475
+ - `match.case(...).case(...).default(...)` — build a reusable matcher function
476
+
477
+ Reusable matchers are also valid Standard Schema V1 implementations. Before `.default()` is called, they expose a `'~standard'` property with `validate`, allowing them to be used as schemas in other matchers or any standard-schema consumer.
478
+
479
+ ### `matchAsync(value)` / `matchAsync.case(...)`
480
+
481
+ Async equivalents for async schemas, guards, and handlers. Same API as sync variants, but `.default()` returns `Promise<...>` (inline) or `(input) => Promise<...>` (reusable).
482
+
483
+ ### `isMatching(schema, value?)` / `isMatchingAsync(schema, value?)`
484
+
485
+ Schema-backed type guards.
486
+
487
+ ### `NonExhaustiveError`
488
+
489
+ Thrown by `.default('assert')` / `.default('never')`, or returned by `.default('reject')`.
490
+
491
+ Implements `StandardSchemaV1.FailureResult` — the `.issues` array contains per-case validation details conforming to the standard-schema spec. Also exposes `.input`, `.schemas`, and `.discriminator` for programmatic access.
492
+
493
+ ## Type inference
494
+
495
+ - Handler input type is inferred from schema output type.
496
+ - Return types are unioned across branches.
497
+ - `.default('never')` constrains the reusable matcher's input to the union of case schema input types.
498
+ - `StandardSchemaV1.InferInput<typeof matcher>` gives the case input union; `StandardSchemaV1.InferOutput<typeof matcher>` gives the handler return union.
499
+ - `isMatching` narrows from `unknown` using schema output.
500
+
501
+ ## Comparison
502
+
503
+ ### vs `ts-pattern`
504
+
505
+ - `ts-pattern` matches JS patterns directly and is excellent for structural matching.
506
+ - `schematch` matches with runtime schemas you already own.
507
+
508
+ Use `schematch` when schema-driven validation is central and you want matching to follow it.
509
+
510
+ ### vs ad-hoc validation + branching
511
+
512
+ - Ad-hoc approach repeats parse checks and manual narrowing.
513
+ - `schematch` centralizes this in a single typed expression.
514
+
515
+ ## Caveats
516
+
517
+ - Use `matchAsync`/`isMatchingAsync` for async schema validation.
518
+ - `.default('assert')` and `.default('never')` provide runtime exhaustiveness, not compile-time algebraic exhaustiveness. TypeScript cannot verify that your case schemas cover every member of a union at the type level.
519
+ - `.when()` clauses don't contribute to `CaseInputs` for `.default('never')` — use `.input<T>()` for full control when mixing `.when()` with input constraints.
520
+
521
+ ## Exports
522
+
523
+ - `match`, `matchAsync`
524
+ - `isMatching`, `isMatchingAsync`
525
+ - `NonExhaustiveError`
526
+ - `StandardSchemaV1` and helper types: `InferInput`, `InferOutput`
@@ -0,0 +1,28 @@
1
+ import type { StandardSchemaV1 } from './standard-schema/contract.js';
2
+ export type NonExhaustiveErrorOptions = {
3
+ /** Schemas that were attempted during matching */
4
+ schemas?: StandardSchemaV1[];
5
+ /** Discriminator info if a dispatch table was available */
6
+ discriminator?: {
7
+ key: string;
8
+ value: unknown;
9
+ expected: unknown[];
10
+ matched: boolean;
11
+ };
12
+ };
13
+ /**
14
+ * Error thrown (or returned) when no case in a match expression matched the input.
15
+ *
16
+ * Implements {@link StandardSchemaV1.FailureResult} so it can be used directly as a
17
+ * standard-schema failure result — the `.issues` array contains per-case validation details.
18
+ */
19
+ export declare class NonExhaustiveError extends Error implements StandardSchemaV1.FailureResult {
20
+ input: unknown;
21
+ /** Standard-schema failure issues describing why each case failed to match. */
22
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
23
+ /** The schemas that were tried (if available) */
24
+ schemas?: StandardSchemaV1[];
25
+ /** Discriminator info (if a dispatch table was available) */
26
+ discriminator?: NonExhaustiveErrorOptions['discriminator'];
27
+ constructor(input: unknown, options?: NonExhaustiveErrorOptions);
28
+ }