UNPKG

wellcrafted

Version:

Delightful TypeScript patterns for elegant, type-safe applications

283 lines (208 loc) 13.1 kB
# wellcrafted [![npm version](https://badge.fury.io/js/wellcrafted.svg)](https://www.npmjs.com/package/wellcrafted) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/wellcrafted)](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