wellcrafted 0.15.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 +718 -0
- package/dist/index.d.ts +509 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +385 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
# wellcrafted
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/wellcrafted)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://bundlephobia.com/package/wellcrafted)
|
|
7
|
+
|
|
8
|
+
*Delightful TypeScript utilities for elegant, type-safe applications*
|
|
9
|
+
|
|
10
|
+
This library provides a robust, Rust-inspired `Result` type, elegant brand types, and a lightweight, serializable error handling system for TypeScript. It's designed to help you write more predictable, type-safe, and composable code by making error handling an explicit part of your function signatures.
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
15
|
+
- [Core Idea: The Result Type](#core-idea-the-result-type)
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Handling Operation Outcomes](#handling-operation-outcomes)
|
|
18
|
+
- [Understanding TaggedError](#understanding-taggederror)
|
|
19
|
+
- [Basic Usage](#basic-usage)
|
|
20
|
+
- [Wrapping Functions That Throw](#wrapping-functions-that-throw)
|
|
21
|
+
- [API Reference](#api-reference)
|
|
22
|
+
- [Design Philosophy](#design-philosophy)
|
|
23
|
+
- [FAQ](#faq)
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
**30-second example:** Transform throwing async operations into type-safe Results.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install wellcrafted
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { tryAsync } from "wellcrafted/result";
|
|
35
|
+
import { type TaggedError } from "wellcrafted/error";
|
|
36
|
+
import * as fs from 'fs/promises';
|
|
37
|
+
|
|
38
|
+
type FileError = TaggedError<"FileError">;
|
|
39
|
+
|
|
40
|
+
async function readConfig(path: string) {
|
|
41
|
+
return tryAsync<string, FileError>({
|
|
42
|
+
try: async () => {
|
|
43
|
+
const content = await fs.readFile(path, 'utf-8');
|
|
44
|
+
return content;
|
|
45
|
+
},
|
|
46
|
+
mapError: (error) => ({
|
|
47
|
+
name: "FileError",
|
|
48
|
+
message: "Failed to read configuration file",
|
|
49
|
+
context: { path },
|
|
50
|
+
cause: error
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle the result
|
|
56
|
+
const { data, error } = await readConfig('./config.json');
|
|
57
|
+
|
|
58
|
+
if (error) {
|
|
59
|
+
console.error(`${error.name}: ${error.message}`);
|
|
60
|
+
console.log("Context:", error.context);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log("Config loaded:", data); // TypeScript knows data is safe here
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**What just happened?** Instead of letting file operations throw unpredictable errors, `tryAsync` wraps them in a `Result` type that makes success and failure explicit in your function signatures. No more unhandled exceptions!
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Core Idea: The Result Type
|
|
72
|
+
|
|
73
|
+
> **💡 TL;DR:** Replace `throw new Error()` with `return Err()` to make errors visible in your function signatures.
|
|
74
|
+
|
|
75
|
+
JavaScript's traditional error handling, based on `try...catch` and throwing `Error` objects, has two major drawbacks for modern application development:
|
|
76
|
+
1. **It's not type-safe**: A function signature `function doSomething(): User` doesn't tell you that it might throw a `NetworkError` or a `ValidationError`. Errors are invisible until they strike at runtime.
|
|
77
|
+
2. **It's not serialization-friendly**: `Error` instances lose their prototype chain when crossing serialization boundaries (JSON.stringify/parse, network requests, worker threads), breaking `instanceof` checks.
|
|
78
|
+
|
|
79
|
+
This library solves these problems with the `Result<T, E>` type. Instead of throwing, functions return a `Result` object that explicitly represents either a success or a failure.
|
|
80
|
+
|
|
81
|
+
A `Result` is a union of two "variants":
|
|
82
|
+
- **`Ok<T>`**: Represents a successful outcome, containing a `data` field with the success value. In this variant, the `error` property is always `null`.
|
|
83
|
+
- **`Err<E>`**: Represents a failure outcome, containing an `error` field with the error value. In this variant, the `data` property is always `null`.
|
|
84
|
+
|
|
85
|
+
This structure allows TypeScript's control-flow analysis to act as if it's a **discriminated union**. By checking if `result.error === null`, TypeScript knows it must be an `Ok` variant and can safely access `result.data`. This makes error handling explicit, type-safe, and predictable.
|
|
86
|
+
|
|
87
|
+
### Anatomy of a Result Type
|
|
88
|
+
|
|
89
|
+
Here's the complete TypeScript implementation - it's simpler than you might think:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// The two possible outcomes
|
|
93
|
+
export type Ok<T> = { data: T; error: null };
|
|
94
|
+
export type Err<E> = { error: E; data: null };
|
|
95
|
+
|
|
96
|
+
// Result is just a union of these two types
|
|
97
|
+
export type Result<T, E> = Ok<T> | Err<E>;
|
|
98
|
+
|
|
99
|
+
// Helper functions to create each variant
|
|
100
|
+
export const Ok = <T>(data: T): Ok<T> => ({ data, error: null });
|
|
101
|
+
export const Err = <E>(error: E): Err<E> => ({ error, data: null });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**That's it!** The entire foundation is built on this elegant simplicity:
|
|
105
|
+
|
|
106
|
+
- **`Ok<T>`** always has `data: T` and `error: null`
|
|
107
|
+
- **`Err<E>`** always has `error: E` and `data: null`
|
|
108
|
+
- **`Result<T, E>`** is simply `Ok<T> | Err<E>`
|
|
109
|
+
|
|
110
|
+
This design creates a **discriminated union** where the `error` (or `data`) property acts as the discriminant (with literal types `null` vs non-null), allowing TypeScript to automatically narrow types:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
function handleResult<T, E>(result: Result<T, E>) {
|
|
114
|
+
if (result.error === null) {
|
|
115
|
+
// TypeScript knows this is Ok<T>
|
|
116
|
+
console.log(result.data); // ✅ data is type T
|
|
117
|
+
// console.log(result.error); // ❌ TypeScript knows this is null
|
|
118
|
+
} else {
|
|
119
|
+
// TypeScript knows this is Err<E>
|
|
120
|
+
console.log(result.error); // ✅ error is type E
|
|
121
|
+
// console.log(result.data); // ❌ TypeScript knows this is null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The beauty is in the transparency - you can see exactly how it works under the hood, yet it provides powerful type safety and ergonomics.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Installation
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm install wellcrafted
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Handling Operation Outcomes
|
|
139
|
+
|
|
140
|
+
Once you have a `Result`, there are two main patterns for working with it. Choose the pattern that best fits your preference for code style and the specific context of your code.
|
|
141
|
+
|
|
142
|
+
### Pattern 1: Destructuring (Preferred)
|
|
143
|
+
|
|
144
|
+
This pattern will feel familiar to developers working with modern libraries like Supabase or Astro Actions. You can destructure the `data` and `error` properties directly from the result object and use a simple conditional check on the `error` property.
|
|
145
|
+
|
|
146
|
+
This approach is often cleaner and more direct for handling the two possible outcomes, as it gives you immediate access to the inner `data` and `error` values.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const { data, error } = someOperation();
|
|
150
|
+
|
|
151
|
+
if (error) {
|
|
152
|
+
// `error` holds the inner error value from the Err variant.
|
|
153
|
+
console.error(`An error occurred: ${error.message}`);
|
|
154
|
+
return; // Or handle the error appropriately
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// If `error` is null, `data` holds the inner success value from the Ok variant.
|
|
158
|
+
// In most modern TypeScript setups, `data` will be correctly inferred as the success type.
|
|
159
|
+
console.log(`The result is: ${data}`);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Pattern 2: Using Type Guards
|
|
163
|
+
|
|
164
|
+
In some complex scenarios or with certain TypeScript configurations, the compiler might not be able to perfectly infer the relationship between `data` and `error` when they are destructured into separate variables. In these cases, using the `isOk()` and `isErr()` type guards is a more robust solution. TypeScript's control flow analysis is designed to work flawlessly with this pattern, guaranteeing type safety within each conditional block.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { isOk, isErr } from "wellcrafted/result";
|
|
168
|
+
|
|
169
|
+
const result = someOperation();
|
|
170
|
+
|
|
171
|
+
if (isErr(result)) {
|
|
172
|
+
// TypeScript *guarantees* that `result` is `Err<E>` here.
|
|
173
|
+
// The `result.data` property is `null`.
|
|
174
|
+
// The `result.error` property contains the error value.
|
|
175
|
+
const errorValue = result.error;
|
|
176
|
+
console.error(errorValue);
|
|
177
|
+
|
|
178
|
+
} else {
|
|
179
|
+
// If it's not an error, it must be a success.
|
|
180
|
+
// TypeScript *guarantees* that `result` is `Ok<T>` here.
|
|
181
|
+
// The `result.error` property is `null`.
|
|
182
|
+
// The `result.data` property contains the success value.
|
|
183
|
+
const successValue = result.data;
|
|
184
|
+
console.log(successValue);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
> **When to use Type Guards:** While destructuring is preferred for its simplicity, reach for `isOk()` and `isErr()` whenever you notice that TypeScript isn't correctly narrowing the type of `data` after an error check. This ensures your code remains fully type-safe without needing manual type assertions.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Understanding TaggedError
|
|
193
|
+
|
|
194
|
+
This library promotes a **serializable, type-safe error system** using plain objects instead of JavaScript's `Error` class. The foundation of this system is the `TaggedError` type.
|
|
195
|
+
|
|
196
|
+
### Why Plain Objects for Errors?
|
|
197
|
+
|
|
198
|
+
1. **Serialization-First**: Plain objects can be easily serialized to JSON (`JSON.stringify`) and transmitted across boundaries (network APIs, IPC, web workers) without losing information, unlike `Error` classes.
|
|
199
|
+
2. **Type Safety**: Use TypeScript's literal and union types to create a discriminated union of possible errors, allowing `switch` statements to safely narrow down error types.
|
|
200
|
+
3. **Lightweight**: Avoids the overhead of class instantiation and the complexities of `instanceof` checks.
|
|
201
|
+
4. **Structured Context**: Easily enforce that all errors carry structured, machine-readable context.
|
|
202
|
+
|
|
203
|
+
Every `TaggedError` contains four essential properties that work together to create a robust, debuggable error system:
|
|
204
|
+
|
|
205
|
+
### The Four Properties
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
type TaggedError<T extends string> = {
|
|
209
|
+
readonly name: T; // 1. The discriminant
|
|
210
|
+
message: string; // 2. Human-readable description
|
|
211
|
+
context: Record<string, unknown>; // 3. Debugging data
|
|
212
|
+
cause?: unknown; // 4. Root cause (optional)
|
|
213
|
+
};
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### 1. **`name`** - The Discriminant (Tagged Field)
|
|
217
|
+
|
|
218
|
+
This is your error's unique identifier and the key to pattern matching. Use it in `if` statements and `switch` statements to handle different error types:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
type ValidationError = TaggedError<"ValidationError">;
|
|
222
|
+
type NetworkError = TaggedError<"NetworkError">;
|
|
223
|
+
type FileError = TaggedError<"FileError">;
|
|
224
|
+
|
|
225
|
+
function handleError(error: ValidationError | NetworkError | FileError) {
|
|
226
|
+
switch (error.name) {
|
|
227
|
+
case "ValidationError":
|
|
228
|
+
// TypeScript knows this is ValidationError
|
|
229
|
+
console.log("Invalid input:", error.context);
|
|
230
|
+
break;
|
|
231
|
+
case "NetworkError":
|
|
232
|
+
// TypeScript knows this is NetworkError
|
|
233
|
+
console.log("Network failed:", error.message);
|
|
234
|
+
break;
|
|
235
|
+
case "FileError":
|
|
236
|
+
// TypeScript knows this is FileError
|
|
237
|
+
console.log("File issue:", error.context);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### 2. **`message`** - Human-Readable Text
|
|
244
|
+
|
|
245
|
+
Pure text description that explains what went wrong. Keep it clear and actionable:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
return Err({
|
|
249
|
+
name: "ValidationError",
|
|
250
|
+
message: "Email address must contain an @ symbol", // Clear, specific
|
|
251
|
+
context: { email: userInput },
|
|
252
|
+
cause: undefined
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### 3. **`context`** - Debugging Data
|
|
257
|
+
|
|
258
|
+
Include function inputs and any data that would help debug the issue. This is invaluable for logging and troubleshooting:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
function processUser(id: number, options: UserOptions): Result<User, ProcessError> {
|
|
262
|
+
return Err({
|
|
263
|
+
name: "ProcessError",
|
|
264
|
+
message: "User processing failed",
|
|
265
|
+
context: {
|
|
266
|
+
userId: id, // Function input
|
|
267
|
+
options, // Function input
|
|
268
|
+
timestamp: new Date().toISOString(), // Additional context
|
|
269
|
+
retryCount: 3 // Useful debugging info
|
|
270
|
+
},
|
|
271
|
+
cause: undefined
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### 4. **`cause`** - Root Cause Bubbling
|
|
277
|
+
|
|
278
|
+
- **For new errors**: Set `cause: undefined`
|
|
279
|
+
- **For wrapping existing errors**: Pass the original error as `cause`
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// Creating a new error
|
|
283
|
+
return Err({
|
|
284
|
+
name: "ValidationError",
|
|
285
|
+
message: "Invalid user data",
|
|
286
|
+
context: { input },
|
|
287
|
+
cause: undefined // New error, no underlying cause
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Wrapping an existing error
|
|
291
|
+
try {
|
|
292
|
+
await database.save(user);
|
|
293
|
+
} catch (dbError) {
|
|
294
|
+
return Err({
|
|
295
|
+
name: "SaveError",
|
|
296
|
+
message: "Failed to save user",
|
|
297
|
+
context: { userId: user.id },
|
|
298
|
+
cause: dbError // Bubble up the original database error
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Creating Domain-Specific Errors
|
|
304
|
+
|
|
305
|
+
You can define a set of possible errors for a specific domain:
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// Define your specific error types
|
|
309
|
+
export type FileNotFoundError = TaggedError<"FileNotFoundError">;
|
|
310
|
+
export type PermissionDeniedError = TaggedError<"PermissionDeniedError">;
|
|
311
|
+
export type DiskFullError = TaggedError<"DiskFullError">;
|
|
312
|
+
|
|
313
|
+
// Create a union of all possible errors for this domain
|
|
314
|
+
export type FileSystemError = FileNotFoundError | PermissionDeniedError | DiskFullError;
|
|
315
|
+
|
|
316
|
+
// A factory function to create an error
|
|
317
|
+
function createFileNotFoundError(path: string, cause?: unknown): FileNotFoundError {
|
|
318
|
+
return {
|
|
319
|
+
name: "FileNotFoundError",
|
|
320
|
+
message: `The file at path "${path}" was not found.`,
|
|
321
|
+
context: { path },
|
|
322
|
+
cause
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Because `name` is a unique literal type for each error, TypeScript can use it to discriminate between them in a `switch` statement:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
function handleError(error: FileSystemError) {
|
|
331
|
+
switch (error.name) {
|
|
332
|
+
case "FileNotFoundError":
|
|
333
|
+
// TypeScript knows `error` is `FileNotFoundError` here.
|
|
334
|
+
console.error(`Path not found: ${error.context.path}`);
|
|
335
|
+
break;
|
|
336
|
+
case "PermissionDeniedError":
|
|
337
|
+
// TypeScript knows `error` is `PermissionDeniedError` here.
|
|
338
|
+
console.error("Permission was denied.");
|
|
339
|
+
break;
|
|
340
|
+
case "DiskFullError":
|
|
341
|
+
// ...
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Best Practices for Errors
|
|
348
|
+
|
|
349
|
+
#### 1. Include Meaningful Context
|
|
350
|
+
Always include function inputs and other relevant state in the `context` object. This is invaluable for logging and debugging.
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
function createDbError(
|
|
354
|
+
message: string,
|
|
355
|
+
query: string,
|
|
356
|
+
params: unknown[],
|
|
357
|
+
cause: unknown
|
|
358
|
+
): DbError {
|
|
359
|
+
return {
|
|
360
|
+
name: "DbError",
|
|
361
|
+
message,
|
|
362
|
+
context: {
|
|
363
|
+
query,
|
|
364
|
+
params,
|
|
365
|
+
timestamp: new Date().toISOString(),
|
|
366
|
+
},
|
|
367
|
+
cause,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
#### 2. Handle Errors at the Right Level
|
|
373
|
+
Handle or transform errors where you can add more context or make a recovery decision.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
async function initializeApp(): Promise<Result<App, FsError | ValidationError>> {
|
|
377
|
+
const configResult = await readConfig("./config.json");
|
|
378
|
+
|
|
379
|
+
// Propagate the file system error directly if config read fails
|
|
380
|
+
if (isErr(configResult)) {
|
|
381
|
+
return configResult;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// If config is read, but is invalid, return a *different* kind of error
|
|
385
|
+
const validationResult = validateConfig(configResult.data);
|
|
386
|
+
if (isErr(validationResult)) {
|
|
387
|
+
return validationResult;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return Ok(new App(validationResult.data));
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
This structure makes errors **trackable**, **debuggable**, and **type-safe** while maintaining clean separation between different failure modes in your application.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Basic Usage
|
|
399
|
+
|
|
400
|
+
Now that you understand how to handle Result values and the TaggedError structure, let's see complete examples that combine both concepts:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { Result, Ok, Err, isOk } from "wellcrafted/result";
|
|
404
|
+
import { type TaggedError } from "wellcrafted/error";
|
|
405
|
+
|
|
406
|
+
// --- Example 1: A Safe Division Function ---
|
|
407
|
+
|
|
408
|
+
// 1. Define a specific error for math-related failures
|
|
409
|
+
type MathError = TaggedError<"MathError">;
|
|
410
|
+
|
|
411
|
+
// 2. Create a function that returns a Result with our structured error
|
|
412
|
+
function divide(numerator: number, denominator: number): Result<number, MathError> {
|
|
413
|
+
if (denominator === 0) {
|
|
414
|
+
return Err({
|
|
415
|
+
name: "MathError",
|
|
416
|
+
message: "Cannot divide by zero.",
|
|
417
|
+
context: { numerator, denominator },
|
|
418
|
+
cause: undefined
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return Ok(numerator / denominator);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 3. Handle the result
|
|
425
|
+
const divisionResult = divide(10, 0);
|
|
426
|
+
|
|
427
|
+
if (!isOk(divisionResult)) {
|
|
428
|
+
// `divisionResult.error` is a fully-typed MathError object
|
|
429
|
+
console.error(`Error (${divisionResult.error.name}): ${divisionResult.error.message}`);
|
|
430
|
+
console.log("Context:", divisionResult.error.context); // { numerator: 10, denominator: 0 }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --- Example 2: Parsing a User Object ---
|
|
434
|
+
|
|
435
|
+
// 1. Define a specific error for parsing failures
|
|
436
|
+
type ParseError = TaggedError<"ParseError">;
|
|
437
|
+
|
|
438
|
+
// 2. Create a function that returns a Result with our structured error
|
|
439
|
+
function parseUser(json: string): Result<{ name: string }, ParseError> {
|
|
440
|
+
try {
|
|
441
|
+
const data = JSON.parse(json);
|
|
442
|
+
if (typeof data.name !== "string") {
|
|
443
|
+
return Err({
|
|
444
|
+
name: "ParseError",
|
|
445
|
+
message: "User object must have a name property of type string.",
|
|
446
|
+
context: { receivedValue: data.name },
|
|
447
|
+
cause: undefined
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return Ok(data);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return Err({
|
|
453
|
+
name: "ParseError",
|
|
454
|
+
message: "Invalid JSON provided.",
|
|
455
|
+
context: { rawString: json },
|
|
456
|
+
cause: e,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 3. Handle the result
|
|
462
|
+
const userResult = parseUser('{"name": "Alice"}');
|
|
463
|
+
|
|
464
|
+
if (isOk(userResult)) {
|
|
465
|
+
console.log(`Welcome, ${userResult.data.name}!`);
|
|
466
|
+
} else {
|
|
467
|
+
// `userResult.error` is a fully-typed ParseError object
|
|
468
|
+
console.error(`Error (${userResult.error.name}): ${userResult.error.message}`);
|
|
469
|
+
console.log("Context:", userResult.error.context);
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Wrapping Functions That Throw
|
|
476
|
+
|
|
477
|
+
When integrating with existing code that throws exceptions (like `JSON.parse`, fetch APIs, or database clients), you'll need a way to convert these throwing functions into safe `Result`-returning functions. This library provides `trySync` and `tryAsync` to handle this conversion seamlessly.
|
|
478
|
+
|
|
479
|
+
### Synchronous Operations with `trySync`
|
|
480
|
+
|
|
481
|
+
Use `trySync` for synchronous functions that might throw. You provide the operation and a `mapError` function to transform the caught exception into your desired error type.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { trySync, Result } from "wellcrafted/result";
|
|
485
|
+
import { type TaggedError } from "wellcrafted/error";
|
|
486
|
+
|
|
487
|
+
type ParseError = TaggedError<"ParseError">;
|
|
488
|
+
|
|
489
|
+
function parseJson(raw: string): Result<object, ParseError> {
|
|
490
|
+
return trySync({
|
|
491
|
+
try: () => JSON.parse(raw),
|
|
492
|
+
mapError: (error) => ({
|
|
493
|
+
name: "ParseError",
|
|
494
|
+
message: "Failed to parse JSON",
|
|
495
|
+
context: { raw },
|
|
496
|
+
cause: error
|
|
497
|
+
})
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const result = parseJson('{"key": "value"}'); // Ok<{key: string}>
|
|
502
|
+
const failedResult = parseJson('not json'); // Err<ParseError>
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Asynchronous Operations with `tryAsync`
|
|
506
|
+
|
|
507
|
+
Use `tryAsync` for functions that return a `Promise`. It handles both rejected promises and synchronous throws within the async function.
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
import { tryAsync, Result } from "wellcrafted/result";
|
|
511
|
+
import { type TaggedError } from "wellcrafted/error";
|
|
512
|
+
|
|
513
|
+
type User = { id: number; name: string };
|
|
514
|
+
type NetworkError = TaggedError<"NetworkError">;
|
|
515
|
+
|
|
516
|
+
async function fetchUser(userId: number): Promise<Result<User, NetworkError>> {
|
|
517
|
+
return tryAsync({
|
|
518
|
+
try: async () => {
|
|
519
|
+
const response = await fetch(`https://api.example.com/users/${userId}`);
|
|
520
|
+
if (!response.ok) {
|
|
521
|
+
// You can throw a custom error object
|
|
522
|
+
throw { message: "Request failed", statusCode: response.status };
|
|
523
|
+
}
|
|
524
|
+
return response.json();
|
|
525
|
+
},
|
|
526
|
+
mapError: (error) => ({
|
|
527
|
+
name: "NetworkError",
|
|
528
|
+
message: "Failed to fetch user",
|
|
529
|
+
context: { userId },
|
|
530
|
+
cause: error
|
|
531
|
+
})
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const userResult = await fetchUser(1);
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### Type Safety with Generics
|
|
539
|
+
|
|
540
|
+
When using `trySync` and `tryAsync`, you have two approaches to ensure your `mapError` function returns the correct TaggedError type:
|
|
541
|
+
|
|
542
|
+
#### Approach 1: Explicit Generics (Recommended)
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
async function readConfig(path: string) {
|
|
546
|
+
return tryAsync<string, FileError>({ // 👈 Explicitly specify generics
|
|
547
|
+
try: async () => {
|
|
548
|
+
const content = await fs.readFile(path, 'utf-8');
|
|
549
|
+
return content;
|
|
550
|
+
},
|
|
551
|
+
mapError: (error) => ({
|
|
552
|
+
name: "FileError",
|
|
553
|
+
message: "Failed to read configuration file",
|
|
554
|
+
context: { path },
|
|
555
|
+
cause: error
|
|
556
|
+
})
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
#### Approach 2: Return Type Annotation
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
async function readConfig(path: string) {
|
|
565
|
+
return tryAsync({
|
|
566
|
+
try: async () => {
|
|
567
|
+
const content = await fs.readFile(path, 'utf-8');
|
|
568
|
+
return content;
|
|
569
|
+
},
|
|
570
|
+
mapError: (error): FileError => ({ // 👈 Annotate return type
|
|
571
|
+
name: "FileError",
|
|
572
|
+
message: "Failed to read configuration file",
|
|
573
|
+
context: { path },
|
|
574
|
+
cause: error
|
|
575
|
+
})
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
**Key points:**
|
|
581
|
+
- Both approaches ensure `mapError` returns your exact TaggedError type
|
|
582
|
+
- Avoid using `as const` - always map to proper TaggedError objects
|
|
583
|
+
- Choose explicit generics for clarity, or return type annotation for brevity
|
|
584
|
+
- The important thing is ensuring type safety for your error handling
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## API Reference
|
|
589
|
+
|
|
590
|
+
### Quick Reference Table
|
|
591
|
+
|
|
592
|
+
| Function | Purpose | Example |
|
|
593
|
+
|----------|---------|---------|
|
|
594
|
+
| `Ok(data)` | Create success result | `Ok("hello")` |
|
|
595
|
+
| `Err(error)` | Create failure result | `Err("failed")` |
|
|
596
|
+
| `isOk(result)` | Check if success | `if (isOk(res)) { ... }` |
|
|
597
|
+
| `isErr(result)` | Check if failure | `if (isErr(res)) { ... }` |
|
|
598
|
+
| `trySync()` | Wrap throwing function | `trySync({ try: () => JSON.parse(str) })` |
|
|
599
|
+
| `tryAsync()` | Wrap async throwing function | `tryAsync({ try: () => fetch(url) })` |
|
|
600
|
+
|
|
601
|
+
### Detailed API
|
|
602
|
+
|
|
603
|
+
#### Types
|
|
604
|
+
- **`Result<T, E>`**: The core union type, representing `Ok<T> | Err<E>`.
|
|
605
|
+
- **`Ok<T>`**: Represents a success. Contains `{ data: T; error: null; }`.
|
|
606
|
+
- **`Err<E>`**: Represents a failure. Contains `{ data: null; error: E; }`.
|
|
607
|
+
- **`BaseError` / `TaggedError<T>`**: Helpers for creating a structured error system.
|
|
608
|
+
|
|
609
|
+
#### Core Result Functions
|
|
610
|
+
- **`Ok(data)`**: Creates a success `Result`.
|
|
611
|
+
- **`Err(error)`**: Creates a failure `Result`.
|
|
612
|
+
- **`isOk(result)`**: Type guard. Returns `true` if the result is an `Ok` variant.
|
|
613
|
+
- **`isErr(result)`**: Type guard. Returns `true` if the result is an `Err` variant.
|
|
614
|
+
- **`unwrap(result)`**: Unwraps a `Result`, returning data on `Ok` or throwing error on `Err`.
|
|
615
|
+
- **`resolve(value)`**: Resolves a value that may or may not be a `Result`, returning the final value or throwing on `Err`.
|
|
616
|
+
- **`isResult(value)`**: Type guard. Returns `true` if a value has the shape of a `Result`.
|
|
617
|
+
|
|
618
|
+
#### Async/Sync Wrappers
|
|
619
|
+
- **`trySync({ try, mapError })`**: Wraps a synchronous function that may throw.
|
|
620
|
+
- **`tryAsync({ try, mapError })`**: Wraps an asynchronous function that may throw or reject.
|
|
621
|
+
|
|
622
|
+
#### Error Utilities
|
|
623
|
+
- **`extractErrorMessage(error)`**: Safely extracts a string message from any error value.
|
|
624
|
+
|
|
625
|
+
#### Utility Functions
|
|
626
|
+
- **`partitionResults(results)`**: Partitions an array of Results into separate arrays of `Ok` and `Err` variants.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Design Philosophy
|
|
631
|
+
|
|
632
|
+
This library is built on a set of core principles designed to create a robust, predictable, and developer-friendly experience. Understanding these principles will help you get the most out of the library and see why its API is designed the way it is.
|
|
633
|
+
|
|
634
|
+
### 1. Embrace JavaScript Primitives
|
|
635
|
+
|
|
636
|
+
A fundamental disagreement we have with some otherwise excellent libraries is the idea that JavaScript's core abstractions need to be completely reinvented. While we have immense respect for the power and type-level ingenuity of ecosystems like Effect-TS, we believe the cost of onboarding developers to an entirely new programming paradigm (like generators for async control flow) is too high for most projects.
|
|
637
|
+
|
|
638
|
+
This library is built on the philosophy of leaning into JavaScript's native primitives whenever they are "good enough." We prefer to build on the familiar foundations of `async/await`, `Promise`, and standard union types (`T | null`) because they are already well-understood by the vast majority of TypeScript developers. This drastically reduces the learning curve and makes the library easy to adopt incrementally.
|
|
639
|
+
|
|
640
|
+
We only introduce new abstractions where JavaScript has a clear and significant weakness. In our view, the two biggest pain points in modern TypeScript are:
|
|
641
|
+
1. **Error Handling**: The imperative nature of `try/catch` and the non-serializable, class-based `Error` object.
|
|
642
|
+
2. **Data Validation**: Ensuring that `unknown` data conforms to a known type at runtime.
|
|
643
|
+
|
|
644
|
+
This library provides `Result` to solve the first problem. It intentionally omits an `Option` type because native features like optional chaining (`?.`) and nullish coalescing (`??`) provide excellent and familiar ergonomics for handling optional values.
|
|
645
|
+
|
|
646
|
+
### 2. Prioritize Ergonomics and Pragmatism
|
|
647
|
+
|
|
648
|
+
Flowing from the first principle, our API design prioritizes developer experience. This is most evident in our choice of the `{ data, error }` shape for the `Result` type. The ability to destructure `const { data, error } = ...` is a clean, direct, and pragmatic pattern that is already familiar to developers using popular libraries like Supabase and Astro Actions. We chose this pattern for its superior ergonomics, even if other patterns might be considered more "academically pure."
|
|
649
|
+
|
|
650
|
+
### 3. Lightweight, Zero-Dependency, and Tree-Shakable
|
|
651
|
+
|
|
652
|
+
This library is designed to be as lightweight as possible. It ships with **zero runtime dependencies**, meaning it won't add any extra weight to your `node_modules` folder or your final bundle.
|
|
653
|
+
|
|
654
|
+
Every function is exported as a pure, standalone module, making the entire library **tree-shakable**. If you only use the `Result` type and the `isOk` function, the rest of the library's code won't be included in your application's build.
|
|
655
|
+
|
|
656
|
+
We believe a library should have a focused scope and not be overwhelming. While comprehensive ecosystems like Effect-TS are incredibly powerful, their scope can be daunting. This library aims to solve the specific and critical problem of type-safe error handling without pulling in a large, all-encompassing framework. It's a small tool that does one job well.
|
|
657
|
+
|
|
658
|
+
### 4. Serialization-First
|
|
659
|
+
|
|
660
|
+
A core requirement of this library is that all of its data structures, especially errors, must be reliably serializable. They need to behave identically whether you are passing them between functions, sending them over a network (HTTP), or passing them to a web worker. This is why the library fundamentally avoids classes for its error-handling system and instead promotes plain objects.
|
|
661
|
+
|
|
662
|
+
### 5. Opinionated yet Flexible
|
|
663
|
+
|
|
664
|
+
This library is opinionated in that it provides a clear, recommended path for best practices. We believe that a degree of standardization leads to more maintainable and predictable codebases. However, these opinions are not enforced at a technical level. The core `Result` type is deliberately decoupled from the error system, meaning you are free to use a different error implementation if your project requires it.
|
|
665
|
+
|
|
666
|
+
## Inspirations and Relationship to Effect-TS
|
|
667
|
+
|
|
668
|
+
This library's approach is heavily inspired by the powerful concepts pioneered by the **[Effect-TS](https://github.com/Effect-TS/effect)** ecosystem. Effect has indelibly shaped our thinking on how to structure services, handle errors, and compose applications in a type-safe way.
|
|
669
|
+
|
|
670
|
+
However, this library represents a different set of trade-offs and priorities, based on a few key disagreements with the Effect-TS approach:
|
|
671
|
+
|
|
672
|
+
1. **Familiarity Over Novelty**: While we agree that Promises can be a flawed abstraction, we believe the cost of replacing them entirely is too high for most teams. Effect introduces a new, powerful, but unfamiliar execution model based on generators (`yield`), which requires a significant investment to learn. This library chooses to embrace the familiar patterns of `async/await` and Promises, even with their imperfections, to ensure a gentle learning curve. The goal is to provide 80% of the benefit with 20% of the learning curve.
|
|
673
|
+
|
|
674
|
+
2. **Simplicity and Lightweight Integration**: We aim for this library to be as lightweight as possible, easy to adopt incrementally, and simple to integrate with other tools. It is not an all-encompassing application framework but rather a focused tool to solve the specific problem of `Result`-based error handling.
|
|
675
|
+
|
|
676
|
+
That said, the influence of Effect is clear. Functions like `trySync` and `tryAsync` are directly inspired by similar utilities in Effect. The core difference is that we aim to apply these powerful concepts on top of familiar JavaScript primitives, rather than creating a new ecosystem around them. This philosophy also informs our decision to omit an `Option<T>` type, as we believe that native TypeScript features (`T | null`, optional chaining, and nullish coalescing) are "good enough" and more idiomatic for the majority of use cases.
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
## FAQ
|
|
681
|
+
|
|
682
|
+
### Why `{ data, error }` instead of a boolean flag like `{ ok: boolean, ... }`?
|
|
683
|
+
|
|
684
|
+
Some libraries use a discriminated union with a boolean flag, like `{ ok: true, data: T } | { ok: false, error: E }`. While a valid pattern, we chose the `{ data, error }` shape for two main reasons:
|
|
685
|
+
|
|
686
|
+
1. **Ergonomics and Familiarity**: The destructuring pattern `const { data, error } = operation()` is clean and will feel familiar to developers using modern libraries like Supabase and Astro Actions. It provides immediate access to the inner values without an extra layer of property access. Checking a boolean flag first (`if (result.ok)`) and then accessing the value (`result.data`) is slightly more verbose.
|
|
687
|
+
|
|
688
|
+
2. **Lack of Standardization**: The boolean flag approach isn't standardized. Zod's `.safeParse`, for example, returns `{ success: boolean, ... }`. By adopting the `{ data, error }` pattern, we align with a simple, common, and intuitive structure for handling success and failure states in modern JavaScript.
|
|
689
|
+
|
|
690
|
+
### What's the difference between an `Err` variant and an `error` value?
|
|
691
|
+
|
|
692
|
+
This is a key distinction in the library's terminology:
|
|
693
|
+
|
|
694
|
+
- **`Err<E>` (The Variant/Container)**: This is one of the two possible "shapes" of a `Result` object. It's the wrapper itself, whose structure is `{ data: null, error: E }`. You can think of it as the box that signifies a failure.
|
|
695
|
+
|
|
696
|
+
- **`error` (The Value/Payload)**: This is the actual *value* inside the `Err` container. It is the content of the `error` property on the `Err` object. This is the piece of data that describes what went wrong, and its type is `E`.
|
|
697
|
+
|
|
698
|
+
When you use the `isErr()` type guard, you are checking if a `Result` is the `Err` variant. Once that check passes, you can then access the `.error` property to get the error value.
|
|
699
|
+
|
|
700
|
+
### Why doesn't this library include an `Option<T>` type?
|
|
701
|
+
|
|
702
|
+
An `Option<T>` type (sometimes called `Maybe`) is common in other languages to represent a value that might be missing. However, we've intentionally omitted it because **modern JavaScript and TypeScript already have excellent, first-class support for handling potentially missing values.**
|
|
703
|
+
|
|
704
|
+
A custom `Option<T>` type would add a layer of abstraction that is largely unnecessary. Instead, you can and should use:
|
|
705
|
+
|
|
706
|
+
1. **Union Types with `null`**: Simply type your value as `T | null`. This is the idiomatic way to represent an optional value in TypeScript.
|
|
707
|
+
|
|
708
|
+
2. **Optional Chaining (`?.`)**: Safely access nested properties of an object that might be null or undefined.
|
|
709
|
+
```ts
|
|
710
|
+
const street = user?.address?.street; // Returns undefined if user or address is null/undefined
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
3. **Nullish Coalescing (`??`)**: Provide a default value for a `null` or `undefined` expression.
|
|
714
|
+
```ts
|
|
715
|
+
const displayName = user.name ?? "Guest";
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
These built-in language features provide better ergonomics and are more familiar to JavaScript developers than a custom `Option` type would be. This library focuses on solving for `Result`, where the language does not have a built-in equivalent.
|