Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .codex/commands/adopt-better-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
description: Use better-result-adopt skill for better-result migration/adoption
---

Load and use the better-result-adopt skill to help with this codebase.

First, invoke the skill:

```
skill({ name: 'better-result-adopt' })
```

Then follow the skill instructions.

<user-request>
$ARGUMENTS
</user-request>
162 changes: 162 additions & 0 deletions .codex/skills/better-result-adopt/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
name: better-result-adopt
description: Migrate codebase from try/catch or Promise-based error handling to better-result. Use when adopting Result types, converting thrown exceptions to typed errors, or refactoring existing error handling to railway-oriented programming.
---

# better-result Adoption

Migrate existing error handling (try/catch, Promise rejections, thrown exceptions) to typed Result-based error handling with better-result.

## When to Use

- Adopting better-result in existing codebase
- Converting try/catch blocks to Result types
- Replacing thrown exceptions with typed errors
- Migrating Promise-based code to Result.tryPromise
- Introducing railway-oriented programming patterns

## Migration Strategy

### 1. Start at Boundaries

Begin migration at I/O boundaries (API calls, DB queries, file ops) and work inward. Don't attempt full-codebase migration at once.

### 2. Identify Error Categories

Before migrating, categorize errors in target code:

| Category | Example | Migration Target |
| -------------- | ---------------------- | ----------------------------------------------- |
| Domain errors | NotFound, Validation | TaggedError + Result.err |
| Infrastructure | Network, DB connection | Result.tryPromise + TaggedError |
| Bugs/defects | null deref, type error | Let throw (becomes Panic if in Result callback) |

### 3. Migration Order

1. Define TaggedError classes for domain errors
2. Wrap throwing functions with Result.try/tryPromise
3. Convert imperative error checks to Result chains
4. Refactor callbacks to generator composition

## Pattern Transformations

### Try/Catch to Result.try

```typescript
// BEFORE
function parseConfig(json: string): Config {
try {
return JSON.parse(json);
} catch (e) {
throw new ParseError(e);
}
}

// AFTER
function parseConfig(json: string): Result<Config, ParseError> {
return Result.try({
try: () => JSON.parse(json) as Config,
catch: (e) => new ParseError({ cause: e, message: `Parse failed: ${e}` }),
});
}
```

### Async/Await to Result.tryPromise

```typescript
// BEFORE
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new ApiError(res.status);
return res.json();
}

// AFTER
async function fetchUser(id: string): Promise<Result<User, ApiError | UnhandledException>> {
return Result.tryPromise({
try: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new ApiError({ status: res.status, message: `API ${res.status}` });
return res.json() as Promise<User>;
},
catch: (e) => (e instanceof ApiError ? e : new UnhandledException({ cause: e })),
});
}
```

### Null Checks to Result

```typescript
// BEFORE
function findUser(id: string): User | null {
return users.find((u) => u.id === id) ?? null;
}
// Caller must check: if (user === null) ...

// AFTER
function findUser(id: string): Result<User, NotFoundError> {
const user = users.find((u) => u.id === id);
return user
? Result.ok(user)
: Result.err(new NotFoundError({ id, message: `User ${id} not found` }));
}
// Caller: yield* findUser(id) in Result.gen, or .match()
```

### Callback Hell to Generator

```typescript
// BEFORE
async function processOrder(orderId: string) {
try {
const order = await fetchOrder(orderId);
if (!order) throw new NotFoundError(orderId);
const validated = validateOrder(order);
if (!validated.ok) throw new ValidationError(validated.errors);
const result = await submitOrder(validated.data);
return result;
} catch (e) {
if (e instanceof NotFoundError) return { error: "not_found" };
if (e instanceof ValidationError) return { error: "invalid" };
throw e;
}
}

// AFTER
async function processOrder(orderId: string): Promise<Result<OrderResult, OrderError>> {
return Result.gen(async function* () {
const order = yield* Result.await(fetchOrder(orderId));
const validated = yield* validateOrder(order);
const result = yield* Result.await(submitOrder(validated));
return Result.ok(result);
});
}
// Error type is union of all yielded errors
```

## Defining TaggedErrors

See [references/tagged-errors.md](references/tagged-errors.md) for TaggedError patterns.

## Workflow

1. **Check for source reference**: Look for `opensrc/` directory - if present, read the better-result source code for implementation details and patterns
2. **Audit**: Find try/catch, Promise.catch, thrown errors in target module
3. **Define errors**: Create TaggedError classes for domain errors
4. **Wrap boundaries**: Use Result.try/tryPromise at I/O points
5. **Chain operations**: Convert if/else error checks to .andThen or Result.gen
6. **Update signatures**: Change return types to Result<T, E>
7. **Update callers**: Propagate Result handling up call stack
8. **Test**: Verify error paths with .match or type narrowing

## Common Pitfalls

- **Over-wrapping**: Don't wrap every function. Start at boundaries, propagate inward.
- **Losing error info**: Always include cause/context in TaggedError constructors.
- **Mixing paradigms**: Once a module returns Result, callers should too (or explicitly .unwrap).
- **Ignoring Panic**: Callbacks that throw become Panic. Fix the bug, don't catch Panic.

## References

- [TaggedError Patterns](references/tagged-errors.md) - Defining and matching typed errors
- `opensrc/` directory (if present) - Full better-result source code for deeper context
188 changes: 188 additions & 0 deletions .codex/skills/better-result-adopt/references/tagged-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# TaggedError Patterns

## Defining Errors

### Simple Error (no computed message)

```typescript
import { TaggedError } from "better-result";

class NotFoundError extends TaggedError("NotFoundError")<{
resource: string;
id: string;
message: string;
}>() {}

// Usage
new NotFoundError({ resource: "User", id: "123", message: "User 123 not found" });
```

### Error with Computed Message

Keep constructor for derived message:

```typescript
class NotFoundError extends TaggedError("NotFoundError")<{
resource: string;
id: string;
message: string;
}>() {
constructor(args: { resource: string; id: string }) {
super({ ...args, message: `${args.resource} not found: ${args.id}` });
}
}

// Usage: new NotFoundError({ resource: "User", id: "123" })
```

### Error with Cause

Wrap underlying exceptions:

```typescript
class DatabaseError extends TaggedError("DatabaseError")<{
operation: string;
message: string;
cause: unknown;
}>() {
constructor(args: { operation: string; cause: unknown }) {
const msg = args.cause instanceof Error ? args.cause.message : String(args.cause);
super({ ...args, message: `DB ${args.operation} failed: ${msg}` });
}
}

// Usage in Result.tryPromise
Result.tryPromise({
try: () => db.query(sql),
catch: (e) => new DatabaseError({ operation: "query", cause: e }),
});
```

### Error with Validation/Runtime Props

```typescript
class RateLimitError extends TaggedError("RateLimitError")<{
retryAfter: number;
message: string;
}>() {
constructor(args: { retryAfterMs: number }) {
super({
retryAfter: args.retryAfterMs,
message: `Rate limited, retry after ${args.retryAfterMs}ms`,
});
}
}
```

## Error Unions

Group related errors for function signatures:

```typescript
// Domain errors
class NotFoundError extends TaggedError("NotFoundError")<{ id: string; message: string }>() {}
class ValidationError extends TaggedError("ValidationError")<{ field: string; message: string }>() {}
class AuthError extends TaggedError("AuthError")<{ reason: string; message: string }>() {}

// Union type
type AppError = NotFoundError | ValidationError | AuthError;

// Function signature
function processRequest(req: Request): Result<Response, AppError> { ... }
```

## Matching Errors

### Exhaustive Match

Compiler ensures all error types handled:

```typescript
import { matchError } from "better-result";

const message = matchError(error, {
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Invalid: ${e.field}`,
AuthError: (e) => `Unauthorized: ${e.reason}`,
});
```

### Partial Match with Fallback

Handle subset, catch-all for rest:

```typescript
import { matchErrorPartial } from "better-result";

const message = matchErrorPartial(
error,
{ NotFoundError: (e) => `Missing: ${e.id}` },
(e) => `Error: ${e.message}`, // fallback for ValidationError, AuthError
);
```

### Type Guards

```typescript
import { isTaggedError, TaggedError } from "better-result";

// Check any tagged error
if (isTaggedError(value)) {
console.log(value._tag);
}

// Check specific error class
if (NotFoundError.is(value)) {
console.log(value.id); // narrowed to NotFoundError
}

// Also available
TaggedError.is(value); // same as isTaggedError
```

### In Result.match

```typescript
result.match({
ok: (value) => handleSuccess(value),
err: (e) =>
matchError(e, {
NotFoundError: (e) => handleNotFound(e),
ValidationError: (e) => handleValidation(e),
}),
});
```

## Pipeable Style

matchError/matchErrorPartial support data-last for pipelines:

```typescript
const handler = matchError({
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Invalid: ${e.field}`,
});
pipe(error, handler);
```

## Converting Existing Errors

```typescript
// FROM: class hierarchy
class NotFoundError extends AppError {
constructor(public id: string) {
super(`Not found: ${id}`);
}
}
// TO: TaggedError
class NotFoundError extends TaggedError("NotFoundError")<{ id: string; message: string }>() {
constructor(args: { id: string }) {
super({ ...args, message: `Not found: ${args.id}` });
}
}

// FROM: string/generic errors
throw "User not found";
// TO: typed Result
return Result.err(new NotFoundError({ id, message: "User not found" }));
```
6 changes: 4 additions & 2 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ Then replace `YOUR_API_KEY` with your MCP API key (see `btca remote mcp` or the

In the interactive TUI, use `/` to access commands:

- `/clear` - Clear chat history
- `/model` - Select from recommended models
- `/connect` - Configure provider and model
- `/add` - Add a new resource
- `/clear` - Clear chat history
- `/resume` - Resume a previous thread
- `/new` - Alias for `/clear`

## Keyboard Shortcuts

Expand Down
Loading
Loading