schematch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # schematch
2
2
 
3
- Schema-first pattern matching for TypeScript.
3
+ Pattern matching for TypeScript.
4
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.
5
+ `schematch` lets you use [Standard Schema](https://standardschema.dev) validators (zod, valibot, arktype et. al.) as matcher clauses in pattern-matching expressions.
6
6
 
7
- ## Install
7
+ ## Do it
8
8
 
9
9
  ```sh
10
10
  pnpm add schematch
11
11
  ```
12
12
 
13
- ## Quick start
14
-
15
13
  ```typescript
16
14
  import {match} from 'schematch'
17
15
  import {z} from 'zod'
@@ -23,19 +21,16 @@ const output = match(input)
23
21
  .default(() => 'unexpected')
24
22
  ```
25
23
 
26
- This works with zod, valibot, arktype, and any other standard-schema compatible library. You can even mix and match libraries:
24
+ You can get a useful and pretty error message in the `.default` callback's second argument:
27
25
 
28
26
  ```typescript
29
- import {match} from 'schematch'
30
- import {z} from 'zod'
31
- import * as v from 'valibot'
32
- import {type} from 'arktype'
33
-
34
27
  const output = match(input)
35
28
  .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')
29
+ .case(z.array(z.number()), arr => `got ${arr.length} numbers`)
30
+ .default(({error}) => {
31
+ console.warn(error.message) // "Schema matching error: no schema matches input (...)\n Case 1: ...\n Case 2: ..."
32
+ return 'unexpected'
33
+ })
39
34
  ```
40
35
 
41
36
  ## Reusable matcher builders
@@ -89,99 +84,115 @@ const output = match(input)
89
84
  .default(() => 'fallback')
90
85
  ```
91
86
 
92
- ## `.default(mode)` terminating a match
87
+ This all works with zod, valibot, arktype, and any other standard-schema compatible library. You could even mix and match libraries (but maybe don't?):
93
88
 
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):
89
+ ```typescript
90
+ import {match} from 'schematch'
91
+ import {z} from 'zod'
92
+ import * as v from 'valibot'
93
+ import {type} from 'arktype'
95
94
 
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` |
95
+ const output = match(input)
96
+ .case(z.string(), s => `hello ${s.slice(1, 3)}`)
97
+ .case(v.array(v.number()), arr => `got ${arr.length} numbers`)
98
+ .case(type({msg: 'string'}), obj => obj.msg)
99
+ .default(() => 'unexpected')
100
+ ```
102
101
 
103
- ### `.default(handler)`
102
+ ## `.default(...)` - terminating a match
104
103
 
105
- A fallback handler called with the raw input when no case matched:
104
+ The `.default(...)` method terminates a match expression. It takes a fallback handler, called with a single context object when no case matched.
105
+
106
+ `context.input` is the unmatched input value. `context.error` is the `MatchError` (a standard-schema validation failure) from attempting to match against the cases specified (note: it is lazy and memoized, so if you don't use it, there's no cost).
106
107
 
107
108
  ```typescript
108
109
  match(input)
109
110
  .case(z.string(), s => s.length)
110
- .default(() => -1) // -1 when no case matches
111
+ .default(({error}) => {
112
+ console.warn(error.message)
113
+ return -1
114
+ })
111
115
  ```
112
116
 
113
- ### `.default('assert')`
117
+ ### `.default(match.throw)`
118
+
119
+ `match.throw` is just a shorthand for `({error}) => {throw error}` - so using `.default(match.throw)` throws the error produced from failing to match any of the cases.
120
+
114
121
 
115
- Throws a `NonExhaustiveError` at runtime if no case matched. The input type is unconstrained (`unknown` for reusable matchers):
122
+ ### `.default<never>(match.throw)`
123
+
124
+ Throws the `MatchError` at runtime if no case matched (like `.default(match.throw)`), and **constrains the input type** at compile time to the union of all case schema input types. If you like types which I think you do, this is best when you know the input will always be one of the declared cases:
116
125
 
117
126
  ```typescript
118
127
  const fn = match
119
128
  .case(z.string(), s => s.length)
120
129
  .case(z.number(), n => n + 1)
121
- .default('assert')
130
+ .default<never>(match.throw) // equivalent to `.default(({error}) => {throw error;})`
122
131
 
132
+ // fn has type: (input: string | number) => number
123
133
  fn('hello') // 5
124
134
  fn(42) // 43
125
- fn(true) // throws NonExhaustiveError
135
+ fn(true) // compile-time type error
126
136
  ```
127
137
 
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:
138
+ You can do whatever you like in a handler, for example logging errors to stderr:
131
139
 
132
140
  ```typescript
133
141
  const fn = match
134
142
  .case(z.string(), s => s.length)
135
143
  .case(z.number(), n => n + 1)
136
- .default('never')
144
+ .default<never>(({input, error}) => {
145
+ input satisfies never
146
+ console.warn(error.message)
147
+ return -1
148
+ })
137
149
 
138
150
  // fn has type: (input: string | number) => number
139
- fn('hello') // 5
140
- fn(42) // 43
141
- fn(true) // compile-time type error
142
151
  ```
143
152
 
144
- For inline matchers, `'never'` produces a compile-time error if the input value doesn't extend the case union:
153
+ For inline matchers, `<never>` produces a compile-time error if the input value doesn't extend the case union:
145
154
 
146
155
  ```typescript
147
156
  match(42 as number)
148
157
  .case(z.number(), n => n + 1)
149
- .default('never') // ok number extends number
158
+ .default<never>(match.throw) // ok: number extends number
150
159
 
151
160
  match('hello' as unknown)
152
161
  .case(z.number(), n => n + 1)
153
- .default('never') // type error unknown doesn't extend number
162
+ .default<never>(match.throw) // type error: unknown doesn't extend number
154
163
  ```
155
164
 
156
- ### `.default('reject')`
165
+ ### `.default(({error}) => error)`
157
166
 
158
- Returns a `NonExhaustiveError` instance instead of throwing. Useful in pipelines where you don't want try/catch:
167
+ You can also of course returns a `MatchError` instance instead of throwing. Useful in pipelines where you don't want try/catch:
159
168
 
160
169
  ```typescript
161
170
  const fn = match
162
171
  .case(z.string(), s => s.length)
163
- .default('reject')
172
+ .default(({error}) => error)
164
173
 
165
174
  const result = fn(42)
166
- // result has type: number | NonExhaustiveError
175
+ // result has type: number | MatchError
167
176
 
168
- if (result instanceof NonExhaustiveError) {
177
+ if (result instanceof MatchError) {
169
178
  console.log(result.issues) // standard-schema failure issues
170
179
  }
171
180
  ```
172
181
 
173
182
  ## Matchers as Standard Schemas
174
183
 
184
+ > "wow! *another* valid standard-schema is produced from schemas composed via schematch!" - Winston Churchill
185
+
175
186
  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
187
 
177
- This means a matcher can be used anywhere a standard-schema is expected including as a case schema inside another matcher:
188
+ This means a matcher can be used anywhere a standard-schema is expected, INCLUDING as a case schema inside another matcher:
178
189
 
179
190
  ```typescript
180
191
  import {match} from 'schematch'
181
192
  import {z} from 'zod'
182
193
  import type {StandardSchemaV1} from 'schematch'
183
194
 
184
- // Build a matcher — it's also a StandardSchema
195
+ // Build a matcher. It's also a StandardSchema, if you can believe such a thing
185
196
  const Stringify = match
186
197
  .case(z.string(), s => s.split(','))
187
198
  .case(z.number(), n => Array.from({length: n}, () => 'hi'))
@@ -193,11 +204,11 @@ Stringify['~standard'].validate('a,b,c') // { value: ['a', 'b', 'c'] }
193
204
  Stringify['~standard'].validate(3) // { value: ['hi', 'hi', 'hi'] }
194
205
  Stringify['~standard'].validate(null) // { issues: [...] }
195
206
 
196
- // Compose use a matcher as a case schema inside another matcher
207
+ // Compose: use a matcher as a case schema inside another matcher
197
208
  const outer = match
198
209
  .case(Stringify, arr => arr.length) // Stringify is the schema here
199
210
  .case(z.boolean(), () => -1)
200
- .default('assert')
211
+ .default(match.throw)
201
212
 
202
213
  outer('a,b,c') // 3
203
214
  outer(5) // 5
@@ -206,20 +217,35 @@ outer(true) // -1
206
217
 
207
218
  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
219
 
209
- Async matchers (`matchAsync.case(...)`) work the same way — their `validate` function returns a `Promise`.
220
+ For async schemas/guards/handlers, use `.defaultAsync(...)` to execute the same matcher asynchronously.
210
221
 
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.
222
+ **Note:** Calling `.default(match.throw)` terminates the matcher and returns a plain function. The returned function is not a StandardSchema. The schema interface lives on the matcher *before* `.default(match.throw)` is called.
212
223
 
213
224
  ## Why use this
214
225
 
215
226
  - 🔁 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.
227
+ - 🧩 Support any standard-schema libraries, even mixed libraries in one matcher.
228
+ - 👷 It's type safe and runtime-y safe
229
+ - 🥰 It looks nicer than `if`/`switch` trees
230
+
231
+ ## When use this
232
+
233
+ - When you want to pattern-match
234
+ - When you don't want to learn ts-pattern's special matching/selection rules
235
+ - This section is kind of the same as why use this
236
+
237
+ ## How use this
238
+
239
+ - `npm install schematch`
240
+ - See [README.md](./README.md)
241
+
242
+ ## Where use this
243
+
244
+ - At your... computer?
219
245
 
220
246
  ## Performance
221
247
 
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.
248
+ `schematch` if fast. It 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
249
 
224
250
  Results from a representative run (ops/sec, higher is better):
225
251
 
@@ -313,7 +339,7 @@ Arktype has its own [`match` API](https://arktype.io/docs/match) that uses set t
313
339
 
314
340
  **Discriminator dispatch** (15 branches, reusable matcher with dispatch table):
315
341
 
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.
342
+ This benchmark uses 15 object schemas with a shared `kind` discriminator key and 2-4 additional typed fields each. Roughly simulating an event-sourcing or webhook scenario. It shows how the dispatch table helps as the branch count grows, especially for late-matching inputs.
317
343
 
318
344
  <!-- bench:fullName="tests/bench/discriminator-dispatch.bench.ts > 15-branch discriminated: mixed inputs (realistic)" -->
319
345
 
@@ -349,7 +375,7 @@ Every schema object is compiled into a `{ sync, async }` pair of functions exact
349
375
 
350
376
  ### Literal fast path
351
377
 
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.
378
+ 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 with no allocation or validation overhead.
353
379
 
354
380
  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
381
 
@@ -361,9 +387,9 @@ When the literal fast path doesn't apply, the compiler detects which library pro
361
387
 
362
388
  **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
389
 
364
- **Valibot** (`~run`): Similar approach calls valibot's internal `~run` directly. Pre-allocates a shared `config` object.
390
+ **Valibot** (`~run`): Similar approach: calls valibot's internal `~run` directly. Pre-allocates a shared `config` object.
365
391
 
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.
392
+ **Arktype** (`.allows`): Uses arktype's `.allows(value)` method, which returns a boolean without creating result objects or issue arrays, so no allocation per call. When the schema has no transforms, the input value is returned directly without calling the full validation pipeline.
367
393
 
368
394
  **Generic fallback**: For any other Standard Schema V1 implementation, calls `~standard.validate` and inspects the result.
369
395
 
@@ -371,13 +397,13 @@ When the literal fast path doesn't apply, the compiler detects which library pro
371
397
 
372
398
  ### Recursive prechecks
373
399
 
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.
400
+ 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
401
 
376
402
  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
403
 
378
404
  Each precheck node is classified as **complete** or **partial**:
379
405
 
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.
406
+ - **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
407
  - **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
408
 
383
409
  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.
@@ -386,11 +412,11 @@ For valibot's `variant` type (discriminated union), the precheck builds a `Map<d
386
412
 
387
413
  ### Reusable matchers
388
414
 
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.
415
+ 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
416
 
391
417
  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
418
 
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.
419
+ **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
420
 
395
421
  ### Cross-branch discriminator dispatch
396
422
 
@@ -402,20 +428,20 @@ Discriminator extraction is library-specific:
402
428
  - **Valibot**: Inspects `schema.entries` for keys where `entry.type === 'literal'`.
403
429
  - **Arktype**: Inspects `schema.json.required` for entries where `value.unit` exists.
404
430
 
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.
431
+ 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
432
 
407
433
  **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
434
 
409
435
  ### Enhanced error messages
410
436
 
411
- When `.default('assert')` throws because no branch matched, the error message includes:
437
+ When `.default(match.throw)` throws because no branch matched, the error message includes:
412
438
 
413
439
  - **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
440
  - **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
441
 
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.
442
+ Re-validation only happens on the error path, so there is no performance impact on successful matches. The `MatchError` object also exposes `.schemas`, `.discriminator`, and `.issues` properties for programmatic access.
417
443
 
418
- `NonExhaustiveError` implements `StandardSchemaV1.FailureResult`, so its `.issues` array conforms to the standard-schema spec.
444
+ `MatchError` implements `StandardSchemaV1.FailureResult`, so its `.issues` array conforms to the standard-schema spec.
419
445
 
420
446
  ### Micro-optimisations
421
447
 
@@ -438,7 +464,7 @@ A few smaller techniques contribute to throughput:
438
464
  | Partial precheck | Any compiled schema | Full validation on mismatches | Precheck call + full validation on match |
439
465
  | Reusable matcher | Hot paths with repeated matching | Fluent chain rebuild | Fixed clause array |
440
466
  | 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 |
467
+ | Enhanced error messages | `.default(match.throw)` failures | - | Re-validation on error path only |
442
468
 
443
469
  ## Supported ecosystems
444
470
 
@@ -454,28 +480,32 @@ A few smaller techniques contribute to throughput:
454
480
 
455
481
  Sync matcher builder:
456
482
 
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
483
+ - `.output<T>()` - constrain the return type of the matcher
484
+ - `.case(schema, handler)` - try a schema, run handler if it matches
485
+ - `.case(schema, predicate, handler)` - schema + guard
486
+ - `.case(schemaA, schemaB, ..., handler)` - multiple schemas, first match wins
487
+ - `.when(predicate, handler)` - no schema, just a predicate
488
+ - `.default(handler)` — fallback handler for unmatched inputs (`({input, error})`)
489
+ - `.defaultAsync(handler)` — async fallback handler (`({input, error})`)
490
+ - `.default(match.throw)` — throw `MatchError` if nothing matched
491
+ - `.defaultAsync(match.throw)` — async terminal that throws `MatchError` if nothing matched
492
+ - `.default<never>(match.throw)` — throw if nothing matched; type error if input doesn't extend case union
493
+ - `.default(({error}) => error)` — return `MatchError` instead of throwing
494
+
495
+ Nothing is evaluated until you call a terminal (`.default(...)` or `.defaultAsync(...)`).
466
496
 
467
497
  `handler` receives `(parsedValue, input)`. For transforming schemas, `parsedValue` is transformed output; for non-transforming schemas, fast paths may pass through the input value.
468
498
 
469
- ### `match.case(...)` reusable matchers
499
+ ### `match.case(...)` - reusable matchers
470
500
 
471
501
  Static builder entrypoints that return reusable functions:
472
502
 
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
- - `.at(key)` switch to discriminator-value cases (`.case(value, handler)`)
476
- - `match.case(...).case(...).default(...)` build a reusable matcher function
503
+ - `match.input<T>()` - constrain the input type for a reusable matcher
504
+ - `match.output<T>()` - constrain the output type for a reusable matcher
505
+ - `.at(key)` - switch to discriminator-value cases (`.case(value, handler)`)
506
+ - `match.case(...).case(...).default(...)` - build a reusable matcher function
477
507
 
478
- 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.
508
+ 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.
479
509
 
480
510
  ### Narrowing unions
481
511
 
@@ -498,7 +528,7 @@ const getSessionId = match
498
528
  .at('type')
499
529
  .case('session.status', value => value.sessionId)
500
530
  .case('message.updated', value => value.properties.sessionId)
501
- .default('assert')
531
+ .default(match.throw)
502
532
  ```
503
533
 
504
534
  `at().case()` checks `input[key] === value` and narrows the handler type. It does not run full branch schema validation.
@@ -525,31 +555,49 @@ const routeLead = match
525
555
  .input<Lead>()
526
556
  .case(z.object({email: z.string().email()}), (_parsed, input) => `email:${input.campaignId}`)
527
557
  .case(z.object({phone: z.string()}), (_parsed, input) => `sms:${input.country}`)
528
- .default('assert')
558
+ .default(match.throw)
529
559
  ```
530
560
 
531
- ### `matchAsync(value)` / `matchAsync.case(...)`
561
+ ### `.defaultAsync(...)`
562
+
563
+ Use `.defaultAsync(...)` when any case schema, guard, or handler is async.
564
+
565
+ ```typescript
566
+ const result = await match(input)
567
+ .case(AsyncSchema, async value => transform(value))
568
+ .defaultAsync(async ({error}) => {
569
+ console.warn(error.message)
570
+ return fallback
571
+ })
572
+ ```
532
573
 
533
- Async equivalents for async schemas, guards, and handlers. Same API as sync variants, but `.default()` returns `Promise<...>` (inline) or `(input) => Promise<...>` (reusable).
574
+ Reusable matchers work the same way:
534
575
 
535
- ### `isMatching(schema, value?)` / `isMatchingAsync(schema, value?)`
576
+ ```typescript
577
+ const fn = match
578
+ .case(AsyncSchema, async value => transform(value))
579
+ .defaultAsync(() => fallback)
536
580
 
537
- Schema-backed type guards.
581
+ const result = await fn(input)
582
+ ```
538
583
 
539
- ### `NonExhaustiveError`
584
+ ### `MatchError`
540
585
 
541
- Thrown by `.default('assert')` / `.default('never')`, or returned by `.default('reject')`.
586
+ Thrown by `.default(match.throw)` / `.default<never>(match.throw)`, or returned by `.default(({error}) => error)`.
542
587
 
543
- 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.
588
+ 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.
544
589
 
545
590
  ## Type inference
546
591
 
547
592
  - First handler arg (`parsed`) is inferred from schema output type.
548
593
  - Second handler arg (`input`) is for input-oriented logic and narrows in common non-transforming union cases.
549
594
  - Return types are unioned across branches.
550
- - `.default('never')` constrains the reusable matcher's input to the union of case schema input types.
595
+ - `.default<never>(match.throw)` constrains the reusable matcher's input to the union of case schema input types.
551
596
  - `StandardSchemaV1.InferInput<typeof matcher>` gives the case input union; `StandardSchemaV1.InferOutput<typeof matcher>` gives the handler return union.
552
- - `isMatching` narrows from `unknown` using schema output.
597
+
598
+ ## Other fun stuff
599
+
600
+ schematch also exports a `prettifyStandardSchemaError` which works on any standard-schema error object and makes it more human-readable (and less token-wasteful for LLMs). That's not an official part of the API surface though so it might move around.
553
601
 
554
602
  ## Comparison
555
603
 
@@ -567,13 +615,12 @@ Use `schematch` when schema-driven validation is central and you want matching t
567
615
 
568
616
  ## Caveats
569
617
 
570
- - Use `matchAsync`/`isMatchingAsync` for async schema validation.
571
- - `.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.
572
- - `.when()` clauses don't contribute to `CaseInputs` for `.default('never')` use `.input<T>()` for full control when mixing `.when()` with input constraints.
618
+ - Use `.defaultAsync(...)` for async schema validation, guards, or handlers.
619
+ - `.default(match.throw)` and `.default<never>(match.throw)` 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.
620
+ - `.when()` clauses don't contribute to `CaseInputs` for `.default<never>(match.throw)`. Use `.input<T>()` for full control when mixing `.when()` with input constraints.
573
621
 
574
622
  ## Exports
575
623
 
576
- - `match`, `matchAsync`
577
- - `isMatching`, `isMatchingAsync`
578
- - `NonExhaustiveError`
624
+ - `match`
625
+ - `MatchError`
579
626
  - `StandardSchemaV1` and helper types: `InferInput`, `InferOutput`
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { StandardSchemaV1 } from './standard-schema/contract.js';
2
- export type NonExhaustiveErrorOptions = {
2
+ export type MatchErrorOptions = {
3
3
  /** Schemas that were attempted during matching */
4
4
  schemas?: StandardSchemaV1[];
5
5
  /** Discriminator info if a dispatch table was available */
@@ -16,13 +16,14 @@ export type NonExhaustiveErrorOptions = {
16
16
  * Implements {@link StandardSchemaV1.FailureResult} so it can be used directly as a
17
17
  * standard-schema failure result — the `.issues` array contains per-case validation details.
18
18
  */
19
- export declare class NonExhaustiveError extends Error implements StandardSchemaV1.FailureResult {
19
+ export declare class MatchError extends Error implements StandardSchemaV1.FailureResult {
20
20
  input: unknown;
21
21
  /** Standard-schema failure issues describing why each case failed to match. */
22
22
  readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
23
23
  /** The schemas that were tried (if available) */
24
24
  schemas?: StandardSchemaV1[];
25
25
  /** Discriminator info (if a dispatch table was available) */
26
- discriminator?: NonExhaustiveErrorOptions['discriminator'];
27
- constructor(input: unknown, options?: NonExhaustiveErrorOptions);
26
+ discriminator?: MatchErrorOptions['discriminator'];
27
+ constructor(input: unknown, options?: MatchErrorOptions);
28
+ static summarizeInput(input: unknown): string;
28
29
  }