dismatch 0.0.2
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 +269 -0
- package/lib/index.js +11 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# dismatch
|
|
2
|
+
|
|
3
|
+
Type-safe pattern matching for TypeScript discriminated unions. Zero dependencies. Full type inference. Exhaustive checking at compile time.
|
|
4
|
+
|
|
5
|
+
Stop writing `switch` statements. Start matching.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install dismatch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
TypeScript discriminated unions are powerful, but working with them is tedious:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
type Shape =
|
|
19
|
+
| { type: 'circle'; radius: number }
|
|
20
|
+
| { type: 'rectangle'; width: number; height: number }
|
|
21
|
+
| { type: 'triangle'; base: number; height: number };
|
|
22
|
+
|
|
23
|
+
// This gets old fast
|
|
24
|
+
function area(shape: Shape): number {
|
|
25
|
+
switch (shape.type) {
|
|
26
|
+
case 'circle':
|
|
27
|
+
return Math.PI * shape.radius ** 2;
|
|
28
|
+
case 'rectangle':
|
|
29
|
+
return shape.width * shape.height;
|
|
30
|
+
case 'triangle':
|
|
31
|
+
return (shape.base * shape.height) / 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add a new variant to `Shape` and the compiler won't tell you about the 14 switch statements you forgot to update.
|
|
37
|
+
|
|
38
|
+
## The Solution
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { match } from 'dismatch';
|
|
42
|
+
|
|
43
|
+
const area = match(shape)({
|
|
44
|
+
circle: ({ radius }) => Math.PI * radius ** 2,
|
|
45
|
+
rectangle: ({ width, height }) => width * height,
|
|
46
|
+
triangle: ({ base, height }) => (base * height) / 2,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Add a new variant. TypeScript screams at every `match` call that's missing a handler. That's the point.
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### `match(union)(handlers)` — Exhaustive Pattern Matching
|
|
55
|
+
|
|
56
|
+
Every variant must have a handler. Miss one and TypeScript won't compile.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
type Result =
|
|
60
|
+
| { type: 'ok'; data: string }
|
|
61
|
+
| { type: 'error'; message: string }
|
|
62
|
+
| { type: 'loading' };
|
|
63
|
+
|
|
64
|
+
const response: Result = { type: 'ok', data: 'hello' };
|
|
65
|
+
|
|
66
|
+
const message = match(response)({
|
|
67
|
+
ok: ({ data }) => `Got: ${data}`,
|
|
68
|
+
error: ({ message }) => `Failed: ${message}`,
|
|
69
|
+
loading: () => 'Loading...',
|
|
70
|
+
});
|
|
71
|
+
// message: "Got: hello"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The return type is inferred from your handlers. Return numbers, strings, objects, whatever you want — it just works.
|
|
75
|
+
|
|
76
|
+
### `matchWithDefault(union)(handlers)` — Match With Fallback
|
|
77
|
+
|
|
78
|
+
Handle some variants explicitly. Catch the rest with `Default`.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const label = matchWithDefault(response)({
|
|
82
|
+
error: ({ message }) => `Error: ${message}`,
|
|
83
|
+
Default: () => 'Everything is fine',
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Useful when you only care about specific variants and want a catch-all for the rest.
|
|
88
|
+
|
|
89
|
+
### `map(union)(handlers)` — Partial Transformation
|
|
90
|
+
|
|
91
|
+
Transform specific variants. The rest pass through unchanged.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const doubled = map(shape)({
|
|
95
|
+
circle: ({ type, radius }) => ({ type, radius: radius * 2 }),
|
|
96
|
+
});
|
|
97
|
+
// If shape is a circle: radius is doubled
|
|
98
|
+
// If shape is anything else: returned as-is, untouched
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Think of it as "update where type matches". Handlers you don't provide default to identity.
|
|
102
|
+
|
|
103
|
+
### `mapAll(union)(handlers)` — Full Transformation
|
|
104
|
+
|
|
105
|
+
Like `map`, but every variant must have a handler. No freebies.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const normalized = mapAll(shape)({
|
|
109
|
+
circle: ({ type, radius }) => ({ type, radius: Math.abs(radius) }),
|
|
110
|
+
rectangle: ({ type, width, height }) => ({ type, width: Math.abs(width), height: Math.abs(height) }),
|
|
111
|
+
triangle: ({ type, base, height }) => ({ type, base: Math.abs(base), height: Math.abs(height) }),
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `is(union, type)` — Type Guard
|
|
116
|
+
|
|
117
|
+
Narrows a union to a specific variant. Works in `if` statements, `filter` calls, anywhere TypeScript expects a type predicate.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { is } from 'dismatch';
|
|
121
|
+
|
|
122
|
+
if (is(shape, 'circle')) {
|
|
123
|
+
// TypeScript knows: shape is { type: 'circle'; radius: number }
|
|
124
|
+
console.log(shape.radius);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Works great with array filtering
|
|
128
|
+
const circles = shapes.filter((s) => is(s, 'circle'));
|
|
129
|
+
// circles: { type: 'circle'; radius: number }[]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `isUnion(value)` — Runtime Validation
|
|
133
|
+
|
|
134
|
+
Checks whether a value is a valid discriminated union (a non-null object with a string `type` property). Useful at system boundaries — API responses, form data, anything you can't trust at compile time.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { isUnion } from 'dismatch';
|
|
138
|
+
|
|
139
|
+
isUnion({ type: 'circle', radius: 5 }); // true
|
|
140
|
+
isUnion({ name: 'no type field' }); // false
|
|
141
|
+
isUnion(null); // false
|
|
142
|
+
isUnion('string'); // false
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Defining Your Unions
|
|
146
|
+
|
|
147
|
+
Use plain TypeScript types. Any object with a `type` string discriminant works:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
type ApiResponse =
|
|
151
|
+
| { type: 'success'; data: User[] }
|
|
152
|
+
| { type: 'error'; code: number; message: string }
|
|
153
|
+
| { type: 'unauthorized' };
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Or use the `Model` helper type for cleaner definitions:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import type { Model } from 'dismatch';
|
|
160
|
+
|
|
161
|
+
type Success = Model<'success', { data: User[] }>;
|
|
162
|
+
type ApiError = Model<'error', { code: number; message: string }>;
|
|
163
|
+
type Unauthorized = Model<'unauthorized', {}>;
|
|
164
|
+
|
|
165
|
+
type ApiResponse = Success | ApiError | Unauthorized;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Curried by Design
|
|
169
|
+
|
|
170
|
+
Every function returns a reusable matcher. Bind the data once, apply different handlers later:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const handleResponse = match(response);
|
|
174
|
+
|
|
175
|
+
// Use the same bound value with different matchers
|
|
176
|
+
const statusCode = handleResponse({
|
|
177
|
+
ok: () => 200,
|
|
178
|
+
error: ({ code }) => code,
|
|
179
|
+
loading: () => 0,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const isHealthy = handleResponse({
|
|
183
|
+
ok: () => true,
|
|
184
|
+
error: () => false,
|
|
185
|
+
loading: () => false,
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Real-World Examples
|
|
190
|
+
|
|
191
|
+
### State Machines
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
type AuthState =
|
|
195
|
+
| { type: 'logged_out' }
|
|
196
|
+
| { type: 'logging_in'; email: string }
|
|
197
|
+
| { type: 'logged_in'; user: User; token: string }
|
|
198
|
+
| { type: 'error'; reason: string };
|
|
199
|
+
|
|
200
|
+
function renderAuth(state: AuthState) {
|
|
201
|
+
return match(state)({
|
|
202
|
+
logged_out: () => renderLoginForm(),
|
|
203
|
+
logging_in: ({ email }) => renderSpinner(`Signing in ${email}...`),
|
|
204
|
+
logged_in: ({ user }) => renderDashboard(user),
|
|
205
|
+
error: ({ reason }) => renderError(reason),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Reducer Actions
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
type Action =
|
|
214
|
+
| { type: 'increment'; amount: number }
|
|
215
|
+
| { type: 'decrement'; amount: number }
|
|
216
|
+
| { type: 'reset' };
|
|
217
|
+
|
|
218
|
+
function reducer(state: number, action: Action): number {
|
|
219
|
+
return match(action)({
|
|
220
|
+
increment: ({ amount }) => state + amount,
|
|
221
|
+
decrement: ({ amount }) => state - amount,
|
|
222
|
+
reset: () => 0,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### API Response Handling
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
type FetchResult<T> =
|
|
231
|
+
| { type: 'pending' }
|
|
232
|
+
| { type: 'fulfilled'; data: T }
|
|
233
|
+
| { type: 'rejected'; error: Error };
|
|
234
|
+
|
|
235
|
+
function unwrapOr<T>(result: FetchResult<T>, fallback: T): T {
|
|
236
|
+
return matchWithDefault(result)({
|
|
237
|
+
fulfilled: ({ data }) => data,
|
|
238
|
+
Default: () => fallback,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Selective Updates
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
type Notification =
|
|
247
|
+
| { type: 'email'; subject: string; read: boolean }
|
|
248
|
+
| { type: 'sms'; body: string; read: boolean }
|
|
249
|
+
| { type: 'push'; title: string; read: boolean };
|
|
250
|
+
|
|
251
|
+
// Mark only emails as read, leave everything else alone
|
|
252
|
+
const markEmailsRead = (n: Notification) =>
|
|
253
|
+
map(n)({
|
|
254
|
+
email: ({ type, subject }) => ({ type, subject, read: true }),
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Scripts
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
npm run build # Compile TypeScript
|
|
262
|
+
npm test # Run tests
|
|
263
|
+
npm run test:watch # Run tests in watch mode
|
|
264
|
+
npm run ts:ci # Type check (no emit)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
ISC
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isUnion = exports.is = exports.mapAll = exports.map = exports.matchWithDefault = exports.match = void 0;
|
|
4
|
+
var unions_1 = require("./unions");
|
|
5
|
+
Object.defineProperty(exports, "match", { enumerable: true, get: function () { return unions_1.match; } });
|
|
6
|
+
Object.defineProperty(exports, "matchWithDefault", { enumerable: true, get: function () { return unions_1.matchWithDefault; } });
|
|
7
|
+
Object.defineProperty(exports, "map", { enumerable: true, get: function () { return unions_1.map; } });
|
|
8
|
+
Object.defineProperty(exports, "mapAll", { enumerable: true, get: function () { return unions_1.mapAll; } });
|
|
9
|
+
var module_1 = require("./module");
|
|
10
|
+
Object.defineProperty(exports, "is", { enumerable: true, get: function () { return module_1.is; } });
|
|
11
|
+
Object.defineProperty(exports, "isUnion", { enumerable: true, get: function () { return module_1.isUnion; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dismatch",
|
|
3
|
+
"description": "A lightweight discriminated unions library for TypeScript",
|
|
4
|
+
"version": "0.0.2",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"typescript",
|
|
9
|
+
"discriminated-unions",
|
|
10
|
+
"pattern-matching"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"author": "Amir Gorji",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"clean": "rm -rf lib",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"ts:ci": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/amir-gorji/discriminated-unions.git"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5.9.3",
|
|
31
|
+
"vitest": "^4.0.18"
|
|
32
|
+
}
|
|
33
|
+
}
|