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 +146 -99
- package/dist/errors.d.ts +5 -4
- package/dist/errors.js +56 -85
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/match.d.ts +56 -162
- package/dist/match.js +95 -368
- package/dist/match.js.map +1 -1
- package/dist/standard-schema/validation.js +1 -1
- package/dist/standard-schema/validation.js.map +1 -1
- package/package.json +10 -8
- package/dist/is-matching.d.ts +0 -6
- package/dist/is-matching.js +0 -28
- package/dist/is-matching.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# schematch
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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(
|
|
37
|
-
.
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
102
|
+
## `.default(...)` - terminating a match
|
|
104
103
|
|
|
105
|
-
|
|
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(() =>
|
|
111
|
+
.default(({error}) => {
|
|
112
|
+
console.warn(error.message)
|
|
113
|
+
return -1
|
|
114
|
+
})
|
|
111
115
|
```
|
|
112
116
|
|
|
113
|
-
### `.default(
|
|
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
|
-
|
|
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(
|
|
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) //
|
|
135
|
+
fn(true) // compile-time type error
|
|
126
136
|
```
|
|
127
137
|
|
|
128
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
162
|
+
.default<never>(match.throw) // type error: unknown doesn't extend number
|
|
154
163
|
```
|
|
155
164
|
|
|
156
|
-
### `.default(
|
|
165
|
+
### `.default(({error}) => error)`
|
|
157
166
|
|
|
158
|
-
|
|
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(
|
|
172
|
+
.default(({error}) => error)
|
|
164
173
|
|
|
165
174
|
const result = fn(42)
|
|
166
|
-
// result has type: number |
|
|
175
|
+
// result has type: number | MatchError
|
|
167
176
|
|
|
168
|
-
if (result instanceof
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
- 🧩
|
|
217
|
-
-
|
|
218
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 `
|
|
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
|
-
`
|
|
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(
|
|
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>()`
|
|
458
|
-
- `.case(schema, handler)`
|
|
459
|
-
- `.case(schema, predicate, handler)`
|
|
460
|
-
- `.case(schemaA, schemaB, ..., handler)`
|
|
461
|
-
- `.when(predicate, handler)`
|
|
462
|
-
- `.default(handler)` — fallback handler for unmatched inputs
|
|
463
|
-
- `.
|
|
464
|
-
- `.default(
|
|
465
|
-
- `.
|
|
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(...)`
|
|
499
|
+
### `match.case(...)` - reusable matchers
|
|
470
500
|
|
|
471
501
|
Static builder entrypoints that return reusable functions:
|
|
472
502
|
|
|
473
|
-
- `match.input<T>()`
|
|
474
|
-
- `match.output<T>()`
|
|
475
|
-
- `.at(key)`
|
|
476
|
-
- `match.case(...).case(...).default(...)`
|
|
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(
|
|
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(
|
|
558
|
+
.default(match.throw)
|
|
529
559
|
```
|
|
530
560
|
|
|
531
|
-
###
|
|
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
|
-
|
|
574
|
+
Reusable matchers work the same way:
|
|
534
575
|
|
|
535
|
-
|
|
576
|
+
```typescript
|
|
577
|
+
const fn = match
|
|
578
|
+
.case(AsyncSchema, async value => transform(value))
|
|
579
|
+
.defaultAsync(() => fallback)
|
|
536
580
|
|
|
537
|
-
|
|
581
|
+
const result = await fn(input)
|
|
582
|
+
```
|
|
538
583
|
|
|
539
|
-
### `
|
|
584
|
+
### `MatchError`
|
|
540
585
|
|
|
541
|
-
Thrown by `.default(
|
|
586
|
+
Thrown by `.default(match.throw)` / `.default<never>(match.throw)`, or returned by `.default(({error}) => error)`.
|
|
542
587
|
|
|
543
|
-
Implements `StandardSchemaV1.FailureResult
|
|
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(
|
|
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
|
-
|
|
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 `
|
|
571
|
-
- `.default(
|
|
572
|
-
- `.when()` clauses don't contribute to `CaseInputs` for `.default(
|
|
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
|
|
577
|
-
- `
|
|
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
|
|
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
|
|
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?:
|
|
27
|
-
constructor(input: unknown, options?:
|
|
26
|
+
discriminator?: MatchErrorOptions['discriminator'];
|
|
27
|
+
constructor(input: unknown, options?: MatchErrorOptions);
|
|
28
|
+
static summarizeInput(input: unknown): string;
|
|
28
29
|
}
|