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
771 changes: 183 additions & 588 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/core/error-system.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type AnyTaggedError = { name: string; message: string };

### `name` — The Discriminant

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:
This is your error's unique identifier and the key to pattern matching — the same `.name` property every JavaScript `Error` already has. See [Why `name` and `message`](/philosophy/why-name-and-message) for why we follow this convention. Use it in `if` statements and `switch` statements to handle different error types:

```typescript
const AppError = defineErrors({
Expand Down
2 changes: 2 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@
"group": "💭 Philosophy",
"pages": [
"philosophy/design-principles",
"philosophy/why-name-and-message",
"philosophy/rust-inspiration",
"philosophy/error-api-evolution",
"philosophy/developer-experience",
"philosophy/production-reliability",
"philosophy/brand-implementation"
Expand Down
2 changes: 1 addition & 1 deletion docs/philosophy/design-principles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ if (result.error) {

**No Method Chaining**: Rust's `.map()` and `.and_then()` are elegant in Rust, but feel foreign in JavaScript. We use standard JavaScript control flow instead.

**No Classes**: Error objects are plain data structures, not class instances that lose their prototype when serialized.
**No Classes**: Error objects are plain data structures, not class instances that lose their prototype when serialized. They use `name` and `message` because that's what JavaScript's `Error` class already uses — see [Why `name` and `message`](/philosophy/why-name-and-message) for the full reasoning.

**No Complex Abstractions**: The entire Result core is ~50 lines of code you can read and understand in 5 minutes.

Expand Down
210 changes: 210 additions & 0 deletions docs/philosophy/error-api-evolution.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
---
title: "We Wrote a Builder Pattern, Then Deleted It"
description: "How wellcrafted's error API went from types-only to a fluent builder to the realization that errors are just functions"
icon: 'route'
---

# We Wrote a Builder Pattern, Then Deleted It

Every error in wellcrafted started as a hand-written object literal. Nine months and seven rewrites later, the API is ten lines of runtime. The journey between those two points is a story about over-engineering, and the moment we realized the builder was hiding what errors actually are: a function that takes input and returns `{ name, message, ...data }`.

## Hand-Written Error Objects Were Correct But Unusable

The earliest API had no factory functions at all. You defined a `TaggedError` type and constructed errors by hand:

```typescript
type TaggedError<T extends string> = Readonly<{
name: T;
message: string;
context: Record<string, unknown>;
cause: unknown;
}>;

// Every. Single. Call site.
const error: TaggedError<'NetworkError'> = {
name: 'NetworkError',
message: 'Connection failed',
context: { url: '/api/data' },
cause: undefined,
};
```

`context` and `cause` were required. Even if you had nothing to put in them, you wrote `context: {}` and `cause: undefined`. The types were correct, but the ergonomics were brutal.

## `createTaggedError` Automated the Boilerplate

The first factory function arrived a month later. Give it a name, get back two factories: one for plain errors, one pre-wrapped in `Err`.

```typescript
const { NetworkError, NetworkErr } = createTaggedError('NetworkError');

// Plain error object
NetworkError({ message: 'Connection failed', context: { url: '/api' }, cause: undefined });

// Same thing, wrapped in Err(...)
NetworkErr({ message: 'Connection failed', context: { url: '/api' }, cause: undefined });
```

The name had to end in `Error`. The `Err` suffix variant was auto-generated by string manipulation: `NetworkError` became `NetworkErr`. This solved the repetition problem, but the required fields remained. You still couldn't create a simple error without passing empty context and an undefined cause.

## Making Fields Optional Broke Type Safety

We made `context` and `cause` optional. That fixed the simple case, but opened a new problem: how do you type-constrain the context shape? A `NetworkError` should require `{ url: string }` in its context. A `ParseError` should require `{ input: string }`.

The answer was generic parameters. Then typed cause. Then function overloads to support every combination:

```typescript
// Overload 1: no constraints
function createTaggedError<TName extends `${string}Error`>(name: TName): FlexibleFactories<TName>;
// Overload 2: typed context
function createTaggedError<TName, TContext>(name: TName): ContextFactories<TName, TContext>;
// Overload 3: typed context + cause
function createTaggedError<TName, TContext, TCause>(name: TName): FullFactories<TName, TContext, TCause>;
```

This worked at call sites. But TypeScript's `ReturnType` picks the last overload. So `type NetworkError = ReturnType<typeof NetworkError>` resolved to the most constrained signature, requiring both context and cause even when you only wanted the flexible version. Users couldn't extract types from their own factories.

## The Fluent Builder Made It Worse

To escape the overload problem, we introduced a fluent builder. Instead of cramming everything into generic parameters, you chained method calls:

```typescript
const { NetworkError, NetworkErr } = createTaggedError('NetworkError')
.withContext<{ url: string }>()
.withCause<DatabaseError>()
.withMessage(({ url }) => `Failed to connect to ${url}`);
```

Each method returned a new builder type with the constraint baked in. `.withContext<T>()` locked the context shape. `.withCause<T>()` locked the cause type. `.withMessage()` was the terminal step that produced the factories.

This was the most "correct" version. It was also the most complex. The builder had four modes depending on which methods you called, three layers of type indirection, and about 60 lines of runtime.

```typescript
// .withFields<T>() was a phantom call — purely type-level, disguised as a method
const { FileError, FileErr } = createTaggedError('FileError')
.withFields<{ path: string; code: number }>() // does nothing at runtime
.withMessage(({ path, code }) => `File error ${code}: ${path}`);
```

`.withFields<T>()` replaced the separate `.withContext<T>()` and `.withCause<T>()`, flattening fields directly onto the error. That required a `NoReservedKeys` constraint to prevent collisions with `name` and `message`, and `JsonObject` constraints for serializability that broke with optional fields. Each fix spawned a new edge case.

## 321 Call Sites Killed Two Extreme Positions

Before the final rewrite, we audited every error call site across the codebase. 321 of them.

| Pattern | Frequency | Example |
|---|---|---|
| Static/predictable message | 59% | `() => ({ message: 'Session expired' })` |
| Dynamic call-site message | 41% | `({ message }) => ({ message })` |

No error type mixed the two patterns. This killed two extreme positions: "always compute the message in the definition" and "always pass the message at the call site." A constructor function handles both cases naturally. If the message is static, compute it from fields. If it's dynamic, accept it as a parameter.

## Rust's `thiserror` Reframed the Problem

Around this time, we started looking at Rust's `thiserror` crate. In Rust, you write `enum HttpError { Connection, Response, Parse }`. The enum name is the namespace; the variant name is the discriminant. The `#[error("...")]` attribute co-locates the message template with each variant. You'd never write `HttpError::ConnectionError` because that's redundant.

```rust
#[derive(Error, Debug)]
enum HttpError {
#[error("Failed to connect: {cause}")]
Connection { cause: String },

#[error("HTTP {status}")]
Response { status: u16 },
}
```

This reframed the whole question. We'd been asking "how do we make the builder more flexible?" when the real question was "why do we have a builder at all?" Rust's error variants are just structs with a display format. No builder, no modes, no phantom type-level calls. Each variant is data in, message out.

## The Builder Was Hiding a Plain Function

That Rust framing made the next insight obvious: every error definition is just a constructor function. Take some input, produce `{ message, ...data }`. The four "modes" of the builder were four shapes of the same function:

```typescript
// "Static message" mode
() => ({ message: 'Session expired' })

// "Call-site message" mode
({ message }: { message: string }) => ({ message })

// "Computed message" mode
({ cause }: { cause: unknown }) => ({
message: `Failed: ${extractErrorMessage(cause)}`,
cause,
})

// "Structured data" mode
({ status }: { status: number; reason?: string }) => ({
message: `HTTP ${status}`,
status,
reason,
})
```

Four tiers of complexity. Zero modes. Just a function with different signatures. The builder was ceremony wrapping this.

## `defineErrors` Combined Both Insights

The Rust namespace pattern and the "just a function" insight converged into `defineErrors`. The entire runtime:

```typescript
function defineErrors(config) {
const result = {};
for (const [name, ctor] of Object.entries(config)) {
result[name] = (...args) => {
const body = ctor(...args);
return Err(Object.freeze({ ...body, name }));
};
}
return result;
}
```

Iterate over the config. For each key, wrap the user's constructor: call it, stamp `name` from the key, freeze the object, wrap in `Err`. That's it.

The config mirrors Rust's enum structure: the object name is the namespace, each key is a variant, each value is the constructor:

```typescript
const HttpError = defineErrors({
Connection: ({ cause }: { cause: unknown }) => ({
message: `Failed to connect: ${extractErrorMessage(cause)}`,
cause,
}),
Response: ({ status }: { status: number }) => ({
message: `HTTP ${status}`,
status,
}),
Parse: ({ cause }: { cause: unknown }) => ({
message: `Failed to parse: ${extractErrorMessage(cause)}`,
cause,
}),
});

type HttpError = InferErrors<typeof HttpError>;
```

The early versions required keys ending in `Error`: `ConnectionError`, `ResponseError`, `ParseError`. Under a namespace already called `HttpError`, that's noise. Dropping the suffix gave us `HttpError.Connection`, `HttpError.Response`, `HttpError.Parse`: directly analogous to Rust's `HttpError::Connection`.

```
Rust: HttpError::Connection { cause: "timeout".into() }
TypeScript: HttpError.Connection({ cause: error })
```

TypeScript's ability to share a name between a value and a type made the final piece work. `const HttpError` and `type HttpError` coexist, just like a Rust enum is both a type and a namespace:

```typescript
const HttpError = defineErrors({ ... }); // value: namespace of factories
type HttpError = InferErrors<typeof HttpError>; // type: union of all variants
```

## Every Fix Was Correct; the Aggregate Was Not

| Version | Runtime | What it looked like |
|---|---|---|
| Hand-written objects | 0 lines | `{ name: 'X', message: '...', context: {}, cause: undefined }` |
| `createTaggedError` factory | ~15 lines | `createTaggedError('XError')` |
| Typed generics + overloads | ~25 lines | `createTaggedError<Name, Context, Cause>(name)` |
| Fluent builder | ~60 lines | `createTaggedError('X').withFields<T>().withMessage(fn)` |
| `defineErrors` | ~10 lines | `defineErrors({ X: (input) => ({ message, ...data }) })` |

Overloads solved type safety. The builder solved overload limitations. `.withFields()` solved nesting. `.withMessage()` solved scattered formatting. Each fix was correct in isolation and added weight in aggregate. When your error definition is a plain function, there's nothing left to configure.
107 changes: 107 additions & 0 deletions docs/philosophy/for-the-pragmatic-fp-developer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
title: "wellcrafted Is for the Pragmatic Almost-Functional Programmer"
description: "You like Rust's error handling. You respect Effect. You just want something that works with TypeScript instead of against it."
icon: 'user-check'
---

# wellcrafted Is for the Pragmatic Almost-Functional Programmer

You've read about Result types. You've seen Rust's `thiserror` and thought "I want that in TypeScript." You may have tried Effect, neverthrow, or fp-ts. You understood the ideas. You liked the ideas. But somewhere between the method chains and the generators, you went back to `try-catch` and moved on with your life.

You're not alone, and you're not wrong. wellcrafted was built for exactly this gap.

---

## The Profile

You probably recognize yourself in a few of these:

You think `Promise<User>` is lying. A function that hits a database can fail, and the return type should say so. You've been bitten by unhandled promise rejections that crashed production at 2 AM because nothing in the type signature warned you.

You've looked at Rust's error handling with genuine envy. `thiserror` defining error variants in five lines, the `?` operator propagating errors without ceremony, `match` expressions that the compiler checks exhaustively. You want that, but in TypeScript.

You tried an FP error handling library and it felt like writing in a foreign language. The types were right, the code compiled, but every function became a wrapper around a wrapper. Your team's PRs slowed down. New contributors couldn't read the error handling code without a tutorial.

You went back to `try-catch` not because you think it's better, but because the alternative was worse in practice. The cognitive tax wasn't paying off for your team.

If that's you, here's the deal: you don't have to choose between "untyped try-catch" and "full functional runtime." There's a middle ground that gives you the one thing you actually wanted—typed, named errors—without the parts that didn't work.

---

## What You Wanted vs. What You Got

The core appeal of FP error handling has always been three things: errors in the type signature, exhaustive handling, and named variants you can branch on. Everything else—the method chains, the generators, the effect system—is machinery to support those three things in languages that have the features for it.

TypeScript doesn't have those features. So the machinery becomes the product, and the original goals get buried under it.

wellcrafted strips back to the goals:

```typescript
const UserError = defineErrors({
AlreadyExists: ({ email }: { email: string }) => ({
message: `User ${email} already exists`,
email,
}),
CreateFailed: ({ email, cause }: { email: string; cause: unknown }) => ({
message: `Failed to create user ${email}: ${extractErrorMessage(cause)}`,
email,
cause,
}),
});
type UserError = InferErrors<typeof UserError>;
```

Errors in the type signature. Named variants. Exhaustive handling via `switch`. That's the whole pitch.

```typescript
async function createUser(email: string): Promise<Result<User, UserError>> {
const existing = await db.findByEmail(email);
if (existing) return UserError.AlreadyExists({ email });

return tryAsync({
try: () => db.users.create({ email }),
catch: (error) => UserError.CreateFailed({ email, cause: error }),
});
}
```

No generators. No `.andThen()`. Just async/await with early returns, the same control flow you'd write anyway, except now the errors are typed.

---

## The Compromises, Stated Plainly

wellcrafted makes specific, deliberate trade-offs. You should know what they are.

**No method chains.** There's no `.map()` or `.andThen()` on the Result type. Composition is `if (error) return error` and early returns. This is verbose compared to Rust's `?` operator. It's also immediately readable to anyone who knows TypeScript.

**No dependency injection.** Effect's service system is genuinely elegant. wellcrafted has nothing like it. Pass your dependencies as function arguments. It works. It's not as composable.

**No runtime.** No fibers, no structured concurrency, no resource management. If you need those, Effect has them and wellcrafted doesn't.

**Plain objects, not classes.** Errors are frozen plain objects. `instanceof` doesn't work. This is intentional: class instances don't survive `JSON.stringify`, and in any app that crosses a serialization boundary (Web Workers, IPC, message channels), that matters more than prototype chains.

These are real things you give up. They're also things that most TypeScript applications don't need. The question isn't "which library has more features." It's "which features are you actually using, and what are you paying for the ones you're not."

---

## Where This Fits in the Ecosystem

| If you want... | Use |
|---|---|
| A full effect system with DI, fibers, and concurrency | Effect |
| Method chains on Result types | neverthrow |
| The full FP toolkit (Option, Either, pipe) | fp-ts |
| Typed errors that work with async/await and serialize cleanly | wellcrafted |

wellcrafted is the smallest circle on that Venn diagram. It does one thing: gives you typed, named, serializable error variants that compose with the TypeScript you already write. If that's all you wanted from FP error handling—and for most teams, it is—this is the library that stops there instead of continuing into territory TypeScript can't support well.

---

## The Bet

wellcrafted makes a bet: most TypeScript teams don't need a functional programming framework. They need a way to define errors that the type system can see. Everything else—async/await, early returns, switch statements, destructuring—TypeScript already does well enough.

If you tried typed errors and walked away, you weren't wrong about the goal. You were right that TypeScript's errors should be typed. The libraries you tried were right about the theory. The gap was in the execution: they needed language features that TypeScript doesn't have, and the workarounds cost more than they saved.

wellcrafted is what's left when you remove the workarounds.
Loading