Skip to content

Commit effc68b

Browse files
committed
Expand server targets and adopt better-result
1 parent e4fa8ff commit effc68b

33 files changed

Lines changed: 1401 additions & 95 deletions
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
---
2+
name: better-result-adopt
3+
description: Adopt better-result in an existing TypeScript codebase. Use when replacing try/catch, Promise rejection handling, null sentinels, or thrown domain exceptions with typed Result workflows.
4+
references:
5+
- references/tagged-errors.md
6+
---
7+
8+
# better-result Adopt
9+
10+
Adopt `better-result` incrementally in existing codebases without rewriting everything at once.
11+
12+
## When to Use
13+
14+
Use this skill when the user wants to:
15+
16+
- migrate from try/catch to `Result.try` or `Result.tryPromise`
17+
- replace nullable return values with typed `Result<T, E>`
18+
- define domain-specific `TaggedError` types
19+
- refactor nested error handling into `andThen` chains or `Result.gen`
20+
- standardize error handling across a service or module
21+
22+
## Reading Order
23+
24+
| Task | Files to Read |
25+
| -------------------------------------- | ----------------------------- |
26+
| Adopt better-result in a module | This file |
27+
| Define or review error types | `references/tagged-errors.md` |
28+
| Inspect library implementation details | `opensrc/` if present |
29+
30+
## Prerequisites
31+
32+
Before editing code:
33+
34+
1. Confirm `better-result` is already installed in the target project.
35+
2. Check for an `opensrc/` directory. If present, read the package source there for current patterns.
36+
3. Identify the migration scope first: one file, one module, or one boundary layer.
37+
38+
## Migration Strategy
39+
40+
### 1. Start at boundaries
41+
42+
Begin with I/O boundaries and exception-heavy code:
43+
44+
- HTTP clients
45+
- database access
46+
- file system operations
47+
- parsing and validation
48+
- framework adapters
49+
50+
Do not convert the whole codebase at once.
51+
52+
### 2. Classify existing failures
53+
54+
| Category | Examples | Target shape |
55+
| --------------------- | --------------------------- | -------------------------------------------------------------- |
56+
| Domain errors | not found, validation, auth | `TaggedError` + `Result.err` |
57+
| Infrastructure errors | network, DB, file I/O | `Result.tryPromise` + mapped error |
58+
| Programmer defects | bad assumptions, null deref | leave throwing; defects become `Panic` inside Result callbacks |
59+
60+
### 3. Migrate in this order
61+
62+
1. Define error types.
63+
2. Wrap throwing boundaries with `Result.try` / `Result.tryPromise`.
64+
3. Replace null or boolean sentinel returns with `Result`.
65+
4. Refactor call sites to propagate `Result` values.
66+
5. Collapse nested branching into `andThen`, `mapError`, or `Result.gen`.
67+
68+
## Core Transformations
69+
70+
### Try/catch → `Result.try`
71+
72+
```ts
73+
function parseConfig(json: string): Result<Config, ParseError> {
74+
return Result.try({
75+
try: () => JSON.parse(json) as Config,
76+
catch: (cause) => new ParseError({ cause, message: `Parse failed: ${cause}` }),
77+
});
78+
}
79+
```
80+
81+
### Async throws → `Result.tryPromise`
82+
83+
```ts
84+
async function fetchUser(id: string): Promise<Result<User, ApiError | UnhandledException>> {
85+
return Result.tryPromise({
86+
try: async () => {
87+
const res = await fetch(`/api/users/${id}`);
88+
if (!res.ok) throw new ApiError({ status: res.status, message: `API ${res.status}` });
89+
return res.json() as Promise<User>;
90+
},
91+
catch: (cause) => (cause instanceof ApiError ? cause : new UnhandledException({ cause })),
92+
});
93+
}
94+
```
95+
96+
### Null sentinel → `Result`
97+
98+
```ts
99+
function findUser(id: string): Result<User, NotFoundError> {
100+
const user = users.find((candidate) => candidate.id === id);
101+
return user
102+
? Result.ok(user)
103+
: Result.err(new NotFoundError({ id, message: `User ${id} not found` }));
104+
}
105+
```
106+
107+
### Nested flow → `Result.gen`
108+
109+
```ts
110+
async function processOrder(orderId: string): Promise<Result<OrderResult, OrderError>> {
111+
return Result.gen(async function* () {
112+
const order = yield* Result.await(fetchOrder(orderId));
113+
const validated = yield* validateOrder(order);
114+
const result = yield* Result.await(submitOrder(validated));
115+
return Result.ok(result);
116+
});
117+
}
118+
```
119+
120+
## Execution Workflow
121+
122+
1. Audit the target module for `try`, `catch`, `.catch(...)`, `throw`, `null`, `undefined`, and status-flag error handling.
123+
2. Define or update `TaggedError` classes before changing control flow.
124+
3. Convert boundary functions first and change their signatures to `Result<T, E>` or `Promise<Result<T, E>>`.
125+
4. Update immediate callers so they handle or propagate the new `Result`.
126+
5. Where multiple Result-returning steps compose, use `Result.gen` or `andThen`.
127+
6. Preserve error context by keeping `cause`, IDs, messages, and other structured fields.
128+
7. Run tests and add coverage for both success and error paths.
129+
130+
## Completion Criteria
131+
132+
A migration is complete when:
133+
134+
- target functions no longer rely on try/catch for expected domain failures
135+
- nullable or sentinel error returns are replaced with explicit `Result` values
136+
- domain failures use typed `TaggedError` classes
137+
- callers either propagate `Result` or explicitly unwrap/match it
138+
- tests cover at least one success path and one representative error path
139+
140+
## Common Pitfalls
141+
142+
- Over-wrapping everything instead of starting at boundaries
143+
- Losing original failure context when mapping errors
144+
- Mixing `throw`-based and `Result`-based APIs deep in the same flow
145+
- Catching `Panic` instead of fixing the underlying defect
146+
147+
## In This Reference
148+
149+
| File | Purpose |
150+
| ----------------------------- | --------------------------------------------------------- |
151+
| `references/tagged-errors.md` | TaggedError patterns, matching, type guards, and examples |
152+
153+
If `opensrc/` exists, treat it as the source of truth for implementation details and current API behavior.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# TaggedError Patterns
2+
3+
## Defining Errors
4+
5+
### Simple Error (no computed message)
6+
7+
```typescript
8+
import { TaggedError } from "better-result";
9+
10+
class NotFoundError extends TaggedError("NotFoundError")<{
11+
resource: string;
12+
id: string;
13+
message: string;
14+
}>() {}
15+
16+
// Usage
17+
new NotFoundError({ resource: "User", id: "123", message: "User 123 not found" });
18+
```
19+
20+
### Error with Computed Message
21+
22+
Keep constructor for derived message:
23+
24+
```typescript
25+
class NotFoundError extends TaggedError("NotFoundError")<{
26+
resource: string;
27+
id: string;
28+
message: string;
29+
}>() {
30+
constructor(args: { resource: string; id: string }) {
31+
super({ ...args, message: `${args.resource} not found: ${args.id}` });
32+
}
33+
}
34+
35+
// Usage: new NotFoundError({ resource: "User", id: "123" })
36+
```
37+
38+
### Error with Cause
39+
40+
Wrap underlying exceptions:
41+
42+
```typescript
43+
class DatabaseError extends TaggedError("DatabaseError")<{
44+
operation: string;
45+
message: string;
46+
cause: unknown;
47+
}>() {
48+
constructor(args: { operation: string; cause: unknown }) {
49+
const msg = args.cause instanceof Error ? args.cause.message : String(args.cause);
50+
super({ ...args, message: `DB ${args.operation} failed: ${msg}` });
51+
}
52+
}
53+
54+
// Usage in Result.tryPromise
55+
Result.tryPromise({
56+
try: () => db.query(sql),
57+
catch: (e) => new DatabaseError({ operation: "query", cause: e }),
58+
});
59+
```
60+
61+
### Error with Validation/Runtime Props
62+
63+
```typescript
64+
class RateLimitError extends TaggedError("RateLimitError")<{
65+
retryAfter: number;
66+
message: string;
67+
}>() {
68+
constructor(args: { retryAfterMs: number }) {
69+
super({
70+
retryAfter: args.retryAfterMs,
71+
message: `Rate limited, retry after ${args.retryAfterMs}ms`,
72+
});
73+
}
74+
}
75+
```
76+
77+
## Error Unions
78+
79+
Group related errors for function signatures:
80+
81+
```typescript
82+
// Domain errors
83+
class NotFoundError extends TaggedError("NotFoundError")<{ id: string; message: string }>() {}
84+
class ValidationError extends TaggedError("ValidationError")<{ field: string; message: string }>() {}
85+
class AuthError extends TaggedError("AuthError")<{ reason: string; message: string }>() {}
86+
87+
// Union type
88+
type AppError = NotFoundError | ValidationError | AuthError;
89+
90+
// Function signature
91+
function processRequest(req: Request): Result<Response, AppError> { ... }
92+
```
93+
94+
## Matching Errors
95+
96+
### Exhaustive Match
97+
98+
Compiler ensures all error types handled:
99+
100+
```typescript
101+
import { matchError } from "better-result";
102+
103+
const message = matchError(error, {
104+
NotFoundError: (e) => `Missing: ${e.id}`,
105+
ValidationError: (e) => `Invalid: ${e.field}`,
106+
AuthError: (e) => `Unauthorized: ${e.reason}`,
107+
});
108+
```
109+
110+
### Partial Match with Fallback
111+
112+
Handle subset, catch-all for rest:
113+
114+
```typescript
115+
import { matchErrorPartial } from "better-result";
116+
117+
const message = matchErrorPartial(
118+
error,
119+
{ NotFoundError: (e) => `Missing: ${e.id}` },
120+
(e) => `Error: ${e.message}`, // fallback for ValidationError, AuthError
121+
);
122+
```
123+
124+
### Type Guards
125+
126+
```typescript
127+
import { isTaggedError, TaggedError } from "better-result";
128+
129+
// Check any tagged error
130+
if (isTaggedError(value)) {
131+
console.log(value._tag);
132+
}
133+
134+
// Check specific error class
135+
if (NotFoundError.is(value)) {
136+
console.log(value.id); // narrowed to NotFoundError
137+
}
138+
139+
// Also available
140+
TaggedError.is(value); // same as isTaggedError
141+
```
142+
143+
### In Result.match
144+
145+
```typescript
146+
result.match({
147+
ok: (value) => handleSuccess(value),
148+
err: (e) =>
149+
matchError(e, {
150+
NotFoundError: (e) => handleNotFound(e),
151+
ValidationError: (e) => handleValidation(e),
152+
}),
153+
});
154+
```
155+
156+
## Pipeable Style
157+
158+
matchError/matchErrorPartial support data-last for pipelines:
159+
160+
```typescript
161+
const handler = matchError({
162+
NotFoundError: (e) => `Missing: ${e.id}`,
163+
ValidationError: (e) => `Invalid: ${e.field}`,
164+
});
165+
pipe(error, handler);
166+
```
167+
168+
## Converting Existing Errors
169+
170+
```typescript
171+
// FROM: class hierarchy
172+
class NotFoundError extends AppError {
173+
constructor(public id: string) {
174+
super(`Not found: ${id}`);
175+
}
176+
}
177+
// TO: TaggedError
178+
class NotFoundError extends TaggedError("NotFoundError")<{ id: string; message: string }>() {
179+
constructor(args: { id: string }) {
180+
super({ ...args, message: `Not found: ${args.id}` });
181+
}
182+
}
183+
184+
// FROM: string/generic errors
185+
throw "User not found";
186+
// TO: typed Result
187+
return Result.err(new NotFoundError({ id, message: "User not found" }));
188+
```

0 commit comments

Comments
 (0)