wellcrafted 0.33.0 → 0.34.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 +209 -576
- package/dist/error/index.d.ts +34 -17
- package/dist/error/index.d.ts.map +1 -1
- package/dist/error/index.js +33 -5
- package/dist/error/index.js.map +1 -1
- package/dist/{index-Cd0uJHqj.d.ts → index-D_iQ3bBj.d.ts} +2 -2
- package/dist/{index-Cd0uJHqj.d.ts.map → index-D_iQ3bBj.d.ts.map} +1 -1
- package/dist/json.d.ts +25 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +0 -0
- package/dist/query/index.d.ts +2 -3
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -2
- package/dist/query/index.js.map +1 -1
- package/dist/result/index.d.ts +3 -3
- package/dist/result/index.js +2 -2
- package/dist/{result-DnOm5ds5.js → result-BRfWC87j.js} +95 -1
- package/dist/result-BRfWC87j.js.map +1 -0
- package/dist/{result-0QjbC3Hw.js → result-Cd0chHlN.js} +2 -2
- package/dist/{result-0QjbC3Hw.js.map → result-Cd0chHlN.js.map} +1 -1
- package/dist/{result-DolxQXIZ.d.ts → result-xH3TbSDF.d.ts} +28 -60
- package/dist/result-xH3TbSDF.d.ts.map +1 -0
- package/dist/standard-schema/index.d.ts +1 -7
- package/dist/standard-schema/index.d.ts.map +1 -1
- package/dist/standard-schema/index.js.map +1 -1
- package/package.json +5 -1
- package/dist/result-DnOm5ds5.js.map +0 -1
- package/dist/result-DolxQXIZ.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -5,691 +5,324 @@
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://bundlephobia.com/package/wellcrafted)
|
|
7
7
|
|
|
8
|
-
*
|
|
8
|
+
*Define your errors. Type the rest.*
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Tagged errors and Result types as plain objects. < 2KB, zero dependencies.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
// ❌ Before: Which errors can this throw? 🤷
|
|
14
|
-
try {
|
|
15
|
-
await saveUser(user);
|
|
16
|
-
} catch (error) {
|
|
17
|
-
// ... good luck debugging in production
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// ✅ After: Every error is visible and typed
|
|
21
|
-
const { data, error } = await saveUser(user);
|
|
22
|
-
if (error) {
|
|
23
|
-
switch (error.name) {
|
|
24
|
-
case "ValidationError":
|
|
25
|
-
showToast(`Invalid ${error.field}`);
|
|
26
|
-
break;
|
|
27
|
-
case "AuthError":
|
|
28
|
-
redirectToLogin();
|
|
29
|
-
break;
|
|
30
|
-
// TypeScript ensures you handle all cases!
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## A collection of simple, powerful primitives
|
|
36
|
-
|
|
37
|
-
### 🎯 Result Type
|
|
38
|
-
Make errors explicit in function signatures
|
|
39
|
-
```typescript
|
|
40
|
-
function divide(a: number, b: number): Result<number, string> {
|
|
41
|
-
if (b === 0) return Err("Division by zero");
|
|
42
|
-
return Ok(a / b);
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
### 🏷️ Brand Types
|
|
47
|
-
Create distinct types from primitives
|
|
48
|
-
```typescript
|
|
49
|
-
type UserId = string & Brand<"UserId">;
|
|
50
|
-
type OrderId = string & Brand<"OrderId">;
|
|
12
|
+
Most Result libraries hand you a container and leave the error type as an exercise. You get `Ok` and `Err` but nothing to help you define, compose, or serialize the errors themselves. So you end up with string literals, ad-hoc objects, or class hierarchies that break the moment you call `JSON.stringify`.
|
|
51
13
|
|
|
52
|
-
|
|
53
|
-
function getUser(id: UserId) { /* ... */ }
|
|
54
|
-
```
|
|
14
|
+
wellcrafted takes the opposite approach: start with the errors. `defineErrors` gives you typed, serializable, composable error variants inspired by Rust's [thiserror](https://docs.rs/thiserror). The Result type is just `{ data, error }` destructuring — the same shape you already know from Supabase, SvelteKit load functions, and TanStack Query. No `.isOk()` method chains, no `.map().andThen().orElse()` pipelines. Check `error`, use `data`. That's it.
|
|
55
15
|
|
|
56
|
-
### 📋 Tagged Errors
|
|
57
|
-
Structured, serializable errors with a declarative API
|
|
58
16
|
```typescript
|
|
59
|
-
import { defineErrors, type
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
17
|
+
import { defineErrors, extractErrorMessage, type InferErrors } from "wellcrafted/error";
|
|
18
|
+
import { tryAsync, Ok, type Result } from "wellcrafted/result";
|
|
19
|
+
|
|
20
|
+
// Define domain errors — all variants in one call
|
|
21
|
+
const UserError = defineErrors({
|
|
22
|
+
AlreadyExists: ({ email }: { email: string }) => ({
|
|
23
|
+
message: `User ${email} already exists`,
|
|
24
|
+
email,
|
|
65
25
|
}),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
26
|
+
CreateFailed: ({ email, cause }: { email: string; cause: unknown }) => ({
|
|
27
|
+
message: `Failed to create user ${email}: ${extractErrorMessage(cause)}`,
|
|
28
|
+
email,
|
|
29
|
+
cause,
|
|
70
30
|
}),
|
|
71
31
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
queryKey: ['users', userId],
|
|
87
|
-
queryFn: () => getUserFromAPI(userId) // Returns Result<User, ApiError>
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// Use reactively in components with automatic state management
|
|
91
|
-
// Svelte 5 requires accessor function; React uses options directly
|
|
92
|
-
const query = createQuery(() => userQuery.options); // Svelte
|
|
93
|
-
// query.data, query.error, query.isPending all managed automatically
|
|
32
|
+
type UserError = InferErrors<typeof UserError>;
|
|
33
|
+
// ^? { name: "AlreadyExists"; message: string; email: string }
|
|
34
|
+
// | { name: "CreateFailed"; message: string; email: string; cause: unknown }
|
|
35
|
+
|
|
36
|
+
// Each factory returns Err<...> directly — no wrapping needed
|
|
37
|
+
async function createUser(email: string): Promise<Result<User, UserError>> {
|
|
38
|
+
const existing = await db.findByEmail(email);
|
|
39
|
+
if (existing) return UserError.AlreadyExists({ email });
|
|
40
|
+
|
|
41
|
+
return tryAsync({
|
|
42
|
+
try: () => db.users.create({ email }),
|
|
43
|
+
catch: (error) => UserError.CreateFailed({ email, cause: error }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
94
46
|
|
|
95
|
-
//
|
|
96
|
-
const { data, error } = await
|
|
97
|
-
// Or shorthand: await userQuery() (same as .ensure())
|
|
47
|
+
// Discriminate with switch — TypeScript narrows automatically
|
|
48
|
+
const { data, error } = await createUser("alice@example.com");
|
|
98
49
|
if (error) {
|
|
99
|
-
|
|
100
|
-
|
|
50
|
+
switch (error.name) {
|
|
51
|
+
case "AlreadyExists": console.log(error.email); break;
|
|
52
|
+
case "CreateFailed": console.log(error.email); break;
|
|
53
|
+
// ^ TypeScript knows exactly which fields exist
|
|
54
|
+
}
|
|
101
55
|
}
|
|
102
|
-
// Use data...
|
|
103
56
|
```
|
|
104
57
|
|
|
105
|
-
##
|
|
58
|
+
## Install
|
|
106
59
|
|
|
107
60
|
```bash
|
|
108
61
|
npm install wellcrafted
|
|
109
62
|
```
|
|
110
63
|
|
|
111
|
-
##
|
|
112
|
-
|
|
113
|
-
```typescript
|
|
114
|
-
import { tryAsync } from "wellcrafted/result";
|
|
115
|
-
import { defineErrors, type InferError } from "wellcrafted/error";
|
|
116
|
-
|
|
117
|
-
// Define your errors declaratively
|
|
118
|
-
const errors = defineErrors({
|
|
119
|
-
ApiError: (fields: { endpoint: string }) => ({
|
|
120
|
-
...fields,
|
|
121
|
-
message: `Failed to fetch ${fields.endpoint}`,
|
|
122
|
-
}),
|
|
123
|
-
});
|
|
124
|
-
const { ApiError, ApiErr } = errors;
|
|
125
|
-
type ApiError = InferError<typeof errors, "ApiError">;
|
|
126
|
-
|
|
127
|
-
// Wrap any throwing operation
|
|
128
|
-
const { data, error } = await tryAsync({
|
|
129
|
-
try: () => fetch('/api/user').then(r => r.json()),
|
|
130
|
-
catch: (e) => ApiErr({ endpoint: '/api/user' })
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
if (error) {
|
|
134
|
-
console.error(`${error.name}: ${error.message}`);
|
|
135
|
-
} else {
|
|
136
|
-
console.log("User:", data);
|
|
137
|
-
}
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
## Core Features
|
|
141
|
-
|
|
142
|
-
<table>
|
|
143
|
-
<tr>
|
|
144
|
-
<td>
|
|
145
|
-
|
|
146
|
-
**🎯 Explicit Error Handling**
|
|
147
|
-
All errors visible in function signatures
|
|
148
|
-
|
|
149
|
-
</td>
|
|
150
|
-
<td>
|
|
151
|
-
|
|
152
|
-
**📦 Serialization-Safe**
|
|
153
|
-
Plain objects work everywhere
|
|
154
|
-
|
|
155
|
-
</td>
|
|
156
|
-
<td>
|
|
157
|
-
|
|
158
|
-
**✨ Elegant API**
|
|
159
|
-
Clean, intuitive patterns
|
|
160
|
-
|
|
161
|
-
</td>
|
|
162
|
-
</tr>
|
|
163
|
-
<tr>
|
|
164
|
-
<td>
|
|
165
|
-
|
|
166
|
-
**🔍 Zero Magic**
|
|
167
|
-
~50 lines of core code
|
|
168
|
-
|
|
169
|
-
</td>
|
|
170
|
-
<td>
|
|
171
|
-
|
|
172
|
-
**🚀 Lightweight**
|
|
173
|
-
Zero dependencies, < 2KB
|
|
174
|
-
|
|
175
|
-
</td>
|
|
176
|
-
<td>
|
|
177
|
-
|
|
178
|
-
**🎨 Composable**
|
|
179
|
-
Mix and match utilities
|
|
180
|
-
|
|
181
|
-
</td>
|
|
182
|
-
</tr>
|
|
183
|
-
</table>
|
|
184
|
-
|
|
185
|
-
## The Result Pattern Explained
|
|
186
|
-
|
|
187
|
-
The Result type makes error handling explicit and type-safe:
|
|
188
|
-
|
|
189
|
-
```typescript
|
|
190
|
-
type Ok<T> = { data: T; error: null };
|
|
191
|
-
type Err<E> = { error: E; data: null };
|
|
192
|
-
type Result<T, E> = Ok<T> | Err<E>;
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
This creates a discriminated union where TypeScript automatically narrows types:
|
|
64
|
+
## Why define errors at all?
|
|
196
65
|
|
|
197
|
-
|
|
198
|
-
if (result.error) {
|
|
199
|
-
// TypeScript knows: error is E, data is null
|
|
200
|
-
} else {
|
|
201
|
-
// TypeScript knows: data is T, error is null
|
|
202
|
-
}
|
|
203
|
-
```
|
|
66
|
+
You can use `Ok` and `Err` with any value. So why bother with `defineErrors`?
|
|
204
67
|
|
|
205
|
-
|
|
68
|
+
Because in practice, errors aren't random. Every service has a handful of things that can go wrong, and you want to enumerate them upfront. A user service has `AlreadyExists`, `CreateFailed`, `InvalidEmail`. An HTTP client has `Connection`, `Timeout`, `Response`. These are logical groups — the error vocabulary for a domain. Rust codified this with [thiserror](https://docs.rs/thiserror). `defineErrors` brings the same pattern to TypeScript, but outputs plain objects instead of classes.
|
|
206
69
|
|
|
207
|
-
|
|
70
|
+
**Errors are data, not classes.** Plain frozen objects with no prototype chain. `JSON.stringify` just works — no `stack` property eating up your logs, no `instanceof` checks that break across package boundaries. This matters anywhere errors cross a serialization boundary: Web Workers, server actions, sync engines, IPC. The error you create is the error that arrives.
|
|
208
71
|
|
|
209
|
-
|
|
210
|
-
const { data, error } = await someOperation();
|
|
72
|
+
**Every factory returns `Err<...>` directly.** No wrapping step. Return it from a `tryAsync` catch handler or as a standalone early return — `if (existing) return UserError.AlreadyExists({ email })`. The Result type flows naturally.
|
|
211
73
|
|
|
212
|
-
|
|
213
|
-
// Handle error with full type safety
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
74
|
+
**Discriminated unions for free.** `switch (error.name)` gives you full TypeScript narrowing. No `instanceof`, no type predicates, no manual union types. Add a new variant and every consumer that switches gets a compile error until they handle it.
|
|
216
75
|
|
|
217
|
-
|
|
218
|
-
```
|
|
76
|
+
## Wrapping unsafe code
|
|
219
77
|
|
|
220
|
-
|
|
78
|
+
`trySync` and `tryAsync` turn throwing operations into `Result` types. The `catch` handler receives the raw error and returns an `Err<...>` from your `defineErrors` factories.
|
|
221
79
|
|
|
222
80
|
```typescript
|
|
223
|
-
import {
|
|
81
|
+
import { trySync, tryAsync } from "wellcrafted/result";
|
|
224
82
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}),
|
|
231
|
-
NetworkError: (fields: { url: string }) => ({
|
|
232
|
-
...fields,
|
|
233
|
-
message: `Request to ${fields.url} failed`,
|
|
83
|
+
const JsonError = defineErrors({
|
|
84
|
+
ParseFailed: ({ input, cause }: { input: string; cause: unknown }) => ({
|
|
85
|
+
message: `Invalid JSON: ${extractErrorMessage(cause)}`,
|
|
86
|
+
input: input.slice(0, 100),
|
|
87
|
+
cause,
|
|
234
88
|
}),
|
|
235
89
|
});
|
|
236
|
-
const { ParseErr, NetworkErr } = errors;
|
|
237
90
|
|
|
238
91
|
// Synchronous
|
|
239
|
-
const
|
|
240
|
-
try: () => JSON.parse(
|
|
241
|
-
catch: () =>
|
|
92
|
+
const { data, error } = trySync({
|
|
93
|
+
try: () => JSON.parse(rawInput),
|
|
94
|
+
catch: (cause) => JsonError.ParseFailed({ input: rawInput, cause }),
|
|
242
95
|
});
|
|
243
96
|
|
|
244
97
|
// Asynchronous
|
|
245
|
-
const
|
|
246
|
-
try: () => fetch(url),
|
|
247
|
-
catch: () =>
|
|
98
|
+
const { data, error } = await tryAsync({
|
|
99
|
+
try: () => fetch(url).then((r) => r.json()),
|
|
100
|
+
catch: (cause) => HttpError.Connection({ url, cause }),
|
|
248
101
|
});
|
|
249
102
|
```
|
|
250
103
|
|
|
251
|
-
|
|
104
|
+
When `catch` returns `Ok(fallback)` instead of `Err`, the return type narrows to `Ok<T>` — no error checking needed:
|
|
252
105
|
|
|
253
106
|
```typescript
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const recorderErrors = defineErrors({
|
|
259
|
-
RecorderServiceError: (fields: { currentState?: string; permissions?: string }) => ({
|
|
260
|
-
...fields,
|
|
261
|
-
message: fields.permissions
|
|
262
|
-
? `Missing ${fields.permissions} permission`
|
|
263
|
-
: `Invalid recorder state: ${fields.currentState}`,
|
|
264
|
-
}),
|
|
107
|
+
const { data: parsed } = trySync({
|
|
108
|
+
try: (): unknown => JSON.parse(riskyJson),
|
|
109
|
+
catch: () => Ok([]),
|
|
265
110
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
export function createRecorderService() {
|
|
270
|
-
let isRecording = false;
|
|
271
|
-
let currentBlob: Blob | null = null;
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
async startRecording(): Promise<Result<void, RecorderServiceError>> {
|
|
275
|
-
if (isRecording) {
|
|
276
|
-
return RecorderServiceErr({ currentState: 'recording' });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return tryAsync({
|
|
280
|
-
try: async () => {
|
|
281
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
282
|
-
const recorder = new MediaRecorder(stream);
|
|
283
|
-
// ... recording setup
|
|
284
|
-
isRecording = true;
|
|
285
|
-
},
|
|
286
|
-
catch: () => RecorderServiceErr({ permissions: 'microphone' })
|
|
287
|
-
});
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
async stopRecording(): Promise<Result<Blob, RecorderServiceError>> {
|
|
291
|
-
if (!isRecording) {
|
|
292
|
-
return RecorderServiceErr({ currentState: 'idle' });
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Stop recording and return blob...
|
|
296
|
-
isRecording = false;
|
|
297
|
-
return Ok(currentBlob!);
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
}
|
|
111
|
+
// parsed is always defined — the catch recovered
|
|
112
|
+
```
|
|
301
113
|
|
|
302
|
-
|
|
303
|
-
import { createQueryFactories } from "wellcrafted/query";
|
|
114
|
+
## Composing errors across layers
|
|
304
115
|
|
|
305
|
-
|
|
116
|
+
This is where the pattern pays off. Each layer defines its own error vocabulary; inner errors become `cause` fields, and `extractErrorMessage` formats them inside the factory so call sites stay clean.
|
|
306
117
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// Transform service error to UI-friendly error
|
|
314
|
-
return Err({
|
|
315
|
-
title: "❌ Failed to get recorder state",
|
|
316
|
-
description: error.message,
|
|
317
|
-
action: { type: 'retry' }
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
return Ok(data);
|
|
321
|
-
},
|
|
322
|
-
refetchInterval: 1000, // Poll for state changes
|
|
118
|
+
```typescript
|
|
119
|
+
// Service layer: domain errors wrap raw failures via cause
|
|
120
|
+
const UserServiceError = defineErrors({
|
|
121
|
+
NotFound: ({ userId }: { userId: string }) => ({
|
|
122
|
+
message: `User ${userId} not found`,
|
|
123
|
+
userId,
|
|
323
124
|
}),
|
|
125
|
+
FetchFailed: ({ userId, cause }: { userId: string; cause: unknown }) => ({
|
|
126
|
+
message: `Failed to fetch user ${userId}: ${extractErrorMessage(cause)}`,
|
|
127
|
+
userId,
|
|
128
|
+
cause,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
type UserServiceError = InferErrors<typeof UserServiceError>;
|
|
132
|
+
|
|
133
|
+
async function getUser(userId: string): Promise<Result<User, UserServiceError>> {
|
|
134
|
+
const response = await tryAsync({
|
|
135
|
+
try: () => fetch(`/api/users/${userId}`),
|
|
136
|
+
catch: (cause) => UserServiceError.FetchFailed({ userId, cause }),
|
|
137
|
+
// raw fetch error becomes cause ^^^^^
|
|
138
|
+
});
|
|
139
|
+
if (response.error) return response;
|
|
140
|
+
|
|
141
|
+
if (response.data.status === 404) return UserServiceError.NotFound({ userId });
|
|
142
|
+
|
|
143
|
+
return tryAsync({
|
|
144
|
+
try: () => response.data.json() as Promise<User>,
|
|
145
|
+
catch: (cause) => UserServiceError.FetchFailed({ userId, cause }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
324
148
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const { error } = await services.recorder.startRecording();
|
|
329
|
-
if (error) {
|
|
330
|
-
return Err({
|
|
331
|
-
title: "❌ Failed to start recording",
|
|
332
|
-
description: error.message,
|
|
333
|
-
action: { type: 'more-details', error }
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Optimistically update cache
|
|
338
|
-
queryClient.setQueryData(['recorder', 'state'], 'recording');
|
|
339
|
-
return Ok(undefined);
|
|
340
|
-
}
|
|
341
|
-
})
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
// 3. Component Usage - Choose reactive or imperative based on needs
|
|
345
|
-
// Reactive: Automatic state management (Svelte 5 requires accessor function)
|
|
346
|
-
const recorderState = createQuery(() => recorder.getRecorderState.options);
|
|
149
|
+
// API handler: maps domain errors to HTTP responses
|
|
150
|
+
async function handleGetUser(request: Request, userId: string) {
|
|
151
|
+
const { data, error } = await getUser(userId);
|
|
347
152
|
|
|
348
|
-
// Imperative: Direct execution for event handlers
|
|
349
|
-
async function handleStartRecording() {
|
|
350
|
-
const { error } = await recorder.startRecording.execute();
|
|
351
|
-
// Or shorthand: await recorder.startRecording()
|
|
352
153
|
if (error) {
|
|
353
|
-
|
|
154
|
+
switch (error.name) {
|
|
155
|
+
case "NotFound":
|
|
156
|
+
return Response.json({ error: error.message }, { status: 404 });
|
|
157
|
+
case "FetchFailed":
|
|
158
|
+
return Response.json({ error: error.message }, { status: 502 });
|
|
159
|
+
}
|
|
354
160
|
}
|
|
161
|
+
|
|
162
|
+
return Response.json(data);
|
|
355
163
|
}
|
|
356
164
|
```
|
|
357
165
|
|
|
358
|
-
|
|
166
|
+
The full error chain is JSON-serializable at every level. Log it, send it over the wire, display it in a toast. The structure survives.
|
|
359
167
|
|
|
360
|
-
The
|
|
168
|
+
## The Result type
|
|
361
169
|
|
|
362
|
-
|
|
363
|
-
```typescript
|
|
364
|
-
// ❌ Before: Mutable variable required
|
|
365
|
-
let parsed: unknown;
|
|
366
|
-
try {
|
|
367
|
-
parsed = JSON.parse(riskyJson);
|
|
368
|
-
} catch {
|
|
369
|
-
parsed = [];
|
|
370
|
-
}
|
|
371
|
-
// Now use parsed...
|
|
170
|
+
The foundation is a simple discriminated union:
|
|
372
171
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
try: () => JSON.parse(riskyJson),
|
|
376
|
-
catch: () => Ok([])
|
|
377
|
-
});
|
|
378
|
-
// parsed is always defined and type-safe!
|
|
172
|
+
```typescript
|
|
173
|
+
import { Ok, Err, trySync, tryAsync, type Result } from "wellcrafted/result";
|
|
379
174
|
|
|
380
|
-
|
|
381
|
-
|
|
175
|
+
type Ok<T> = { data: T; error: null };
|
|
176
|
+
type Err<E> = { error: E; data: null };
|
|
177
|
+
type Result<T, E> = Ok<T> | Err<E>;
|
|
382
178
|
```
|
|
383
179
|
|
|
384
|
-
|
|
385
|
-
```typescript
|
|
386
|
-
const parseErrors = defineErrors({
|
|
387
|
-
ParseError: () => ({
|
|
388
|
-
message: "Invalid JSON",
|
|
389
|
-
}),
|
|
390
|
-
});
|
|
391
|
-
const { ParseErr } = parseErrors;
|
|
180
|
+
Check `error` first, and TypeScript narrows `data` automatically:
|
|
392
181
|
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
// mayFail: Result<object, ParseError> - Must check for errors
|
|
399
|
-
if (isOk(mayFail)) {
|
|
400
|
-
console.log(mayFail.data); // Only safe after checking
|
|
182
|
+
```typescript
|
|
183
|
+
const { data, error } = await someOperation();
|
|
184
|
+
if (error) {
|
|
185
|
+
// error is E, data is null
|
|
186
|
+
return;
|
|
401
187
|
}
|
|
188
|
+
// data is T, error is null
|
|
402
189
|
```
|
|
403
190
|
|
|
404
|
-
|
|
405
|
-
```typescript
|
|
406
|
-
const smartParse = trySync({
|
|
407
|
-
try: () => JSON.parse(input),
|
|
408
|
-
catch: () => {
|
|
409
|
-
// Recover from empty input
|
|
410
|
-
if (input.trim() === "") {
|
|
411
|
-
return Ok({}); // Return Ok<T> for fallback
|
|
412
|
-
}
|
|
413
|
-
// Propagate other errors
|
|
414
|
-
return ParseErr();
|
|
415
|
-
}
|
|
416
|
-
});
|
|
417
|
-
// smartParse: Result<object, ParseError> - Mixed handling = Result type
|
|
418
|
-
```
|
|
191
|
+
## Also in the box
|
|
419
192
|
|
|
420
|
-
|
|
193
|
+
### Brand Types
|
|
421
194
|
|
|
422
|
-
|
|
195
|
+
Create distinct types from primitives so TypeScript catches mix-ups at compile time. Zero runtime footprint — purely a type utility.
|
|
423
196
|
|
|
424
|
-
|
|
197
|
+
```typescript
|
|
198
|
+
import type { Brand } from "wellcrafted/brand";
|
|
425
199
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
3. **No Type Safety**: TypeScript can't help with `catch (error)` blocks
|
|
429
|
-
4. **Inconsistent**: Libraries throw different things (strings, errors, objects, undefined)
|
|
200
|
+
type UserId = string & Brand<"UserId">;
|
|
201
|
+
type OrderId = string & Brand<"OrderId">;
|
|
430
202
|
|
|
431
|
-
|
|
432
|
-
- **Explicit** in function signatures
|
|
433
|
-
- **Serializable** across all boundaries
|
|
434
|
-
- **Type-safe** with full TypeScript support
|
|
435
|
-
- **Consistent** with structured error objects
|
|
203
|
+
function getUser(id: UserId) { /* ... */ }
|
|
436
204
|
|
|
437
|
-
|
|
205
|
+
const userId = "abc" as UserId;
|
|
206
|
+
const orderId = "xyz" as OrderId;
|
|
207
|
+
getUser(userId); // compiles
|
|
208
|
+
getUser(orderId); // type error
|
|
209
|
+
```
|
|
438
210
|
|
|
439
|
-
|
|
211
|
+
### Query Integration
|
|
440
212
|
|
|
441
|
-
|
|
213
|
+
TanStack Query factories with a dual interface: `.options` for reactive components, callable for imperative use in event handlers.
|
|
442
214
|
|
|
443
215
|
```typescript
|
|
444
|
-
import {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
message: fields.isRecording ? "Already recording" : "Not currently recording",
|
|
452
|
-
}),
|
|
216
|
+
import { createQueryFactories } from "wellcrafted/query";
|
|
217
|
+
|
|
218
|
+
const { defineQuery, defineMutation } = createQueryFactories(queryClient);
|
|
219
|
+
|
|
220
|
+
const userQuery = defineQuery({
|
|
221
|
+
queryKey: ["users", userId],
|
|
222
|
+
queryFn: () => getUser(userId), // returns Result<User, UserError>
|
|
453
223
|
});
|
|
454
|
-
const { RecorderServiceError, RecorderServiceErr } = recorderErrors;
|
|
455
|
-
type RecorderServiceError = InferError<typeof recorderErrors, "RecorderServiceError">;
|
|
456
|
-
|
|
457
|
-
// 2. Create service with factory function
|
|
458
|
-
export function createRecorderService() {
|
|
459
|
-
// Private state in closure
|
|
460
|
-
let isRecording = false;
|
|
461
|
-
|
|
462
|
-
// Return object with methods
|
|
463
|
-
return {
|
|
464
|
-
startRecording(): Result<void, RecorderServiceError> {
|
|
465
|
-
if (isRecording) {
|
|
466
|
-
return RecorderServiceErr({ isRecording });
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
isRecording = true;
|
|
470
|
-
return Ok(undefined);
|
|
471
|
-
},
|
|
472
|
-
|
|
473
|
-
stopRecording(): Result<Blob, RecorderServiceError> {
|
|
474
|
-
if (!isRecording) {
|
|
475
|
-
return RecorderServiceErr({ isRecording });
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
isRecording = false;
|
|
479
|
-
return Ok(new Blob(["audio data"]));
|
|
480
|
-
}
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
224
|
|
|
484
|
-
//
|
|
485
|
-
|
|
225
|
+
// Reactive — pass to useQuery (React) or createQuery (Svelte)
|
|
226
|
+
const query = createQuery(() => userQuery.options);
|
|
486
227
|
|
|
487
|
-
//
|
|
488
|
-
|
|
228
|
+
// Imperative — direct execution for event handlers
|
|
229
|
+
const { data, error } = await userQuery.fetch();
|
|
489
230
|
```
|
|
490
231
|
|
|
491
|
-
|
|
232
|
+
## Comparison
|
|
492
233
|
|
|
493
|
-
|
|
234
|
+
| | wellcrafted | neverthrow | better-result | fp-ts | Effect |
|
|
235
|
+
|---|---|---|---|---|---|
|
|
236
|
+
| Error definition | `defineErrors` factories | Bring your own | `TaggedError` classes | Bring your own | Class-based with `_tag` |
|
|
237
|
+
| Error shape | Plain frozen objects | Any type | Class instances | Any type | Class instances |
|
|
238
|
+
| Composition | Manual `if (error)` | `.map().andThen()` | `Result.gen()` generators | Pipe operators | `yield*` generators |
|
|
239
|
+
| Bundle size | < 2KB | ~5KB | ~2KB | ~30KB | ~50KB |
|
|
240
|
+
| Syntax | async/await | Method chains | Method chains + generators | Pipe operators | Generators |
|
|
494
241
|
|
|
495
|
-
|
|
496
|
-
// types.ts - shared interface
|
|
497
|
-
export type FileService = {
|
|
498
|
-
readFile(path: string): Promise<Result<string, FileServiceError>>;
|
|
499
|
-
writeFile(path: string, content: string): Promise<Result<void, FileServiceError>>;
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
// desktop.ts
|
|
503
|
-
export function createFileServiceDesktop(): FileService {
|
|
504
|
-
return {
|
|
505
|
-
async readFile(path) {
|
|
506
|
-
// Desktop implementation using Node.js APIs
|
|
507
|
-
},
|
|
508
|
-
async writeFile(path, content) {
|
|
509
|
-
// Desktop implementation
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
}
|
|
242
|
+
Every Result library gives you a container. wellcrafted gives you what goes inside it — then gets out of the way.
|
|
513
243
|
|
|
514
|
-
|
|
515
|
-
export function createFileServiceWeb(): FileService {
|
|
516
|
-
return {
|
|
517
|
-
async readFile(path) {
|
|
518
|
-
// Web implementation using File API
|
|
519
|
-
},
|
|
520
|
-
async writeFile(path, content) {
|
|
521
|
-
// Web implementation
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
|
-
}
|
|
244
|
+
## Philosophy
|
|
525
245
|
|
|
526
|
-
|
|
527
|
-
export const FileServiceLive = typeof window !== 'undefined'
|
|
528
|
-
? createFileServiceWeb()
|
|
529
|
-
: createFileServiceDesktop();
|
|
530
|
-
```
|
|
246
|
+
wellcrafted is deliberately idiomatic to JavaScript. The `{ data, error }` shape isn't novel — it's the same pattern used by Supabase, SvelteKit load functions, and TanStack Query. We chose it because it's already familiar, already destructurable, and requires zero new mental models.
|
|
531
247
|
|
|
532
|
-
|
|
248
|
+
The same principle applies throughout: async/await instead of generators, `switch` instead of `.match()`, plain objects instead of class hierarchies. The best abstractions are the ones your team already knows. wellcrafted adds type-safe error definition on top of patterns that JavaScript developers use every day — it doesn't ask you to learn a new programming paradigm to handle errors.
|
|
533
249
|
|
|
534
|
-
|
|
535
|
-
<summary><b>API Route Handler</b></summary>
|
|
250
|
+
## API Reference
|
|
536
251
|
|
|
537
|
-
|
|
538
|
-
export async function GET(request: Request) {
|
|
539
|
-
const result = await userService.getUser(params.id);
|
|
540
|
-
|
|
541
|
-
if (result.error) {
|
|
542
|
-
switch (result.error.name) {
|
|
543
|
-
case "UserNotFoundError":
|
|
544
|
-
return new Response("Not found", { status: 404 });
|
|
545
|
-
case "DatabaseError":
|
|
546
|
-
return new Response("Server error", { status: 500 });
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return Response.json(result.data);
|
|
551
|
-
}
|
|
552
|
-
```
|
|
553
|
-
</details>
|
|
252
|
+
### Error functions
|
|
554
253
|
|
|
555
|
-
|
|
556
|
-
|
|
254
|
+
- **`defineErrors(config)`** — define multiple error factories in a single call. Each key becomes a variant; the value is a factory returning `{ message, ...fields }`. Every factory returns `Err<...>` directly.
|
|
255
|
+
- **`extractErrorMessage(error)`** — extract a readable string from any `unknown` error value.
|
|
557
256
|
|
|
558
|
-
|
|
559
|
-
import { defineErrors, type InferError } from "wellcrafted/error";
|
|
257
|
+
### Error types
|
|
560
258
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
...fields,
|
|
564
|
-
message: `Validation failed for: ${Object.keys(fields.fields).join(", ")}`,
|
|
565
|
-
}),
|
|
566
|
-
});
|
|
567
|
-
const { FormErr } = formErrors;
|
|
568
|
-
type FormError = InferError<typeof formErrors, "FormError">;
|
|
259
|
+
- **`InferErrors<T>`** — extract union of all error types from a `defineErrors` return value.
|
|
260
|
+
- **`InferError<T>`** — extract a single variant's error type from one factory.
|
|
569
261
|
|
|
570
|
-
|
|
571
|
-
const errors: Record<string, string[]> = {};
|
|
262
|
+
### Result functions
|
|
572
263
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
264
|
+
- **`Ok(data)`** — create a success result
|
|
265
|
+
- **`Err(error)`** — create a failure result
|
|
266
|
+
- **`trySync({ try, catch })`** — wrap a synchronous throwing operation
|
|
267
|
+
- **`tryAsync({ try, catch })`** — wrap an async throwing operation
|
|
268
|
+
- **`isOk(result)` / `isErr(result)`** — type guards
|
|
269
|
+
- **`unwrap(result)`** — extract data or throw error
|
|
270
|
+
- **`resolve(value)`** — handle values that may or may not be Results
|
|
271
|
+
- **`partitionResults(results)`** — split an array of Results into separate ok/err arrays
|
|
576
272
|
|
|
577
|
-
|
|
578
|
-
return FormErr({ fields: errors });
|
|
579
|
-
}
|
|
273
|
+
### Query functions
|
|
580
274
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
</details>
|
|
275
|
+
- **`createQueryFactories(client)`** — create query/mutation factories for TanStack Query
|
|
276
|
+
- **`defineQuery(options)`** — define a query with dual interface (`.options` for reactive, callable for imperative)
|
|
277
|
+
- **`defineMutation(options)`** — define a mutation with dual interface
|
|
585
278
|
|
|
586
|
-
|
|
587
|
-
<summary><b>React Hook</b></summary>
|
|
279
|
+
### Standard Schema
|
|
588
280
|
|
|
589
|
-
|
|
590
|
-
function useUser(id: number) {
|
|
591
|
-
const [state, setState] = useState<{
|
|
592
|
-
loading: boolean;
|
|
593
|
-
user?: User;
|
|
594
|
-
error?: ApiError;
|
|
595
|
-
}>({ loading: true });
|
|
596
|
-
|
|
597
|
-
useEffect(() => {
|
|
598
|
-
fetchUser(id).then(result => {
|
|
599
|
-
if (result.error) {
|
|
600
|
-
setState({ loading: false, error: result.error });
|
|
601
|
-
} else {
|
|
602
|
-
setState({ loading: false, user: result.data });
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
}, [id]);
|
|
606
|
-
|
|
607
|
-
return state;
|
|
608
|
-
}
|
|
609
|
-
```
|
|
610
|
-
</details>
|
|
281
|
+
- **`ResultSchema(dataSchema, errorSchema)`** — [Standard Schema](https://github.com/standard-schema/standard-schema) wrapper for Result types, interoperable with any validator that supports the spec.
|
|
611
282
|
|
|
612
|
-
|
|
283
|
+
### Other types
|
|
613
284
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
| **Bundle Size** | < 2KB | ~30KB | ~50KB | ~5KB |
|
|
617
|
-
| **Learning Curve** | Minimal | Steep | Steep | Moderate |
|
|
618
|
-
| **Syntax** | Native async/await | Pipe operators | Generators | Method chains |
|
|
619
|
-
| **Type Safety** | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
|
|
620
|
-
| **Serializable Errors** | ✅ Built-in | ❌ Classes | ❌ Classes | ❌ Classes |
|
|
621
|
-
| **Runtime Overhead** | Zero | Minimal | Moderate | Minimal |
|
|
285
|
+
- **`Result<T, E>`** — union of `Ok<T> | Err<E>`
|
|
286
|
+
- **`Brand<T, B>`** — branded type wrapper for distinct primitives
|
|
622
287
|
|
|
623
|
-
##
|
|
288
|
+
## AI Agent Skills
|
|
624
289
|
|
|
625
|
-
|
|
290
|
+
If you use an AI coding agent (Claude Code, Cursor, etc.), teach it how to use wellcrafted correctly:
|
|
626
291
|
|
|
627
|
-
|
|
292
|
+
```bash
|
|
293
|
+
npx skills add wellcrafted-dev/wellcrafted
|
|
294
|
+
```
|
|
628
295
|
|
|
629
|
-
|
|
630
|
-
- **`Ok(data)`** - Create success result
|
|
631
|
-
- **`Err(error)`** - Create failure result
|
|
632
|
-
- **`isOk(result)`** - Type guard for success
|
|
633
|
-
- **`isErr(result)`** - Type guard for failure
|
|
634
|
-
- **`trySync(options)`** - Wrap throwing function
|
|
635
|
-
- **`tryAsync(options)`** - Wrap async function
|
|
636
|
-
- **`unwrap(result)`** - Extract data or throw error
|
|
637
|
-
- **`resolve(value)`** - Handle values that may or may not be Results
|
|
638
|
-
- **`partitionResults(results)`** - Split array into oks/errs
|
|
639
|
-
|
|
640
|
-
### Query Functions
|
|
641
|
-
- **`createQueryFactories(client)`** - Create query/mutation factories for TanStack Query
|
|
642
|
-
- **`defineQuery(options)`** - Define a query with dual interface (`.options` + callable/`.ensure()`/`.fetch()`)
|
|
643
|
-
- **`defineMutation(options)`** - Define a mutation with dual interface (`.options` + callable/`.execute()`)
|
|
644
|
-
|
|
645
|
-
### Error Functions
|
|
646
|
-
- **`defineErrors(definitions)`** - Define multiple error factories in a single declaration
|
|
647
|
-
- Each key becomes an error name; the value is a factory function returning fields + `message`
|
|
648
|
-
- Returns `{ErrorName}` (plain error) and `{ErrorName}Err` (Err-wrapped) for each key
|
|
649
|
-
- Factory functions receive typed fields and return `{ ...fields, message }`
|
|
650
|
-
- No-field errors use `() => ({ message: '...' })`
|
|
651
|
-
- `name` is a reserved key — prevented at compile time
|
|
652
|
-
- **`extractErrorMessage(error)`** - Extract readable message from unknown error
|
|
653
|
-
|
|
654
|
-
### Types
|
|
655
|
-
- **`Result<T, E>`** - Union of Ok<T> | Err<E>
|
|
656
|
-
- **`Ok<T>`** - Success result type
|
|
657
|
-
- **`Err<E>`** - Error result type
|
|
658
|
-
- **`InferError<TErrors, TName>`** - Extract error type from `defineErrors` result
|
|
659
|
-
- **`Brand<T, B>`** - Branded type wrapper
|
|
660
|
-
- **`ExtractOkFromResult<R>`** - Extract Ok variant from Result union
|
|
661
|
-
- **`ExtractErrFromResult<R>`** - Extract Err variant from Result union
|
|
662
|
-
- **`UnwrapOk<R>`** - Extract success value type from Result
|
|
663
|
-
- **`UnwrapErr<R>`** - Extract error value type from Result
|
|
296
|
+
This installs 5 skills that teach your agent the patterns, anti-patterns, and API conventions:
|
|
664
297
|
|
|
665
|
-
|
|
298
|
+
| Skill | What it teaches |
|
|
299
|
+
| --- | --- |
|
|
300
|
+
| `define-errors` | `defineErrors` variants, `extractErrorMessage`, `InferErrors`/`InferError` type extraction |
|
|
301
|
+
| `result-types` | `Ok`, `Err`, `trySync`/`tryAsync`, the `{ data, error }` destructuring pattern |
|
|
302
|
+
| `query-factories` | `createQueryFactories`, `defineQuery`/`defineMutation`, dual interface (reactive + imperative) |
|
|
303
|
+
| `branded-types` | `Brand<T>`, brand constructor pattern, when to add runtime validation |
|
|
304
|
+
| `patterns` | Architectural style guide: control flow, factory composition, service layers, error composition |
|
|
666
305
|
|
|
667
|
-
|
|
306
|
+
Skills work with any agent that supports [`npx skills`](https://www.npmjs.com/package/skills). Install once, update with `npx skills update`.
|
|
668
307
|
|
|
669
|
-
Wellcrafted shares AI agent skills (`.agents/skills/`, `.claude/skills/`) with the [Epicenter](https://github.com/EpicenterHQ/epicenter) repo via relative symlinks. Epicenter is the source of truth for skill definitions — they're authored and maintained there, and wellcrafted consumes them to stay in sync.
|
|
670
308
|
|
|
671
|
-
|
|
309
|
+
## Development Setup
|
|
672
310
|
|
|
673
|
-
|
|
674
|
-
Code/
|
|
675
|
-
├── epicenter/ # Source of truth for skills
|
|
676
|
-
│ └── .agents/skills/
|
|
677
|
-
└── wellcrafted/ # Symlinks to epicenter
|
|
678
|
-
├── .agents/skills/<name> → ../../../epicenter/.agents/skills/<name>
|
|
679
|
-
└── .claude/skills/<name> → ../../.agents/skills/<name>
|
|
680
|
-
```
|
|
311
|
+
### AI Agent Skills
|
|
681
312
|
|
|
682
|
-
|
|
313
|
+
AI agent skills are managed via [`npx skills`](https://www.npmjs.com/package/skills), sourced from [Epicenter](https://github.com/EpicenterHQ/epicenter). Only skills relevant to wellcrafted's domain are installed.
|
|
683
314
|
|
|
684
315
|
```bash
|
|
685
|
-
|
|
686
|
-
|
|
316
|
+
# Install skills (already committed, but can be refreshed)
|
|
317
|
+
npx skills add EpicenterHQ/epicenter --skill error-handling --skill define-errors -a claude-code -y
|
|
318
|
+
|
|
319
|
+
# Update all installed skills
|
|
320
|
+
npx skills update
|
|
321
|
+
|
|
322
|
+
# List installed skills
|
|
323
|
+
npx skills list
|
|
687
324
|
```
|
|
688
325
|
|
|
689
326
|
## License
|
|
690
327
|
|
|
691
328
|
MIT
|
|
692
|
-
|
|
693
|
-
---
|
|
694
|
-
|
|
695
|
-
Made with ❤️ by developers who believe error handling should be delightful.
|