wellcrafted
Version:
Delightful TypeScript patterns for elegant, type-safe applications
283 lines (208 loc) • 13.1 kB
Markdown
# wellcrafted
[](https://www.npmjs.com/package/wellcrafted)
[](https://www.typescriptlang.org/)
[](https://opensource.org/licenses/MIT)
[](https://bundlephobia.com/package/wellcrafted)
*Define your errors. Type the rest.*
Tagged errors and Result types as plain objects. < 2KB, zero dependencies.
`try/catch` throws away your error's type the moment you catch it. You get `catch (error: unknown)` and you're guessing again. And a thrown `Error` travels badly: `JSON.stringify(new Error("boom"))` is `{}`, so the message vanishes the moment it hits a log line, a Web Worker, or an IPC boundary, where `instanceof` stops working too.
wellcrafted fixes both. Define your errors once as plain data, return them instead of throwing, and check them with the `{ data, error }` shape you already know from Supabase, SvelteKit load functions, and TanStack Query. No `.isOk()` method chains, no `.map().andThen().orElse()` pipelines. Check `error`, use `data`.
```typescript
import { defineErrors } from "wellcrafted/error";
import { trySync } from "wellcrafted/result";
// The key becomes error.name. The fields you return are typed on the error.
const { ParseError } = defineErrors({
ParseError: ({ path }: { path: string }) => ({
message: `Could not parse ${path}`,
path,
}),
});
const { data, error } = trySync({
try: () => JSON.parse(raw),
catch: () => ParseError({ path: "config.json" }),
});
if (error) {
// error is { name: "ParseError"; message: string; path: string }
console.error(error.message, error.path);
} else {
// data is the parsed value, error is null
use(data);
}
```
That's the whole idea: define an error, wrap the throwing call, destructure the result, check `error`. A *tagged error* is just an object with a `name` field you can `switch` on. Everything below is that pattern at scale.
## Install
```bash
npm install wellcrafted
```
## A real service
Here is the pattern in shipping code, lightly trimmed from [Whispering](https://github.com/EpicenterHQ/epicenter)'s transcription layer. Each service owns a small vocabulary of things that can go wrong, declared up front with `defineErrors`.
```typescript
import {
defineErrors,
extractErrorMessage,
type InferErrors,
} from "wellcrafted/error";
import { type Result, tryAsync } from "wellcrafted/result";
export const ElevenLabsError = defineErrors({
MissingApiKey: () => ({ message: "ElevenLabs API key is required" }),
FileTooLarge: ({ sizeMb, maxMb }: { sizeMb: number; maxMb: number }) => ({
message: `File ${sizeMb.toFixed(1)}MB exceeds ${maxMb}MB limit`,
sizeMb,
maxMb,
}),
Unexpected: ({ cause }: { cause: unknown }) => ({
message: extractErrorMessage(cause),
cause,
}),
});
export type ElevenLabsError = InferErrors<typeof ElevenLabsError>;
async function transcribe(
audio: Blob,
apiKey: string,
): Promise<Result<string, ElevenLabsError>> {
if (!apiKey) return ElevenLabsError.MissingApiKey(); // a factory already is an Err
const sizeMb = audio.size / (1024 * 1024);
if (sizeMb > 1000) return ElevenLabsError.FileTooLarge({ sizeMb, maxMb: 1000 });
return tryAsync({
try: () => callElevenLabs(apiKey, audio), // may throw
catch: (cause) => ElevenLabsError.Unexpected({ cause }),
});
}
```
The caller checks `error`, then `switch`es on `error.name` to handle each case with the right fields in scope:
```typescript
const { data, error } = await transcribe(audio, apiKey);
if (error) {
switch (error.name) {
case "MissingApiKey": return promptForKey();
case "FileTooLarge": return warn(`Audio too large: ${error.sizeMb}MB`);
case "Unexpected": return report(error.cause);
}
}
showTranscript(data); // data is string, error is null
```
Three things are doing the work here, and the rest of this README is just those three things.
## Define your errors
You can put any value in `Ok` and `Err`. So why `defineErrors`?
Because errors aren't random. A function fails in a handful of known ways, and a namespace is where you enumerate them up front: the closed set of what can go wrong. A user service fails with `AlreadyExists`, `CreateFailed`, or `InvalidEmail`, and nothing else. That set is exactly a Rust error enum: the namespace is the enum, each key is a variant, and `switch (error.name)` is the `match`. `defineErrors` brings the [thiserror](https://docs.rs/thiserror) pattern to TypeScript as plain objects instead of classes.
Enumerating the set up front is what makes the rest pay off. The union flows into your `Result<T, E>` signature, so a caller sees every way the call can fail right in the type, and a `switch` with a [`never` guard](#exhaustiveness) turns a forgotten variant into a compile error.
Each key becomes a variant. Your constructor returns `{ message, ...fields }`; `defineErrors` stamps the key on as `name` and hands back a factory that returns `Err<...>` directly. `InferErrors` extracts the union of every variant for your `Result<T, E>` signatures.
```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>;
// ^? { name: "AlreadyExists"; message: string; email: string }
// | { name: "CreateFailed"; message: string; email: string; cause: unknown }
```
Two properties make this pay off:
**Errors are data, not classes.** Plain frozen objects, no prototype chain. The fields you put on them are plain own properties, so they survive `JSON.stringify`, a Web Worker, or an IPC hop with no `stack` noise and no `instanceof` that breaks across package boundaries. The error you create is the error that arrives.
**Every factory returns `Err<...>` directly.** No wrapping step. Return it from a `tryAsync` catch handler or as a standalone early return (`if (existing) return UserError.AlreadyExists({ email })`). The `Result` type flows out naturally.
## Wrap throwing code
`trySync` and `tryAsync` turn a throwing operation into a `Result`. The `catch` handler receives the raw error and returns one of your `defineErrors` variants. Anything you don't wrap keeps throwing exactly as before, so you can adopt this one function at a time.
```typescript
import { trySync, tryAsync, Ok } from "wellcrafted/result";
// Synchronous
const { data, error } = trySync({
try: () => JSON.parse(rawInput),
catch: (cause) => JsonError.ParseFailed({ input: rawInput, cause }),
});
// Asynchronous
const { data, error } = await tryAsync({
try: () => fetch(url).then((r) => r.json()),
catch: (cause) => HttpError.Connection({ url, cause }),
});
```
When `catch` returns `Ok(fallback)` instead of an error, there is no error branch left: the return type narrows to `Ok<T>`, so `error` is always `null` and you can skip the check.
```typescript
const { data: items } = trySync({
try: (): string[] => JSON.parse(riskyJson),
catch: () => Ok([] as string[]), // recovered; there is no error to check
});
```
## Compose across layers
Each layer defines its own vocabulary and folds the layer below into a `cause` field. `extractErrorMessage` formats that cause inside the factory, so call sites stay clean. You propagate with a plain `if (error) return` (there is no `?` operator; see [what you give up](#what-you-give-up)).
```typescript
async function getUser(userId: string): Promise<Result<User, UserServiceError>> {
const { data: response, error } = await tryAsync({
try: () => fetch(`/api/users/${userId}`),
catch: (cause) => UserServiceError.FetchFailed({ userId, cause }),
// raw fetch error becomes cause ^^^^^
});
if (error) return Err(error); // propagate as-is
if (response.status === 404) return UserServiceError.NotFound({ userId });
return Ok(await response.json());
}
```
Your tagged fields are plain data, so the chain logs and crosses the wire cleanly. The raw `cause` is the exception: it serializes only as well as whatever you caught, which is why the factories above fold it through `extractErrorMessage` into the `message` string.
## The Result type
The foundation is one discriminated union:
```typescript
type Ok<T> = { data: T; error: null };
type Err<E> = { error: E; data: null };
type Result<T, E> = Ok<T> | Err<E>;
```
Check `error` first and TypeScript narrows `data` for you:
```typescript
const { data, error } = await someOperation();
if (error) return; // error is E, data is null
// data is T, error is null
```
`if (error)` works because errors from `defineErrors` are always objects, and an object is truthy. The exact check is `error !== null` (or the `isErr` guard); reach for it if you ever put a falsy value like `0` or `""` in an `Err`.
### Exhaustiveness
`switch (error.name)` narrows each case, and your editor autocompletes every variant. To make a *new* variant a compile error until it is handled, add a `never` check in `default`:
```typescript
switch (error.name) {
case "NotFound": return notFound();
case "FetchFailed": return badGateway();
default:
error satisfies never; // add a variant and this line fails to compile
}
```
Plain TypeScript does not enforce exhaustive `switch` on its own; this one line is how you opt in.
## What you give up
wellcrafted is not an effect system, and the honest cost is control flow. There is no `?` operator, so you propagate with an explicit `if (error) return` at each step. There is no dependency injection, no automatic short-circuiting, and no built-in concurrency. If you need those, reach for [Effect](https://effect.website); that is what it is for.
In exchange, the whole API is `{ data, error }`, `async/await`, and `switch`: no new runtime, no generators, no pipe operators to learn.
## Also in the box
Each lives behind its own subpath import, so you pay for only what you use.
- `wellcrafted/brand`: `Brand<T>` makes distinct types from primitives (`type UserId = string & Brand<"UserId">`) so the compiler catches mix-ups. Zero runtime.
- `wellcrafted/logger`: a small DI-based structured logger keyed on log level, built to take your `defineErrors` types directly. No global singleton.
- `wellcrafted/testing`: `expectOk` / `expectErr` unwrap a `Result` in a test, or throw with a readable message.
- `wellcrafted/json`: `parseJson` is `JSON.parse` that returns a `Result` instead of throwing.
- `wellcrafted/query`: TanStack Query adapters for Result-returning functions. Queries expose `.options`, `.fetch`, and `.ensure`; mutations are callable and expose `.options`.
- `wellcrafted/standard-schema`: wrap a `Result` as a [Standard Schema](https://github.com/standard-schema/standard-schema) for validators that speak the spec.
## How it compares
| | wellcrafted | neverthrow | better-result | fp-ts | Effect |
|---|---|---|---|---|---|
| Error definition | `defineErrors` factories | Bring your own | `TaggedError` classes | Bring your own | Class-based with `_tag` |
| Error shape | Plain frozen objects | Any type | Class instances | Any type | Class instances |
| Composition | Manual `if (error)` | `.map().andThen()` | `Result.gen()` generators | Pipe operators | `yield*` generators |
| Bundle size | < 2KB | ~5KB | ~2KB | ~30KB | ~50KB |
| Syntax | async/await | Method chains | Method chains + generators | Pipe operators | Generators |
Every Result library hands you a container. wellcrafted hands you what goes inside it, then gets out of the way.
## API at a glance
From `wellcrafted/result`:
- `Ok(data)` / `Err(error)`: construct a success or failure
- `trySync` / `tryAsync`: wrap a throwing operation, sync or async
- `Result<T, E>`: the `Ok<T> | Err<E>` union
From `wellcrafted/error`:
- `defineErrors(config)`: define a namespace of error variant factories
- `extractErrorMessage(value)`: pull a readable string out of any `unknown`
- `InferErrors<typeof MyError>`: the union of all variants; `InferError<typeof MyError.Variant>`: one variant
Less common but there when you need them: `isOk` / `isErr` type guards, `unwrap` (extract or throw), `partitionResults` (split an array of Results), and `resolve` (handle values that may or may not be Results).
## Teach your AI agent
If you use an AI coding agent, install the skills that teach it the patterns and anti-patterns directly:
```bash
npx skills add wellcrafted-dev/wellcrafted
```
This installs five skills: `define-errors`, `result-types`, `query-factories`, `branded-types`, and `patterns` (the architectural style guide). They work with any agent that supports [`npx skills`](https://www.npmjs.com/package/skills). Install once, update with `npx skills update`.
## License
MIT