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: 7 additions & 10 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "pnpm"
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2

- run: pnpm install --frozen-lockfile
- run: pnpm run lint && pnpm run build
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run build
- run: bun test
13 changes: 5 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 24.x
cache: "pnpm"
node-version: 22.x
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile
- run: bun install --frozen-lockfile
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
publish: pnpm run release
publish: bun run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
186 changes: 6 additions & 180 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion docs/core/implementation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ The magic lies in how TypeScript's type system interprets this structure:
- **`Err<E>`** always has `error: E` and `data: null`
- **`Result<T, E>`** is simply `Ok<T> | Err<E>`

This creates a **discriminated union** where the `error` (or `data`) property acts as the discriminant with literal types `null` vs non-`null`.
This creates a **discriminated union** where both `data` and `error` can act as discriminants — they work symmetrically in most cases:

- `error === null` → **Ok** (data is `T`)
- `error !== null` → **Err** (error is `E`)

**Pro tip**: We recommend checking `error` by default. While checking `data` works great when your success type is non-nullable, `error` handles all cases — including `Ok<null>` where the success value itself is `null`.

### How TypeScript Narrows Types

Expand Down
73 changes: 63 additions & 10 deletions docs/core/result-pattern.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,40 @@ type Ok<T> = { data: T; error: null };
type Err<E> = { error: E; data: null };
```

The key insight: **This design creates a discriminated union where the `error` (or `data`) property acts as the discriminant with literal types `null` vs non-`null`**.
The key insight: **Both `data` and `error` can act as discriminants** — they're symmetric in most cases. TypeScript narrows types when you check either one:

- When `error` is `null`, TypeScript knows `data` is `T`
- When `data` is `null`, TypeScript knows `error` is `E`
- `error === null` → **Ok**: TypeScript knows `data` is `T`
- `error !== null` → **Err**: TypeScript knows `error` is `E`
- `data !== null` → **Ok**: TypeScript knows you have success data
- `data === null` → Usually **Err**, but not always...

### Pro Tip: Prefer Checking `error`

In practice, we recommend checking `error` first. Here's why:

```typescript
// Both work in most cases:
if (result.error) { /* handle error */ }
if (!result.data) { /* handle error */ } // Also works... usually

// But what about Ok<null>?
const result: Result<null, string> = Ok(null);
// result = { data: null, error: null }

if (!result.data) {
// ❌ We'd wrongly think this is an error!
// But it's actually a successful Ok<null>
}

if (result.error) {
// ✅ This correctly identifies it as Ok
// error is null, so we skip this branch
}
```

The edge case is `Ok<null>` — when your success value is intentionally `null`. In this case, `data === null` doesn't mean failure; it means success with a null value.

**Bottom line**: Check `error` first — it works reliably in every scenario, including `Ok<null>`. Checking `data` works great when you know your success type is non-nullable, but `error` is the safer, more consistent choice.

### Control-Flow Analysis in Action

Expand All @@ -52,19 +82,42 @@ function processResult<T, E>(result: Result<T, E>) {

This isn't just type checking - it's type *narrowing*. TypeScript eliminates impossible states from consideration.

### The Power of Mutual Exclusivity
### The Tiebreaker Rule

The design ensures that these states are mutually exclusive:
When validating external data (like API responses), you might encounter objects with both `data` and `error` present:

```typescript
// This is impossible to construct with our API:
const invalid = { data: "value", error: "error" }; // ❌ Not a valid Result
// External API returns this ambiguous shape:
const response = { data: "value", error: "Something went wrong" };

// You must use the constructors:
const valid1 = Ok("value"); // { data: "value", error: null }
const valid2 = Err("error"); // { data: null, error: "error" }
// wellcrafted treats this as Err - error takes precedence
// The error property is the sole discriminant, so:
// error !== null → it's an Err (regardless of data value)
```

This "error wins" behavior provides a consistent rule: if there's an error, treat it as a failure. This is especially useful when working with APIs that might populate both fields.

```typescript
// The constructors enforce clean states:
const success = Ok("value"); // { data: "value", error: null }
const failure = Err("error"); // { data: null, error: "error" }

// But when parsing external data, both-present is valid (treated as Err)
```

### The Both-Null Case

What about `{ data: null, error: null }`? Following the "error is the sole discriminant" rule:

```typescript
const bothNull = { data: null, error: null };

// error === null → this is Ok<null>
// It's a successful result where the success value happens to be null
```

This is useful for operations that succeed but intentionally return nothing, like a cache lookup that found nothing (success, but null data) versus a cache lookup that failed (error).

## Why Not Boolean Discriminators?

You might wonder why we don't use a pattern like:
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ type Err<E> = { data: null; error: E }

### Why This Design?

1. **Discriminated Union**: 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
1. **Discriminated Union**: Both `data` and `error` work as discriminants — they're symmetric, so TypeScript narrows types when you check either one. We recommend checking `error` by default since it handles all cases, including `Ok<null>` (e.g., a cache lookup that succeeds but finds nothing)
2. **Destructuring-Friendly**: 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
3. **Serialization-Safe**: Plain objects survive any serialization boundary
4. **Zero Magic**: No classes, no prototypes, just objects
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@
"build": "tsdown",
"format": "biome format --write .",
"lint": "biome lint --write .",
"test": "vitest run",
"test:watch": "vitest",
"release": "pnpm run build && changeset version && changeset publish"
"test": "bun test",
"test:watch": "bun test --watch",
"typecheck": "tsc --noEmit",
"release": "bun run build && changeset version && changeset publish"
},
"keywords": [
"typescript",
Expand All @@ -60,11 +61,11 @@
"@biomejs/biome": "^2.3.3",
"@changesets/cli": "^2.27.10",
"@tanstack/query-core": "^5.82.0",
"@types/bun": "^1.3.5",
"arktype": "^2.1.29",
"tsdown": "^0.12.5",
"typescript": "^5.8.3",
"valibot": "^1.2.0",
"vitest": "^4.0.14",
"zod": "^4.3.3"
}
}
Loading