diff --git a/ERROR_HANDLING_GUIDE.md b/ERROR_HANDLING_GUIDE.md deleted file mode 100644 index bc60c9c..0000000 --- a/ERROR_HANDLING_GUIDE.md +++ /dev/null @@ -1,951 +0,0 @@ -# Comprehensive Error Handling Guide - -A deep dive into effective error handling patterns using the lightweight tagged error system. - -## Philosophy: Errors as Data - -This library treats errors as first-class data rather than exceptional control flow. This approach, inspired by functional programming languages like Rust and Go, offers several advantages: - -1. **Explicit Error Handling**: All potential failures are visible in function signatures -2. **Composable Error Handling**: Errors can be transformed, chained, and recovered from systematically -3. **Serializable by Design**: Errors are plain objects that can cross any boundary -4. **Type-Safe Error Handling**: Full TypeScript support for tagged union error types and recovery patterns - -## Terminology - -### Key Concepts - -- **Error Type**: The actual error value (e.g., `ValidationError`, `NetworkError`) that follows the convention of ending with "Error" suffix -- **Err Data Structure**: The wrapper `{ error: E; data: null }` that contains an error type in the Result system -- **Result**: The union type `Ok | Err` that represents either success or failure - -## Tagged Union Error System - -### Why "Tagged" Errors? - -The error system uses **tagged unions** (also called discriminated unions), where the `name` property acts as a **tag** that allows TypeScript to: - -1. **Discriminate between error types** at compile time -2. **Enable exhaustive pattern matching** in switch statements -3. **Provide intelligent autocomplete** and type narrowing -4. **Catch missing error handling cases** at compile time - -```typescript -// The "name" property is the tag that discriminates between error types -// Note: Error types follow the convention of ending with "Error" suffix -// Additional fields are spread flat on the error object -type AppError = - | { name: "ValidationError"; message: string; field: string; value: unknown } - | { name: "NetworkError"; message: string; url: string; status: number } - | { name: "DatabaseError"; message: string; query: string; table: string }; - -// TypeScript can exhaustively check all cases -function handleAppError(error: AppError) { - switch (error.name) { // TypeScript knows this is the discriminant tag - case "ValidationError": - // TypeScript narrows to ValidationError type here - console.log(`Validation failed for field: ${error.field}`); - break; - case "NetworkError": - // TypeScript narrows to NetworkError type here - console.log(`Network error: ${error.status}`); - break; - case "DatabaseError": - // TypeScript narrows to DatabaseError type here - console.log(`Database error in table: ${error.table}`); - break; - // TypeScript will warn if we miss any cases! - } -} -``` - -## Creating Errors with defineErrors - -The recommended way to create typed errors is with `defineErrors`. Each key in the object becomes an error name, and the value is a factory function that receives fields and returns an object with `message` and any additional fields. - -### Different Function Shapes - -**Static (no fields):** -```typescript -const { RecorderBusyError, RecorderBusyErr } = defineErrors({ - RecorderBusyError: () => ({ - message: 'A recording is already in progress', - }), -}); - -RecorderBusyErr() // no args -``` - -**Cause-wrapping (carries the raw caught error):** -```typescript -const { PlaySoundError, PlaySoundErr } = defineErrors({ - PlaySoundError: ({ cause }: { cause: unknown }) => ({ - message: `Failed to play sound: ${extractErrorMessage(cause)}`, - cause, - }), -}); - -PlaySoundErr({ cause: error }) -``` - -Accept `cause: unknown` (the raw caught error) and call `extractErrorMessage` inside the factory's message template — not at the call site. This keeps call sites clean (`{ cause: error }`) and centralizes message extraction where the message is composed. The anti-pattern to avoid is **string literal unions** (`'a' | 'b' | 'c'`) acting as sub-discriminants. See [Anti-Pattern: Discriminated Union Inputs in Error Factories](#anti-pattern-discriminated-union-inputs-in-error-factories) below. - -#### Why `extractErrorMessage` belongs inside the constructor - -**Anti-pattern — transforming at the call site:** -```typescript -// Every call site must remember to call extractErrorMessage -try { playAudio(); } catch (error) { - return PlaySoundErr({ reason: extractErrorMessage(error) }); -} -``` - -**Preferred — constructor accepts raw `cause`:** -```typescript -// Call site just passes the raw error -try { playAudio(); } catch (error) { - return PlaySoundErr({ cause: error }); -} -``` - -- **The constructor owns the message template — it should also own the transformation.** `extractErrorMessage` is a message-formatting concern; it belongs where the message string is assembled, not scattered across every call site. -- **Raw `cause: unknown` preserves the original error for programmatic access.** Downstream code can inspect, log, or re-wrap the actual error object — not just a lossy string summary. -- **Call sites stay minimal:** `{ cause: error }` instead of `{ reason: extractErrorMessage(error) }`. -- **Mirrors Rust's `#[from]`**, where the enum variant handles the conversion from the source error type — callers just use `?`. - -**Structured (multiple fields):** -```typescript -const responseErrors = defineErrors({ - ResponseError: ({ status, reason }: { status: number; reason?: string }) => ({ - message: `HTTP ${status}${reason ? `: ${reason}` : ''}`, - status, - reason, - }), -}); -const { ResponseError, ResponseErr } = responseErrors; - -ResponseErr({ status: 404 }) -``` - -### Key Principles - -- `name` + `message` are the only built-in fields -- Additional fields spread **flat** on the error object (no nested `context` bag) -- `message` is always returned from the factory function — it is part of the return value, not a separate input -- `cause: unknown` is the recommended way to wrap caught errors — call `extractErrorMessage(cause)` inside the message template, not at the call site -- Only `name` is a reserved key — prevented by `NoReservedKeys` at compile time -- Multiple errors can be grouped in a single `defineErrors` call, or defined individually - -### Full Example - -```typescript -import { defineErrors, type InferError } from 'wellcrafted/error'; - -const clipboardErrors = defineErrors({ - ClipboardServiceError: ({ text, cause }: { text: string; cause?: unknown }) => ({ - message: 'Clipboard operation failed', - text, - cause, - }), -}); -const { ClipboardServiceError, ClipboardServiceErr } = clipboardErrors; - -type ClipboardServiceError = InferError; -``` - -### Usage at Call Sites - -At call sites, provide fields directly as a flat object. The factory function computes the message from the fields you pass: - -```typescript -export function createClipboardServiceExtension(): ClipboardService { - return { - setClipboardText: (text) => - tryAsync({ - try: () => navigator.clipboard.writeText(text), - catch: (error) => ClipboardServiceErr({ - text, - cause: error, - }), - }), - - writeTextToCursor: (text) => - trySync({ - try: () => writeTextToCursor(text), - catch: (error) => ClipboardServiceErr({ - text, - cause: error, - }), - }), - }; -} - -## Anti-Pattern: Discriminated Union Inputs in Error Factories - -When a `defineErrors` variant's input contains a string literal union field — such as `reason: 'a' | 'b' | 'c'` or `operation: 'read' | 'write'` — that field is acting as a **sub-discriminant**, duplicating the role that variant names already serve. This is a code smell. Split into separate variants instead. - -### Problems - -**1. Double narrowing.** Consumers must first narrow on `error.name`, then narrow again on the sub-discriminant field. This defeats the purpose of the tagged union pattern, where a single `switch` on `name` should give you everything you need: - -```typescript -// Consumers are forced into two levels of narrowing -if (error.name === 'InvalidAccelerator') { - if (error.reason === 'invalid_format') { - // now we finally know what happened - } -} -``` - -**2. Dishonest types.** Fields start becoming optional because "some reasons don't use that field". The type says `accelerator?: string`, but the real contract is "required when reason is `'invalid_format'`, meaningless otherwise." TypeScript cannot express this conditional relationship within a single variant, so the type lies about the shape of the data. - -**3. Message lookup tables.** A `const messages = { ... }` object inside the factory function is a strong signal that the variant is doing too much. Each lookup entry is really its own error with its own message template — it should be its own variant. - -### Example - -Before — a single variant with a string literal union acting as a sub-discriminant: - -```typescript -const ShortcutError = defineErrors({ - InvalidAccelerator: (input: { - reason: 'invalid_format' | 'no_key_code' | 'multiple_key_codes'; - accelerator?: string; - }) => { - const messages = { - invalid_format: `Invalid format: '${input.accelerator}'`, - no_key_code: 'No valid key code found', - multiple_key_codes: 'Multiple key codes not allowed', - }; - return { message: messages[input.reason], ...input }; - }, -}); -``` - -After — each case becomes its own variant with honest, minimal types: - -```typescript -const ShortcutError = defineErrors({ - InvalidFormat: ({ accelerator }: { accelerator: string }) => ({ - message: `Invalid accelerator format: '${accelerator}'`, - accelerator, - }), - NoKeyCode: () => ({ - message: 'No valid key code found in pressed keys', - }), - MultipleKeyCodes: () => ({ - message: 'Multiple key codes not allowed in accelerator', - }), -}); -``` - -Consumers now get full type information from a single `switch` on `error.name`, with no optional fields and no second level of narrowing. - -### Exception - -If the string literal field is genuinely metadata for logging or telemetry — and no consumer ever switches on it — keeping it as a field is fine. The test is straightforward: **does any consumer narrow on this field?** If yes, it should be a variant name. If no consumer ever branches on it, it is metadata, not a discriminant, and a field is the right place for it. - -## Error Classification Framework - -### By Origin: New vs. Bubbled-Up Errors - -Following principles from [Miguel Grinberg's error handling guide](https://blog.miguelgrinberg.com/post/the-ultimate-guide-to-error-handling-in-python), we classify errors by their origin: - -#### New Errors -Error types that your code detects and creates: - - -```typescript -import { defineErrors, type InferError } from "wellcrafted/error"; - -// Input validation error type -const validationErrors = defineErrors({ - ValidationError: ({ providedAge, validRange }: { providedAge: unknown; validRange: [number, number] }) => ({ - message: `Age must be a number between ${validRange[0]} and ${validRange[1]}`, - providedAge, - validRange, - }), -}); -const { ValidationError, ValidationErr } = validationErrors; -type ValidationError = InferError; - -function validateAge(age: unknown): Result { - if (typeof age !== "number" || age < 0 || age > 150) { - return ValidationErr({ - providedAge: age, validRange: [0, 150], - }); - } - return Ok(age); -} - -// Business logic error type -const businessErrors = defineErrors({ - BusinessError: ({ accountId, requestedAmount, availableBalance }: { accountId: string; requestedAmount: number; availableBalance: number }) => ({ - message: `Insufficient funds: requested ${requestedAmount}, available ${availableBalance}`, - accountId, - requestedAmount, - availableBalance, - }), -}); -const { BusinessError, BusinessErr } = businessErrors; -type BusinessError = InferError; - -function withdrawFunds(account: Account, amount: number): Result { - if (account.balance < amount) { - return BusinessErr({ - accountId: account.id, - requestedAmount: amount, - availableBalance: account.balance, - }); - } - - return Ok({ - ...account, - balance: account.balance - amount, - }); -} -``` - -#### Bubbled-Up Errors -Error types that your code receives from functions it calls: - -```typescript -import { defineErrors, type InferError } from "wellcrafted/error"; - -const storageErrors = defineErrors({ - StorageError: ({ userId, timestamp, cause }: { userId: string; timestamp: string; cause?: unknown }) => ({ - message: 'Failed to save user data', - userId, - timestamp, - cause, - }), -}); -const { StorageError, StorageErr } = storageErrors; -type StorageError = InferError; - -// Catching and re-wrapping external errors into typed error types -async function saveUserData(user: User): Promise> { - return await tryAsync({ - try: () => database.save(user), - catch: (error) => StorageErr({ - userId: user.id, - timestamp: new Date().toISOString(), - cause: error, // Preserve the original error as a field - }), - }); -} -``` - -### By Recoverability: What Should You Do? - -#### 1. Recoverable Errors (Handle Locally) - -Error types that your current function can meaningfully address: - -```typescript -const networkErrors = defineErrors({ - NetworkError: ({ url, attempt, maxRetries, cause }: { url: string; attempt: number; maxRetries: number; cause?: unknown }) => ({ - message: `Request failed (attempt ${attempt}/${maxRetries})`, - url, - attempt, - maxRetries, - cause, - }), -}); -const { NetworkError, NetworkErr } = networkErrors; -type NetworkError = InferError; - -async function fetchWithRetry( - url: string, - maxRetries = 3 -): Promise> { - let lastError: NetworkError | null = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - const result = await tryAsync({ - try: () => fetch(url).then(r => r.json()), - catch: (error) => NetworkErr({ - url, attempt, maxRetries, - cause: error, - }), - }); - - if (isOk(result)) { - return result; - } - - lastError = result.error; - - // Wait before retry (exponential backoff) - if (attempt < maxRetries) { - await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); - } - } - - // All retries exhausted — message template handles it - return NetworkErr({ - url, attempt: maxRetries, maxRetries, - cause: lastError ?? undefined, - }); -} -``` - -#### 2. Non-Recoverable Errors (Propagate Up) - -Error types that should be handled by a higher-level function: - -```typescript -const userValidationErrors = defineErrors({ - UserValidationError: ({ providedId }: { providedId: number }) => ({ - message: `User ID must be positive, got ${providedId}`, - providedId, - }), -}); -const { UserValidationError, UserValidationErr } = userValidationErrors; -type UserValidationError = InferError; - -// Just propagate database error types up - the HTTP handler will deal with them -async function getUser(id: number): Promise> { - // Validate input - if (id <= 0) { - return UserValidationErr({ providedId: id }); - } - - // Let database error types bubble up unchanged - const result = await database.findUser(id); - return result; // Result -} - -// HTTP handler deals with different error types appropriately -async function handleGetUser(req: Request): Promise { - const userId = parseInt(req.params.id); - const result = await getUser(userId); - - if (isErr(result)) { - switch (result.error.name) { - case "UserValidationError": - return new Response(JSON.stringify(result.error), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - case "DbError": - // Log the full error but return generic message - console.error("Database error:", result.error); - return new Response( - JSON.stringify({ message: "Internal server error" }), - { status: 500, headers: { "Content-Type": "application/json" }} - ); - } - } - - return new Response(JSON.stringify(result.data), { - headers: { "Content-Type": "application/json" } - }); -} -``` - -#### 3. Transformative Errors (Convert and Propagate) - -Error types that need to be converted to a more appropriate type for the calling context: - -```typescript -const dbErrors = defineErrors({ - DbError: ({ sql, cause }: { sql: string; cause?: unknown }) => ({ - message: 'Query execution failed', - sql, - cause, - }), -}); -const { DbError, DbErr } = dbErrors; -type DbError = InferError; - -// Low-level database function -async function executeQuery(sql: string): Promise> { - return await tryAsync({ - try: () => database.query(sql), - catch: (error) => DbErr({ - sql: sql.substring(0, 100), // Truncate for logging - cause: error, - }), - }); -} - -const userServiceErrors = defineErrors({ - UserServiceError: ({ userData, cause }: { userData: { name: string; email: string }; cause?: DbError }) => ({ - message: 'Failed to create user', - userData, - cause, - }), -}); -const { UserServiceError, UserServiceErr } = userServiceErrors; -type UserServiceError = InferError; - -// Higher-level user service transforms DB error types to domain error types -async function createUser(userData: UserData): Promise> { - const result = await executeQuery( - "INSERT INTO users (name, email) VALUES (?, ?)", - [userData.name, userData.email] - ); - - if (isErr(result)) { - // Transform database error type to domain-specific error type - return UserServiceErr({ - userData: { name: userData.name, email: userData.email }, - cause: result.error, - }); - } - - return Ok(result.data[0]); -} -``` - -## Advanced Patterns - -### Error Aggregation - -When you need to collect multiple errors: - -```typescript -type ValidationResult = Result; - -function validateUser(data: unknown): ValidationResult { - const errors: ValidationError[] = []; - const result: Partial = {}; - - // Validate each field - const nameResult = validateName(data.name); - if (isErr(nameResult)) { - errors.push(nameResult.error); - } else { - result.name = nameResult.data; - } - - const emailResult = validateEmail(data.email); - if (isErr(emailResult)) { - errors.push(emailResult.error); - } else { - result.email = emailResult.data; - } - - const ageResult = validateAge(data.age); - if (isErr(ageResult)) { - errors.push(ageResult.error); - } else { - result.age = ageResult.data; - } - - if (errors.length > 0) { - return Err(errors); - } - - return Ok(result as User); -} -``` - -### Error Field Enrichment - -Add fields as errors bubble up through layers: - -```typescript -const fileErrors = defineErrors({ - StorageError: ({ path, contentLength, cause }: { path: string; contentLength: number; cause?: unknown }) => ({ - message: 'File write failed', - path, - contentLength, - cause, - }), -}); -const { StorageError, StorageErr } = fileErrors; -type StorageError = InferError; - -// Storage layer -async function writeFile(path: string, content: string): Promise> { - return await tryAsync({ - try: () => fs.writeFile(path, content), - catch: (error) => StorageErr({ - path, - contentLength: content.length, - cause: error, - }), - }); -} - -const documentErrors = defineErrors({ - DocumentServiceError: ({ documentId, documentTitle, userId, attemptTimestamp, documentSize, cause }: { - documentId: string; - documentTitle: string; - userId: string; - attemptTimestamp: string; - documentSize: number; - cause?: StorageError; - }) => ({ - message: `Failed to save document ${documentId}`, - documentId, - documentTitle, - userId, - attemptTimestamp, - documentSize, - cause, - }), -}); -const { DocumentServiceError, DocumentServiceErr } = documentErrors; -type DocumentServiceError = InferError; - -// Service layer - adds business fields -async function saveDocument(doc: Document): Promise> { - const filePath = `/documents/${doc.id}.json`; - const content = JSON.stringify(doc); - - const result = await writeFile(filePath, content); - if (isErr(result)) { - return DocumentServiceErr({ - documentId: doc.id, - documentTitle: doc.title, - userId: doc.authorId, - attemptTimestamp: new Date().toISOString(), - documentSize: content.length, - cause: result.error, - }); - } - - return Ok(undefined); -} - -// API layer - adds request context by spreading additional info -async function handleSaveDocument(req: Request): Promise { - const document = await req.json(); - const result = await saveDocument(document); - - if (isErr(result)) { - // Log the error with its flat fields - console.error("Document save failed:", { - ...result.error, - // Add request-specific info for logging - requestId: req.headers.get("x-request-id"), - userAgent: req.headers.get("user-agent"), - timestamp: new Date().toISOString(), - }); - - return new Response( - JSON.stringify({ error: "Failed to save document" }), - { status: 500, headers: { "Content-Type": "application/json" }} - ); - } - - return new Response(JSON.stringify({ success: true })); -} -``` - -### Parallel Error Handling - -When dealing with multiple independent operations: - -```typescript -import { partitionResults } from "wellcrafted/result"; - -const processingErrors = defineErrors({ - ProcessingError: ({ totalFiles, successCount, failureCount, failures, successfulPaths, cause }: { - totalFiles: number; - successCount: number; - failureCount: number; - failures: unknown[]; - successfulPaths: string[]; - cause?: unknown; - }) => ({ - message: `Failed to process ${failureCount} out of ${totalFiles} files`, - totalFiles, - successCount, - failureCount, - failures, - successfulPaths, - cause, - }), -}); -const { ProcessingError, ProcessingErr } = processingErrors; -type ProcessingError = InferError; - -async function processMultipleFiles(paths: string[]): Promise> { - // Process all files in parallel - const results = await Promise.all( - paths.map(path => processFile(path)) - ); - - // Partition successes and failures - const { oks, errs } = partitionResults(results); - - if (errs.length > 0) { - return ProcessingErr({ - totalFiles: paths.length, - successCount: oks.length, - failureCount: errs.length, - failures: errs.map(err => err.error), - successfulPaths: oks.map((ok, i) => paths[results.indexOf(ok)]), - cause: errs[0]?.error, // Use first error as primary cause - }); - } - - return Ok(oks.map(ok => ok.data)); -} -``` - -### Circuit Breaker Pattern - -Implement resilience patterns with typed errors: - -```typescript -type CircuitState = "closed" | "open" | "half-open"; - -const circuitErrors = defineErrors({ - CircuitBreakerError: ({ state, failureCount, lastFailureTime, resetTimeout }: { - state: CircuitState; - failureCount: number; - lastFailureTime: number; - resetTimeout: number; - }) => ({ - message: `Circuit breaker is ${state} after ${failureCount} failures`, - state, - failureCount, - lastFailureTime, - resetTimeout, - }), -}); -const { CircuitBreakerError, CircuitBreakerErr } = circuitErrors; -type CircuitBreakerError = InferError; - -class CircuitBreaker { - private state: CircuitState = "closed"; - private failureCount = 0; - private lastFailureTime = 0; - - constructor( - private readonly operation: () => Promise>, - private readonly failureThreshold = 5, - private readonly resetTimeout = 60000 - ) {} - - async execute(): Promise> { - if (this.state === "open") { - if (Date.now() - this.lastFailureTime < this.resetTimeout) { - return CircuitBreakerErr({ - state: this.state, - failureCount: this.failureCount, - lastFailureTime: this.lastFailureTime, - resetTimeout: this.resetTimeout, - }); - } - this.state = "half-open"; - } - - const result = await this.operation(); - - if (isErr(result)) { - this.onFailure(); - return result; - } - - this.onSuccess(); - return result; - } - - private onSuccess(): void { - this.failureCount = 0; - this.state = "closed"; - } - - private onFailure(): void { - this.failureCount++; - this.lastFailureTime = Date.now(); - - if (this.failureCount >= this.failureThreshold) { - this.state = "open"; - } - } -} -``` - -## Error Monitoring and Observability - -### Structured Logging - -```typescript -interface ErrorLogEntry { - level: "error"; - message: string; - error: BaseError; - timestamp: string; - traceId?: string; - spanId?: string; -} - -function logError(error: BaseError, traceId?: string): void { - const logEntry: ErrorLogEntry = { - level: "error", - message: error.message, - error, - timestamp: new Date().toISOString(), - traceId, - }; - - console.error(JSON.stringify(logEntry)); - - // Send to monitoring service - monitoring.recordError(logEntry); -} -``` - -### Error Metrics - -```typescript -function recordErrorMetrics(error: BaseError): void { - // Increment error counter by type - metrics.increment("errors.total", { - errorType: error.name, - }); - - // Record error rate - metrics.histogram("errors.rate", 1, { - errorType: error.name, - }); -} -``` - -## Testing Error Scenarios - -### Unit Testing Errors - -```typescript -import { describe, it, expect } from "vitest"; - -describe("file operations", () => { - it("should return FsError when file does not exist", async () => { - const result = await readFileContent("/nonexistent/file.txt"); - - expect(isErr(result)).toBe(true); - if (isErr(result)) { - expect(result.error.name).toBe("FsError"); - expect(result.error.message).toContain("Failed to read file content"); - expect(result.error.path).toBe("/nonexistent/file.txt"); - expect(result.error.cause).toBeDefined(); - } - }); - - it("should handle error propagation correctly", async () => { - const result = await initializeApp(); - - if (isErr(result)) { - // Should be either ValidationError or FsError - expect(["ValidationError", "FsError"]).toContain(result.error.name); - } - }); -}); -``` - -### Integration Testing with Error Injection - -```typescript -// Mock that can be configured to fail -class MockDatabase { - private shouldFail = false; - - configureFail(shouldFail: boolean): void { - this.shouldFail = shouldFail; - } - - async findUser(id: number): Promise> { - if (this.shouldFail) { - return DbErr({ - sql: `SELECT * FROM users WHERE id = ${id}`, - cause: new Error("Connection timeout"), - }); - } - - return Ok({ id, name: "Test User", email: "test@example.com" }); - } -} - -describe("user service with database failures", () => { - it("should handle database failures gracefully", async () => { - const mockDb = new MockDatabase(); - mockDb.configureFail(true); - - const result = await getUserService.getUser(123); - - expect(isErr(result)).toBe(true); - if (isErr(result)) { - expect(result.error.name).toBe("DbError"); - } - }); -}); -``` - -## Migration Strategies - -### Gradual Migration from Throwing Errors - -```typescript -const migrationErrors = defineErrors({ - ValidationError: ({ input, cause }: { input: string; cause?: unknown }) => ({ - message: 'Input is required', - input, - cause, - }), -}); -const { ValidationError, ValidationErr } = migrationErrors; -type ValidationError = InferError; - -// Legacy function that throws -function legacyFunction(input: string): string { - if (!input) { - throw new Error("Input is required"); - } - return input.toUpperCase(); -} - -// Wrapper that converts to Result -function safeLegacyFunction(input: string): Result { - return trySync({ - try: () => legacyFunction(input), - catch: (error) => ValidationErr({ - input, - cause: error, - }), - }); -} - -// New function using Result pattern directly -function newFunction(input: string): Result { - if (!input) { - return ValidationErr({ input }); - } - return Ok(input.toUpperCase()); -} -``` - -### Interoperability with Promise-based APIs - -```typescript -// Convert Result to Promise (for libraries expecting promises) -function resultToPromise(result: Result): Promise { - if (isOk(result)) { - return Promise.resolve(result.data); - } - - // Convert error object to Error instance for promise rejection - const error = new Error(result.error.message); - error.name = result.error.name; - - return Promise.reject(error); -} - -// Convert Promise to Result (for integrating promise-based libraries) -async function promiseToResult( - promise: Promise, - catch: (error: unknown) => Err -): Promise> { - return await tryAsync({ - try: () => promise, - catch, - }); -} -``` - -This comprehensive approach to error handling provides a robust foundation for building reliable, maintainable applications with excellent observability and debugging capabilities. \ No newline at end of file diff --git a/NAMING_CONVENTION.md b/NAMING_CONVENTION.md deleted file mode 100644 index d3fd725..0000000 --- a/NAMING_CONVENTION.md +++ /dev/null @@ -1,120 +0,0 @@ -# Error Naming Convention - -This document establishes the clear distinction between error-related concepts in the Result library. - -## Key Concepts - -### 1. **Error Types** (defined via `defineErrors`, extracted via `InferErrors`/`InferError`) -- The actual error values/types that contain error information -- Defined as namespaced variants using `defineErrors`, not as standalone type aliases -- The `name` field contains the SHORT variant name (e.g., `"Validation"`, not `"ValidationError"`) -- the namespace variable provides domain context -- These are the `E` in `Result` and `Err` -- Error objects are flat: fields from the factory return are spread directly alongside `name` and `message` - -```typescript -// Define a namespace of related errors -const UserError = defineErrors({ - Validation: ({ field, value }: { field: string; value: string }) => ({ - message: `Invalid ${field}: ${value}`, - field, - value, - }), - NotFound: ({ userId }: { userId: string }) => ({ - message: `User ${userId} not found`, - userId, - }), -}); - -// Extract types from the namespace -type UserError = InferErrors; -// Union of all variants: { name: "Validation"; message: string; field: string; value: string } | { name: "NotFound"; ... } - -// Extract a single variant -type ValidationError = InferError; -``` - -### 2. **Err Data Structure** -- The wrapper that contains an error type in the Result system -- Structure: `{ error: E; data: null }` -- One of the two variants of `Result` (the other being `Ok`) -- `defineErrors` factories return `Err<...>` directly -- no manual `Err()` wrapping needed - -```typescript -// Factory returns Err<...> directly -const result = UserError.Validation({ field: "email", value: "" }); -// result is Err<{ name: "Validation"; message: "Invalid email: "; field: "email"; value: "" }> -``` - -### 3. **Result** -- The union type `Ok | Err` representing either success or failure -- The top-level type that encapsulates both success and error scenarios - -## Function Naming - -### Key Names -- `catch` (not `mapError` or `mapErr`) -- the catch handler in trySync/tryAsync -- `InferErrors` -- extracts the union of all error types from a defineErrors namespace -- `InferError` -- extracts a single error variant type by key -- `isErr` -- checks if something is an Err data structure -- `isOk` -- checks if something is an Ok data structure - -### Function Signatures -```typescript -trySync({ - try: () => T; - catch: (error: unknown) => Err; // Must return Err, not bare E -}): Result - -tryAsync({ - try: () => Promise; - catch: (error: unknown) => Err; // Must return Err, not bare E -}): Promise> -``` - -## Documentation Terminology - -### Consistent Language -- "Error type" or "error value" -- refers to the actual error (`{ name: "Validation"; message: string; field: string; ... }`) -- "Error namespace" -- refers to the object returned by `defineErrors` containing factory methods -- "Err data structure" -- refers to the wrapper `{ error: E; data: null }` -- "Result" -- refers to the union type that can be either Ok or Err - -### Examples -```typescript -// Define error namespace with factories -const UserError = defineErrors({ - Validation: ({ field, value }: { field: string; value: string }) => ({ - message: `Invalid ${field}: ${value}`, - field, - value, - }), -}); - -// Extract types -type UserError = InferErrors; - -// Factories return Err<...> directly -- ready for catch handlers -const result = trySync({ - try: () => JSON.parse(input), - catch: () => UserError.Validation({ field: "json", value: input }), -}); - -// Type guards work on the data structure level -if (isErr(result)) { - // result.error is flat: { name: "Validation", message: "...", field: "json", value: "..." } - console.log(result.error.name); // "Validation" - console.log(result.error.field); // "json" -- flat, no context nesting - console.log(result.error.message); // "Invalid json: ..." -} -``` - -## Error Naming Rules - -1. **Short variant names**: `Validation`, `NotFound`, `Timeout` -- NOT `ValidationError`. The namespace variable (`UserError`, `HttpError`) provides domain context. -2. **Use PascalCase**: `NotFound`, not `not_found` or `NOT_FOUND` -3. **Be specific**: `Authentication` vs generic `Service` -4. **Group by domain**: `UserError.Validation`, `HttpError.Timeout`, `DbError.Connection` -5. **Namespace as the "Error" suffix**: The variable name carries the "Error" suffix (`UserError`), individual variants do not -6. **No string literal unions as sub-discriminants**: If a variant input has a field like `reason: 'a' | 'b' | 'c'`, each literal should be its own variant instead. The variant name is the discriminant — fields should carry data, not act as a second tag to switch on - -This convention provides clear semantics and helps developers understand the distinction between the error data itself and the data structures that contain it. diff --git a/README.md b/README.md index f5b854d..c10be62 100644 --- a/README.md +++ b/README.md @@ -9,84 +9,141 @@ Tagged errors and Result types as plain objects. < 2KB, zero dependencies. -Most Result libraries hand you a container and leave the error type as an exercise. You get `Ok` and `Err` but nothing to help you define, compose, or serialize the errors themselves. So you end up with string literals, ad-hoc objects, or class hierarchies that break the moment you call `JSON.stringify`. +`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 takes the opposite approach: start with the errors. `defineErrors` gives you typed, serializable, composable error variants inspired by Rust's [thiserror](https://docs.rs/thiserror). The Result type is just `{ data, error }` destructuring — the same 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`. That's it. +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, extractErrorMessage, type InferErrors } from "wellcrafted/error"; -import { tryAsync, Ok, type Result } from "wellcrafted/result"; +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, + }), +}); -// Define domain errors — all variants in one call -const UserError = defineErrors({ - AlreadyExists: ({ email }: { email: string }) => ({ - message: `User ${email} already exists`, - email, +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, }), - CreateFailed: ({ email, cause }: { email: string; cause: unknown }) => ({ - message: `Failed to create user ${email}: ${extractErrorMessage(cause)}`, - email, + Unexpected: ({ cause }: { cause: unknown }) => ({ + message: extractErrorMessage(cause), cause, }), }); -type UserError = InferErrors; -// ^? { name: "AlreadyExists"; message: string; email: string } -// | { name: "CreateFailed"; message: string; email: string; cause: unknown } +export type ElevenLabsError = InferErrors; -// Each factory returns Err<...> directly — no wrapping needed -async function createUser(email: string): Promise> { - const existing = await db.findByEmail(email); - if (existing) return UserError.AlreadyExists({ email }); +async function transcribe( + audio: Blob, + apiKey: string, +): Promise> { + 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: () => db.users.create({ email }), - catch: (error) => UserError.CreateFailed({ email, cause: error }), + try: () => callElevenLabs(apiKey, audio), // may throw + catch: (cause) => ElevenLabsError.Unexpected({ cause }), }); } +``` -// Discriminate with switch — TypeScript narrows automatically -const { data, error } = await createUser("alice@example.com"); +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 "AlreadyExists": console.log(error.email); break; - case "CreateFailed": console.log(error.email); break; - // ^ TypeScript knows exactly which fields exist + 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 ``` -## Install +Three things are doing the work here, and the rest of this README is just those three things. -```bash -npm install wellcrafted -``` +## Define your errors -## Why define errors at all? +You can put any value in `Ok` and `Err`. So why `defineErrors`? -You can use `Ok` and `Err` with any value. So why bother with `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. -Because in practice, errors aren't random. Every service has a handful of things that can go wrong, and you want to enumerate them upfront. A user service has `AlreadyExists`, `CreateFailed`, `InvalidEmail`. An HTTP client has `Connection`, `Timeout`, `Response`. These are logical groups — the error vocabulary for a domain. Rust codified this with [thiserror](https://docs.rs/thiserror). `defineErrors` brings the same pattern to TypeScript, but outputs plain objects instead of classes. +Enumerating the set up front is what makes the rest pay off. The union flows into your `Result` 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. -**Errors are data, not classes.** Plain frozen objects with no prototype chain. `JSON.stringify` just works — no `stack` property eating up your logs, no `instanceof` checks that break across package boundaries. This matters anywhere errors cross a serialization boundary: Web Workers, server actions, sync engines, IPC. The error you create is the error that arrives. +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` signatures. -**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 naturally. +```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; +// ^? { name: "AlreadyExists"; message: string; email: string } +// | { name: "CreateFailed"; message: string; email: string; cause: unknown } +``` -**Discriminated unions for free.** `switch (error.name)` gives you full TypeScript narrowing. No `instanceof`, no type predicates, no manual union types. Add a new variant and every consumer that switches gets a compile error until they handle it. +Two properties make this pay off: -## Wrapping unsafe code +**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. -`trySync` and `tryAsync` turn throwing operations into `Result` types. The `catch` handler receives the raw error and returns an `Err<...>` from your `defineErrors` factories. +**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. -```typescript -import { trySync, tryAsync } from "wellcrafted/result"; +## Wrap throwing code -const JsonError = defineErrors({ - ParseFailed: ({ input, cause }: { input: string; cause: unknown }) => ({ - message: `Invalid JSON: ${extractErrorMessage(cause)}`, - input: input.slice(0, 100), - cause, - }), -}); +`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({ @@ -101,135 +158,88 @@ const { data, error } = await tryAsync({ }); ``` -When `catch` returns `Ok(fallback)` instead of `Err`, the return type narrows to `Ok` — no error checking needed: +When `catch` returns `Ok(fallback)` instead of an error, there is no error branch left: the return type narrows to `Ok`, so `error` is always `null` and you can skip the check. ```typescript -const { data: parsed } = trySync({ - try: (): unknown => JSON.parse(riskyJson), - catch: () => Ok([]), +const { data: items } = trySync({ + try: (): string[] => JSON.parse(riskyJson), + catch: () => Ok([] as string[]), // recovered; there is no error to check }); -// parsed is always defined — the catch recovered ``` -## Composing errors across layers +## Compose across layers -This is where the pattern pays off. Each layer defines its own error vocabulary; inner errors become `cause` fields, and `extractErrorMessage` formats them inside the factory so call sites stay clean. +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 -// Service layer: domain errors wrap raw failures via cause -const UserServiceError = defineErrors({ - NotFound: ({ userId }: { userId: string }) => ({ - message: `User ${userId} not found`, - userId, - }), - FetchFailed: ({ userId, cause }: { userId: string; cause: unknown }) => ({ - message: `Failed to fetch user ${userId}: ${extractErrorMessage(cause)}`, - userId, - cause, - }), -}); -type UserServiceError = InferErrors; - async function getUser(userId: string): Promise> { - const response = await tryAsync({ + const { data: response, error } = await tryAsync({ try: () => fetch(`/api/users/${userId}`), catch: (cause) => UserServiceError.FetchFailed({ userId, cause }), - // raw fetch error becomes cause ^^^^^ - }); - if (response.error) return response; - - if (response.data.status === 404) return UserServiceError.NotFound({ userId }); - - return tryAsync({ - try: () => response.data.json() as Promise, - catch: (cause) => UserServiceError.FetchFailed({ userId, cause }), + // raw fetch error becomes cause ^^^^^ }); -} + if (error) return Err(error); // propagate as-is -// API handler: maps domain errors to HTTP responses -async function handleGetUser(request: Request, userId: string) { - const { data, error } = await getUser(userId); - - if (error) { - switch (error.name) { - case "NotFound": - return Response.json({ error: error.message }, { status: 404 }); - case "FetchFailed": - return Response.json({ error: error.message }, { status: 502 }); - } - } - - return Response.json(data); + if (response.status === 404) return UserServiceError.NotFound({ userId }); + return Ok(await response.json()); } ``` -The full error chain is JSON-serializable at every level. Log it, send it over the wire, display it in a toast. The structure survives. +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 a simple discriminated union: +The foundation is one discriminated union: ```typescript -import { Ok, Err, trySync, tryAsync, type Result } from "wellcrafted/result"; - -type Ok = { data: T; error: null }; -type Err = { error: E; data: null }; +type Ok = { data: T; error: null }; +type Err = { error: E; data: null }; type Result = Ok | Err; ``` -Check `error` first, and TypeScript narrows `data` automatically: +Check `error` first and TypeScript narrows `data` for you: ```typescript const { data, error } = await someOperation(); -if (error) { - // error is E, data is null - return; -} +if (error) return; // error is E, data is null // data is T, error is null ``` -## Also in the box +`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`. -### Brand Types +### Exhaustiveness -Create distinct types from primitives so TypeScript catches mix-ups at compile time. Zero runtime footprint — purely a type utility. +`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 -import type { Brand } from "wellcrafted/brand"; - -type UserId = string & Brand<"UserId">; -type OrderId = string & Brand<"OrderId">; - -function getUser(id: UserId) { /* ... */ } - -const userId = "abc" as UserId; -const orderId = "xyz" as OrderId; -getUser(userId); // compiles -getUser(orderId); // type error +switch (error.name) { + case "NotFound": return notFound(); + case "FetchFailed": return badGateway(); + default: + error satisfies never; // add a variant and this line fails to compile +} ``` -### Query Integration +Plain TypeScript does not enforce exhaustive `switch` on its own; this one line is how you opt in. -TanStack Query factories with `.options` for reactive components and explicit imperative helpers for event handlers. +## What you give up -```typescript -import { createQueryFactories } from "wellcrafted/query"; +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. -const { defineQuery, defineMutation } = createQueryFactories(queryClient); +In exchange, the whole API is `{ data, error }`, `async/await`, and `switch`: no new runtime, no generators, no pipe operators to learn. -const userQuery = defineQuery({ - queryKey: ["users", userId], - queryFn: () => getUser(userId), // returns Result -}); +## Also in the box -// Reactive: pass to useQuery (React) or createQuery (Svelte) -const query = createQuery(() => userQuery.options); +Each lives behind its own subpath import, so you pay for only what you use. -// Imperative: choose the query read policy explicitly -const { data, error } = await userQuery.fetch(); -``` +- `wellcrafted/brand`: `Brand` 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. -## Comparison +## How it compares | | wellcrafted | neverthrow | better-result | fp-ts | Effect | |---|---|---|---|---|---| @@ -239,89 +249,33 @@ const { data, error } = await userQuery.fetch(); | Bundle size | < 2KB | ~5KB | ~2KB | ~30KB | ~50KB | | Syntax | async/await | Method chains | Method chains + generators | Pipe operators | Generators | -Every Result library gives you a container. wellcrafted gives you what goes inside it — then gets out of the way. - -## Philosophy - -wellcrafted is deliberately idiomatic to JavaScript. The `{ data, error }` shape isn't novel — it's the same pattern used by Supabase, SvelteKit load functions, and TanStack Query. We chose it because it's already familiar, already destructurable, and requires zero new mental models. - -The same principle applies throughout: async/await instead of generators, `switch` instead of `.match()`, plain objects instead of class hierarchies. The best abstractions are the ones your team already knows. wellcrafted adds type-safe error definition on top of patterns that JavaScript developers use every day — it doesn't ask you to learn a new programming paradigm to handle errors. - -## API Reference - -### Error functions - -- **`defineErrors(config)`** — define multiple error factories in a single call. Each key becomes a variant; the value is a factory returning `{ message, ...fields }`. Every factory returns `Err<...>` directly. -- **`extractErrorMessage(error)`** — extract a readable string from any `unknown` error value. - -### Error types - -- **`InferErrors`** — extract union of all error types from a `defineErrors` return value. -- **`InferError`** — extract a single variant's error type from one factory. - -### Result functions +Every Result library hands you a container. wellcrafted hands you what goes inside it, then gets out of the way. -- **`Ok(data)`** — create a success result -- **`Err(error)`** — create a failure result -- **`trySync({ try, catch })`** — wrap a synchronous throwing operation -- **`tryAsync({ try, catch })`** — wrap an async throwing operation -- **`isOk(result)` / `isErr(result)`** — type guards -- **`unwrap(result)`** — extract data or throw error -- **`resolve(value)`** — handle values that may or may not be Results -- **`partitionResults(results)`** — split an array of Results into separate ok/err arrays +## API at a glance -### Query functions +From `wellcrafted/result`: -- **`createQueryFactories(client)`** — create query/mutation factories for TanStack Query -- **`defineQuery(options)`**: define a query with `.options`, `.fetch()`, and `.ensure()` -- **`defineMutation(options)`**: define a callable mutation with `.options` for hooks +- `Ok(data)` / `Err(error)`: construct a success or failure +- `trySync` / `tryAsync`: wrap a throwing operation, sync or async +- `Result`: the `Ok | Err` union -### Standard Schema +From `wellcrafted/error`: -- **`ResultSchema(dataSchema, errorSchema)`** — [Standard Schema](https://github.com/standard-schema/standard-schema) wrapper for Result types, interoperable with any validator that supports the spec. +- `defineErrors(config)`: define a namespace of error variant factories +- `extractErrorMessage(value)`: pull a readable string out of any `unknown` +- `InferErrors`: the union of all variants; `InferError`: one variant -### Other types +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). -- **`Result`** — union of `Ok | Err` -- **`Brand`** — branded type wrapper for distinct primitives +## Teach your AI agent -## AI Agent Skills - -If you use an AI coding agent (Claude Code, Cursor, etc.), teach it how to use wellcrafted correctly: +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 5 skills that teach your agent the patterns, anti-patterns, and API conventions: - -| Skill | What it teaches | -| --- | --- | -| `define-errors` | `defineErrors` variants, `extractErrorMessage`, `InferErrors`/`InferError` type extraction | -| `result-types` | `Ok`, `Err`, `trySync`/`tryAsync`, the `{ data, error }` destructuring pattern | -| `query-factories` | `createQueryFactories`, `defineQuery`/`defineMutation`, reactive options, and imperative helpers | -| `branded-types` | `Brand`, brand constructor pattern, when to add runtime validation | -| `patterns` | Architectural style guide: control flow, factory composition, service layers, error composition | - -Skills work with any agent that supports [`npx skills`](https://www.npmjs.com/package/skills). Install once, update with `npx skills update`. - - -## Development Setup - -### AI Agent Skills - -AI agent skills are managed via [`npx skills`](https://www.npmjs.com/package/skills), sourced from [Epicenter](https://github.com/EpicenterHQ/epicenter). Only skills relevant to wellcrafted's domain are installed. - -```bash -# Install skills (already committed, but can be refreshed) -npx skills add EpicenterHQ/epicenter --skill error-handling --skill define-errors -a claude-code -y - -# Update all installed skills -npx skills update - -# List installed skills -npx skills list -``` +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 diff --git a/docs/case-studies/whispering-architecture.mdx b/docs/case-studies/whispering-architecture.mdx deleted file mode 100644 index 5314ae9..0000000 --- a/docs/case-studies/whispering-architecture.mdx +++ /dev/null @@ -1,445 +0,0 @@ -# Battle-Tested at Production Scale: Whispering Architecture - -> **22,824 lines of production TypeScript | 97% code sharing | Zero runtime crashes** - -Whispering is a production desktop/web application for audio transcription that has battle-tested wellcrafted across complex real-world scenarios. This case study shows how wellcrafted's patterns scale from simple services to multi-platform applications. - -## Production Impact - -| Metric | Value | Impact | -|--------|-------|---------| -| **Total TypeScript code** | 22,824 lines | Large-scale production usage | -| **Code sharing** | 97% (22,139/22,824) | Platform-agnostic business logic | -| **Platform-specific code** | 3% (685 lines) | Minimal duplication overhead | -| **Runtime crashes** | 0 | Robust error handling eliminates crashes | - -## The Architecture: Three Layers of Type Safety - -``` -┌─────────────┐ ┌─────────────┐ ┌──────────────┐ -│ UI │ --> │ RPC/Query │ --> │ Services │ -│ Components │ │ Layer │ │ (Pure) │ -└─────────────┘ └─────────────┘ └──────────────┘ - ↑ │ - └────────────────────┘ - Reactive Updates -``` - -### Layer 1: Services - Pure Business Logic - -Services are pure functions with explicit dependencies and consistent error handling: - -```typescript -import { defineErrors, type InferErrors } from 'wellcrafted/error'; -import { tryAsync, Ok } from 'wellcrafted/result'; - -const TranscriptionError = defineErrors({ - ApiKeyMissing: () => ({ - message: 'Please enter your OpenAI API key in settings.', - action: { type: 'link', label: 'Add API key', href: '/settings/transcription' }, - }), - Authentication: () => ({ - message: 'Your API key appears to be invalid or expired.', - action: { type: 'link', label: 'Update API key', href: '/settings/transcription' }, - }), - ApiCall: ({ status, body }: { status: number; body: string }) => ({ - message: `OpenAI API error (${status}): ${body}`, - status, - }), -}); -type TranscriptionError = InferErrors; - -export function createOpenaiTranscriptionService() { - return { - async transcribe(audioBlob: Blob, options: TranscribeOptions) { - if (!options.apiKey) { - return TranscriptionError.ApiKeyMissing(); - } - - const { data: transcription, error: apiError } = await tryAsync({ - try: () => new OpenAI({ apiKey: options.apiKey }).audio.transcriptions.create({ - file: audioBlob, - model: options.modelName, - language: options.outputLanguage !== 'auto' ? options.outputLanguage : undefined, - }), - catch: (error) => { - if (!(error instanceof OpenAI.APIError)) throw error; - if (error.status === 401) return TranscriptionError.Authentication(); - return TranscriptionError.ApiCall({ status: error.status ?? 0, body: error.message }); - }, - }); - - if (apiError) return apiError; - return Ok(transcription.text.trim()); - }, - }; -} -``` - -**Key service patterns:** - -- **Factory functions instead of classes**: Simpler testing and dependency injection -- **Explicit parameters**: No hidden global state -- **Result types everywhere**: Every operation can fail gracefully -- **`defineErrors` namespacing**: Related errors grouped under one namespace - -### Layer 2: Platform Abstraction with Zero Overhead - -The service layer handles platform differences through build-time dependency injection: - -```typescript -const ClipboardError = defineErrors({ - Write: ({ text, platform }: { text: string; platform: string }) => ({ - message: `Failed to write to clipboard on ${platform}`, - text, - platform, - }), -}); - -// Platform detection happens once at build time -export const ClipboardServiceLive = window.__TAURI_INTERNALS__ - ? createClipboardServiceDesktop() - : createClipboardServiceWeb(); - -export function createClipboardServiceDesktop(): ClipboardService { - return { - async setClipboardText(text: string) { - return tryAsync({ - try: () => writeText(text), - catch: () => ClipboardError.Write({ text, platform: 'desktop' }), - }); - }, - }; -} - -export function createClipboardServiceWeb(): ClipboardService { - return { - async setClipboardText(text: string) { - return tryAsync({ - try: () => navigator.clipboard.writeText(text), - catch: () => ClipboardError.Write({ text, platform: 'web' }), - }); - }, - }; -} -``` - -**This pattern enables:** -- **97% code sharing**: Business logic is completely platform-agnostic -- **Zero runtime overhead**: No runtime checks or polymorphism -- **Type safety**: Same interfaces guarantee API compatibility - -### Layer 3: Query Layer - Reactive Bridge - -The query layer bridges pure services with reactive UI state: - -```typescript -import { createQueryFactories } from 'wellcrafted/query'; - -export const { defineQuery, defineMutation } = createQueryFactories(queryClient); - -export const recorder = { - startRecording: defineMutation({ - mutationKey: ['recorder', 'startRecording'], - mutationFn: async ({ toastId }: { toastId: string }) => { - const recordingId = nanoid(); - - const params = { - selectedDeviceId: settings.value['recording.manual.selectedDeviceId'], - recordingId, - ...(settings.value['recording.backend'] === 'browser' - ? { platform: 'web' as const, bitrateKbps: settings.value['recording.navigator.bitrateKbps'] } - : { platform: 'desktop' as const, outputFolder: settings.value['recording.desktop.outputFolder'] } - ), - }; - - return recorderService().startRecording(params, { - sendStatus: (options) => notify.loading({ id: toastId, ...options }), - }); - }, - onSettled: () => queryClient.invalidateQueries({ queryKey: ['recorder', 'currentRecordingId'] }), - }), -}; -``` - -**Query layer responsibilities:** -- **Settings injection**: Bridges reactive settings with pure services -- **Cache management**: Optimistic updates and invalidation -- **Reactivity**: Connects TanStack Query with wellcrafted Result types - -## The Dual Interface Pattern - -Every operation provides both reactive and imperative interfaces: - -### Reactive Interface - Automatic State Management - -```svelte - - -{#if recordings.isPending} -
Loading recordings...
-{:else if recordings.error} -
Error: {recordings.error.message}
-{:else if recordings.data} - {#each recordings.data as recording} - - {/each} -{/if} -``` - -### Imperative Interface - Direct Execution - -```typescript -// Event handlers - lightweight and fast -async function handleDelete(recordingId: string) { - const { data, error } = await rpc.recordings.deleteRecording(recordingId); - if (error) { - notify.error({ title: 'Failed to delete recording', description: error.message }); - return; - } -} - -// Sequential operations without reactive overhead -async function stopAndTranscribe() { - const { data: blob, error } = await rpc.recorder.stopRecording({ toastId }); - if (error) return; - - const { data: recording } = await rpc.recordings.createRecording({ blob, timestamp: new Date() }); - await rpc.transcription.transcribeRecording(recording); -} -``` - -## Error Handling That Actually Works - -### Error Namespaces with defineErrors - -```typescript -const RecorderError = defineErrors({ - AlreadyRecording: () => ({ - message: 'A recording is already in progress. Stop the current recording first.', - }), - DeviceAccess: ({ deviceId }: { deviceId: string }) => ({ - message: `Failed to access audio device: ${deviceId}`, - deviceId, - }), - Encoding: ({ format }: { format: string }) => ({ - message: `Failed to encode audio as ${format}`, - format, - }), -}); -type RecorderError = InferErrors; - -// Service layer: domain-specific errors -async function startRecording(): Promise> { - if (isAlreadyRecording) { - return RecorderError.AlreadyRecording(); - } - // ... recording logic -} - -// UI layer: display user-friendly errors -const { data, error } = await rpc.recorder.startRecording({ toastId }); -if (error) { - toast.error({ title: 'Failed to start recording', description: error.message }); -} -``` - -**This ensures:** -- **No crashes**: Every error is caught and handled -- **Rich context**: Debugging information preserved through flat fields -- **User-friendly**: UI gets actionable, readable error messages -- **Type safety**: TypeScript ensures all error paths are handled - -## Multi-Provider Architecture - -Whispering showcases clean multi-provider patterns: - -```typescript -async function transcribeBlob(blob: Blob) { - const selectedService = settings.value['transcription.selectedTranscriptionService']; - - switch (selectedService) { - case 'OpenAI': - return services.transcriptions.openai.transcribe(blob, { - outputLanguage: settings.value['transcription.outputLanguage'], - apiKey: settings.value['apiKeys.openai'], - modelName: settings.value['transcription.openai.model'], - }); - case 'Groq': - return services.transcriptions.groq.transcribe(blob, { - outputLanguage: settings.value['transcription.outputLanguage'], - apiKey: settings.value['apiKeys.groq'], - modelName: settings.value['transcription.groq.model'], - }); - case 'ElevenLabs': - return services.transcriptions.elevenlabs.transcribe(blob, { - apiKey: settings.value['apiKeys.elevenlabs'], - modelId: settings.value['transcription.elevenlabs.model'], - }); - } -} -``` - -All providers share identical `Result` signatures -- change providers without code changes. - -## Complex Workflows Made Simple - -```typescript -export const commands = { - stopRecordingAndTranscribe: defineMutation({ - mutationFn: async () => { - // Step 1: Stop recording - const { data: blob, error: stopError } = await rpc.recorder.stopRecording({ toastId }); - if (stopError) { - await notify.error({ title: 'Failed to stop recording', description: stopError.message }); - return Err(stopError); - } - - // Step 2: Play sound feedback - rpc.sound.playSoundIfEnabled('manual-stop'); - - // Step 3: Save to database - const { data: recording, error: createError } = await rpc.recordings.createRecording({ - blob, - timestamp: new Date(), - transcriptionStatus: 'PENDING', - }); - if (createError) return Err(createError); - - // Step 4: Transcribe - const { error: transcribeError } = await rpc.transcription.transcribeRecording(recording); - if (transcribeError) { - await notify.error({ - title: 'Recording saved, but transcription failed', - description: transcribeError.message, - }); - return Err(transcribeError); - } - - return Ok(recording); - }, - }), -}; -``` - -## Lessons Learned - -After 22,824 lines of production usage, these patterns have proven invaluable: - -### 1. Result Types Eliminate Crashes - -**Before (try-catch):** -```typescript -try { - const transcription = await openai.transcribe(blob); -} catch (error) { - console.error('Something failed:', error); // Lost context -} -``` - -**After (Result types):** -```typescript -const { data: transcription, error } = await services.transcription.openai.transcribe(blob, options); -if (error) { - switch (error.name) { - case 'Authentication': redirectToSettings(); break; - case 'ApiCall': showRetryDialog(); break; - } -} -``` - -### 2. Factory Functions Beat Classes - -- **Simpler testing**: No constructor complexity or `this` binding -- **Better DI**: Explicit parameters vs hidden dependencies -- **Easier composition**: Return objects compose naturally - -### 3. Platform Abstraction Multiplies Code Value - -With 97% code sharing, every feature benefits both platforms. Desktop gets native Tauri performance; web gets identical functionality. One codebase, consistent UX. - -### 4. Error Transformation Prevents Error Fatigue - -Clear error namespaces eliminate generic error messages: -- **Service errors** (`RecorderError`): Technical details for debugging -- **UI errors**: User-friendly messages with remediation steps - -## Getting Started with These Patterns - -### 1. Define Errors and Services - -```typescript -import { defineErrors, type InferErrors } from 'wellcrafted/error'; -import { tryAsync, type Result, Ok } from 'wellcrafted/result'; - -const UserError = defineErrors({ - NotFound: ({ userId }: { userId: string }) => ({ - message: `User ${userId} not found`, - userId, - }), - Fetch: ({ userId }: { userId: string }) => ({ - message: `Failed to fetch user ${userId}`, - userId, - }), -}); -type UserError = InferErrors; - -export function createUserService(config: { apiUrl: string }) { - return { - async getUser(id: string): Promise> { - return tryAsync({ - try: () => fetch(`${config.apiUrl}/users/${id}`).then(r => r.json()), - catch: () => UserError.Fetch({ userId: id }), - }); - }, - }; -} -``` - -### 2. Add Query Layer - -```typescript -import { createQueryFactories } from 'wellcrafted/query'; - -export const { defineQuery, defineMutation } = createQueryFactories(queryClient); - -export const users = { - getUser: (userId: () => string) => defineQuery({ - queryKey: ['users', userId()], - queryFn: () => services.user.getUser(userId()), - }), -}; -``` - -### 3. Use in Components - -```svelte - -``` - -## Conclusion - -Whispering's 22,824 lines of production code demonstrate that wellcrafted's patterns scale from simple scripts to complex multi-platform applications. The combination of Result types, `defineErrors` namespaces, and query factories creates an architecture that is: - -- **Reliable**: Zero runtime crashes through comprehensive error handling -- **Maintainable**: Clear separation of concerns and consistent patterns -- **Scalable**: 97% code sharing across platforms with minimal overhead -- **Developer-friendly**: Rich type safety and intuitive APIs - ---- - -*Want to see the full source code? Check out [Whispering on GitHub](https://github.com/braden-w/whispering) to explore these patterns in detail.* diff --git a/docs/core/brand-types.mdx b/docs/core/brand-types.mdx index 1252aa6..f6ef5e8 100644 --- a/docs/core/brand-types.mdx +++ b/docs/core/brand-types.mdx @@ -6,7 +6,7 @@ icon: 'fingerprint' # Brand Types: Nominal Typing in TypeScript -Brand types (also known as opaque types or nominal types) allow you to create distinct types from primitive types, preventing accidental mixing of values that should be semantically different. This is a powerful technique for making illegal states unrepresentable in your type system. +Brand types (also known as opaque types or nominal types) allow you to create distinct types from primitive types, preventing accidental mixing of values that should be semantically different. This is a technique for making illegal states unrepresentable in your type system. ## The Problem with Structural Typing @@ -51,7 +51,7 @@ transferMoney(orderId, userId, 100); // ❌ Type error: OrderId is not assignabl ## How Brand Types Work -The implementation is elegantly simple: +The implementation is simple: ```typescript declare const brand: unique symbol; @@ -60,7 +60,7 @@ export type Brand = { [brand]: { [K in T]: true } }; This creates a phantom property using a unique symbol that exists only at the type level. The property doesn't exist at runtime, but TypeScript's type system treats each brand as a distinct type. -The nested object structure `{ [K in T]: true }` enables brand stacking—when brands are intersected, the inner object properties merge rather than conflicting, allowing hierarchical brand relationships. +The nested object structure `{ [K in T]: true }` enables brand stacking: when brands are intersected, the inner object properties merge rather than conflicting, allowing hierarchical brand relationships. ### Why This Implementation? @@ -436,12 +436,12 @@ deleteUser(adminId); // ✅ Also works - AdminUserId extends UserId ## Summary -Brand types are a simple yet powerful technique for adding nominal typing to TypeScript: +Brand types are a simple technique for adding nominal typing to TypeScript: - **Prevent errors**: Catch ID mix-ups and parameter swapping at compile time - **Document guarantees**: Branded types communicate validation and invariants - **Zero runtime cost**: Brands exist only in the type system -- **Composable**: Work seamlessly with Result types and other patterns +- **Composable**: Work with Result types and other patterns Use brand types strategically at API boundaries and for values that are easily confused but semantically different. They're one more tool in your toolkit for making illegal states unrepresentable. @@ -452,13 +452,13 @@ Use brand types strategically at API boundaries and for values that are easily c See brand types preventing bugs in authentication and file upload code - Learn how brand types work seamlessly with Result discriminated unions + Learn how brand types work with Result discriminated unions Use brand types for type-safe service APIs and dependency injection - - Understand how brand types fit into wellcrafted's design philosophy + + How defineErrors and tagged errors work diff --git a/docs/core/error-system.mdx b/docs/core/error-system.mdx index 7f1344a..d771e87 100644 --- a/docs/core/error-system.mdx +++ b/docs/core/error-system.mdx @@ -6,15 +6,15 @@ icon: 'triangle-exclamation' # Error System Design -wellcrafted's error system is built on a simple yet powerful principle: **errors should be data, not control flow**. This page explains the design philosophy, implementation details, and best practices for creating robust error handling in your applications. +wellcrafted's error system is built on a simple principle: **errors should be data, not control flow**. This page explains the design philosophy, implementation details, and best practices for error handling in your applications. -**Why this API?** The `defineErrors` design is directly inspired by Rust's [`thiserror`](https://docs.rs/thiserror) crate — short variant names under a namespace, typed fields per variant, and a display message co-located with the definition. See [Rust's thiserror in TypeScript](/philosophy/rust-inspiration) for the full story. +**Why this API?** The `defineErrors` design is directly inspired by Rust's [`thiserror`](https://docs.rs/thiserror) crate: short variant names under a namespace, typed fields per variant, and a display message co-located with the definition. See [Rust's thiserror in TypeScript](/philosophy/rust-inspiration) for the full story. ## The TaggedError Pattern -At the heart of wellcrafted's error system is the `TaggedError` type - a structured, serializable error representation that works seamlessly with TypeScript's type system. +At the heart of wellcrafted's error system is the tagged error: a structured, serializable error representation that works with TypeScript's type system. ### Why TaggedError? @@ -30,7 +30,7 @@ TaggedError solves all these issues: 1. **JSON-serializable**: Plain objects that survive any serialization boundary 2. **Type-safe discrimination**: The `name` field acts as a discriminant for TypeScript 3. **Lightweight**: No overhead of class instantiation or prototype chains -4. **Flat structure**: Fields are spread directly on the error object — no nesting +4. **Flat structure**: Fields are spread directly on the error object, no nesting ## The TaggedError Shape @@ -46,9 +46,9 @@ The minimal type for any tagged error is: type AnyTaggedError = { name: string; message: string }; ``` -### `name` — The Discriminant +### `name`: The Discriminant -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: +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({ @@ -85,9 +85,9 @@ function handleError(error: AppError) { } ``` -### `message` — Human-Readable Text +### `message`: Human-Readable Text -When using `defineErrors`, the constructor function computes the message from the input fields — the caller never passes `message` directly: +When using `defineErrors`, the constructor function computes the message from the input fields; the caller never passes `message` directly: ```typescript import { defineErrors } from 'wellcrafted/error'; @@ -102,7 +102,7 @@ const AuthError = defineErrors({ return AuthError.Validation({ email: userInput }); ``` -### Fields — Flat on the Error Object +### Fields: Flat on the Error Object Additional data is spread directly on the error object, not nested under a `context` property. This makes access natural and concise: @@ -129,7 +129,7 @@ function processUser(id: number): Result -Fields should include the function's input parameters and any relevant debugging information. Since they are spread flat on the error object, you get direct access — `error.userId` instead of `error.context.userId`. +Fields should include the function's input parameters and any relevant debugging information. Since they are spread flat on the error object, you get direct access: `error.userId` instead of `error.context.userId`. ## The Three Tiers @@ -154,7 +154,7 @@ RecordingError.RecorderBusy(); ### Tier 2: Cause-Wrapping Error -For errors that wrap a caught error. Accept `cause: unknown` and call `extractErrorMessage` inside the message template — not at the call site: +For errors that wrap a caught error. Accept `cause: unknown` and call `extractErrorMessage` inside the message template, not at the call site: ```typescript const AudioError = defineErrors({ @@ -178,7 +178,7 @@ For errors that carry rich, typed debugging information: ```typescript const HttpError = defineErrors({ - // reason is genuinely optional enrichment — HTTP/2 dropped reason phrases, + // reason is genuinely optional enrichment: HTTP/2 dropped reason phrases, // so many responses won't have one. Response: ({ status, reason }: { status: number; reason?: string }) => ({ message: `HTTP ${status}${reason ? `: ${reason}` : ''}`, @@ -196,14 +196,14 @@ HttpError.Response({ status: 500, reason: 'Internal server error' }); ## Wrapping Caught Errors with extractErrorMessage -When a `catch` block hands you `unknown`, the constructor should own the conversion — not the call site. +When a `catch` block hands you `unknown`, the constructor should own the conversion, not the call site. ### Preferred: Transform Inside the Constructor Accept `cause: unknown`, call `extractErrorMessage(cause)` in the message template, and store the raw `cause` for programmatic access: ```typescript -import { defineErrors, extractErrorMessage } from '@wellcrafted/result/error'; +import { defineErrors, extractErrorMessage } from 'wellcrafted/error'; const AudioError = defineErrors({ PlaySound: ({ cause }: { cause: unknown }) => ({ @@ -212,7 +212,7 @@ const AudioError = defineErrors({ }), }); -// Call site stays clean — just pass the raw error +// Call site stays clean, just pass the raw error try { await audioContext.play(soundFile); } catch (error) { @@ -233,7 +233,7 @@ The resulting error carries both a human-readable `message` and the original `ca ### Anti-Pattern: Transform at the Call Site ```typescript -// Don't do this — every call site must remember to call extractErrorMessage +// Don't do this; every call site must remember to call extractErrorMessage try { await audioContext.play(soundFile); } catch (error) { @@ -245,7 +245,7 @@ This scatters presentation logic across every `catch` block instead of centraliz ### Why -- **Constructor owns the template.** It already decides the message format — it should also decide how raw inputs become strings. +- **Constructor owns the template.** It already decides the message format; it should also decide how raw inputs become strings. - **Call sites pass raw data.** Callers hand over what they have; the constructor does the rest. - **`cause` stays available.** Storing `cause: unknown` means consumers can inspect the original error programmatically, not just read a stringified version. - **Same principle as Rust's `#[from]`.** In `thiserror`, the `#[from]` attribute tells the variant to handle conversion automatically. The call site just wraps; the definition owns the transform. @@ -255,7 +255,7 @@ This scatters presentation logic across every `catch` block instead of centraliz There is no dedicated cause step. If you need to chain errors, model `cause` as just another field: ```typescript -import { defineErrors, type InferError } from 'wellcrafted/error'; +import { defineErrors, type InferErrors } from 'wellcrafted/error'; // Define errors for each layer const NetworkError = defineErrors({ @@ -386,19 +386,19 @@ type FileNotFoundError = InferError; ## The defineErrors API -wellcrafted provides `defineErrors` — a declarative API that eliminates boilerplate and enforces consistent error structure. Each key in the config object maps to a constructor function that returns `{ message, ...fields }`. The `name` is automatically stamped from the key, and each factory returns `Err<...>` directly — ready to use in a `Result` return. +wellcrafted provides `defineErrors`, a declarative API that eliminates boilerplate and enforces consistent error structure. Each key in the config object maps to a constructor function that returns `{ message, ...fields }`. The `name` is automatically stamped from the key, and each factory returns `Err<...>` directly, ready to use in a `Result` return. ```typescript import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error'; // Define errors with constructor functions const AppError = defineErrors({ - // Static error — no input, fixed message + // Static error: no input, fixed message Network: () => ({ message: 'Network request failed', }), - // Structured error — typed input, computed message + // Structured error: typed input, computed message FileNotFound: ({ path }: { path: string }) => ({ message: `File not found: ${path}`, path, @@ -406,15 +406,15 @@ const AppError = defineErrors({ }); // Each variant is accessed via the namespace -// AppError.Network() — returns Err<{ name: 'Network'; message: string }> -// AppError.FileNotFound({ path }) — returns Err<{ name: 'FileNotFound'; message: string; path: string }> +// AppError.Network() returns Err<{ name: 'Network'; message: string }> +// AppError.FileNotFound({ path }) returns Err<{ name: 'FileNotFound'; message: string; path: string }> // Extract types type AppError = InferErrors; type FileNotFoundError = InferError; ``` -### Constructor Functions — Define Message and Fields Together +### Constructor Functions: Define Message and Fields Together Each constructor function receives the input fields and returns an object with `message` plus any additional fields to spread on the error. The `name` is stamped automatically from the key. @@ -457,18 +457,18 @@ const FileError = defineErrors({ }), }); -// Fields are REQUIRED — TypeScript enforces it +// Fields are REQUIRED; TypeScript enforces it FileError.Write({ path: '/etc/passwd' }); // FileError.Write(); // Type error! fields are required ``` ### Optional Fields (Enrichment) -Use optional properties only for genuine enrichment — data that may not be available at the call site: +Use optional properties only for genuine enrichment: data that may not be available at the call site: ```typescript const ParseError = defineErrors({ - // line is genuinely optional enrichment — parsing may fail before + // line is genuinely optional enrichment: parsing may fail before // a line number is identified (e.g., binary format mismatch) Log: ({ file, line }: { file: string; line?: number }) => ({ message: `Log parse failed: ${file}${line != null ? `:${line}` : ''}`, @@ -477,10 +477,10 @@ const ParseError = defineErrors({ }), }); -// file is always required — you always know what you're parsing +// file is always required; you always know what you're parsing ParseError.Log({ file: 'app.ts' }); -// line is optional enrichment — provide it when available +// line is optional enrichment; provide it when available ParseError.Log({ file: 'app.ts', line: 42 }); // ParseError.Log({ wrong: true }); // Type error! wrong shape ``` @@ -493,7 +493,7 @@ Fields must be JSON-serializable primitives, arrays, or nested objects. This ens // Valid JSON-serializable fields FileError.Write({ path: '/etc/passwd' }); -// Not allowed — Date, functions, class instances are not JSON-serializable +// Not allowed: Date, functions, class instances are not JSON-serializable // FileError.Write({ createdAt: new Date(), handler: () => {} }); // Type error! ``` @@ -526,7 +526,7 @@ type RequestError = InferError; import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error'; const AppError = defineErrors({ - // Tier 1: Static — no fields, no args at call site + // Tier 1: Static, no fields, no args at call site Network: () => ({ message: 'Network request failed', }), @@ -543,7 +543,7 @@ const AppError = defineErrors({ path, }), - // Structured with optional enrichment — line number may not be available + // Structured with optional enrichment: line number may not be available // when parsing fails before a line is identified Log: ({ file, line }: { file: string; line?: number }) => ({ message: `Log parse failed: ${file}${line != null ? `:${line}` : ''}`, @@ -552,7 +552,7 @@ const AppError = defineErrors({ }), }); -// Access variants via the namespace — each returns Err<...> directly +// Access variants via the namespace; each returns Err<...> directly // AppError.Network() // AppError.Parse({ cause: error }) // AppError.FileNotFound({ path: '...' }) @@ -565,7 +565,7 @@ type FileNotFoundError = InferError; ### Using in tryAsync/trySync -The factory pattern works seamlessly with error mapping. Each variant returns `Err<...>` directly, making it a natural fit for the `catch` callback: +The factory pattern works with error mapping. Each variant returns `Err<...>` directly, making it a natural fit for the `catch` callback: ```typescript const ApiError = defineErrors({ @@ -633,7 +633,7 @@ const DbError = defineErrors({ }), }); -// At the call site — fields capture function inputs and debugging info +// At the call site: fields capture function inputs and debugging info DbError.Query({ query: 'SELECT * FROM users WHERE id = ?', // Function input params: [userId], // Function input @@ -658,7 +658,7 @@ Readonly<{ name: "SessionExpired"; message: string; sessionId: string }> ### 3. Avoid String Literal Unions as Sub-Discriminants -If you find yourself adding a field like `reason: 'timeout' | 'refused' | 'dns'` inside a single variant, that field is acting as a second discriminant — duplicating what variant names already do. Split into separate variants instead. +If you find yourself adding a field like `reason: 'timeout' | 'refused' | 'dns'` inside a single variant, that field is acting as a second discriminant, duplicating what variant names already do. Split into separate variants instead. This is a common mistake, especially if you are coming from a codebase where errors were loosely typed strings. The instinct to group related failures under one name is natural, but it forces consumers into double narrowing (`switch` on `name`, then `if` on `reason`) and often leads to dishonest optional fields. @@ -688,11 +688,11 @@ const NetworkError = defineErrors({ }); ``` -Freeform `string` fields for metadata are fine — the anti-pattern is specifically **string literal unions** that consumers would switch on. For wrapping caught errors, prefer `cause: unknown` with `extractErrorMessage(cause)` inside the message template. +Freeform `string` fields for metadata are fine; the anti-pattern is specifically **string literal unions** that consumers would switch on. For wrapping caught errors, prefer `cause: unknown` with `extractErrorMessage(cause)` inside the message template. ### 3b. Avoid Conditional Logic on Factory Inputs -A related smell: if your constructor uses `if`/`switch` on its own inputs to decide what message to produce, the variant is doing double duty. Each branch is really a separate error — flatten the keys. +A related smell: if your constructor uses `if`/`switch` on its own inputs to decide what message to produce, the variant is doing double duty. Each branch is really a separate error; flatten the keys. ```typescript // Avoid: if/switch inside the constructor signals multiple errors hiding in one variant @@ -714,10 +714,10 @@ const FormError = defineErrors({ The problems are the same as string literal unions, just harder to spot: - **Dishonest optionals**: `field`, `value`, and `receivedType` are all optional because no single call site needs all of them. That's a sign they belong on different variants. -- **Hidden branching**: Consumers can't discriminate on `name` alone — they'd need to inspect `field` or `receivedType` to know which validation failed. +- **Hidden branching**: Consumers can't discriminate on `name` alone; they'd need to inspect `field` or `receivedType` to know which validation failed. - **Untypeable messages**: TypeScript can't narrow the message or fields based on which branch ran. -Flatten into separate variants with honest, required fields — and split into separate namespaces when errors serve different purposes: +Flatten into separate variants with honest, required fields, and split into separate namespaces when errors serve different purposes: ```typescript // Prefer: each validation failure is its own variant with exactly the fields it needs. @@ -753,7 +753,7 @@ type SubmissionError = InferErrors; function handleForm(data: unknown): Result { ... } ``` -**The rule of thumb**: if a constructor branches on its inputs to decide the message, each branch should be its own variant. The variant name *is* the discriminant — don't rebuild one inside the constructor body. +**The rule of thumb**: if a constructor branches on its inputs to decide the message, each branch should be its own variant. The variant name *is* the discriminant; don't rebuild one inside the constructor body. ### 4. Use Union Types for Function Signatures @@ -861,7 +861,7 @@ When you need to collect multiple errors: ```typescript const FormError = defineErrors({ - // value is genuinely optional enrichment — sensitive fields like + // value is genuinely optional enrichment: sensitive fields like // passwords should not include the value in error output Validation: ({ field, value }: { field: string; value?: string }) => ({ message: `Validation failed for field "${field}"`, @@ -912,8 +912,8 @@ async function fetchWithRetry( let lastError: NetworkError | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { - const result = await tryAsync({ - try: () => fetch(url).then(r => r.json()), + const result = await tryAsync({ + try: (): Promise => fetch(url).then(r => r.json()), catch: () => NetworkError.Request({ url, attempt, maxRetries }) }); @@ -935,7 +935,7 @@ async function fetchWithRetry( ## Integration with Result Type -TaggedErrors are designed to work seamlessly with the Result type: +TaggedErrors are designed to work with the Result type: ```typescript import { Result, Ok, Err, tryAsync } from "wellcrafted/result"; @@ -954,7 +954,7 @@ const UserError = defineErrors({ type UserError = InferErrors; async function getUser(id: string): Promise> { - const result = await tryAsync>({ + const result = await tryAsync({ try: () => database.users.findById(id), catch: () => UserError.DatabaseFailure({ userId: id }) }); @@ -974,7 +974,7 @@ async function getUser(id: string): Promise> { The TaggedError system transforms error handling from an afterthought to a first-class concern: - **Structured**: Every error has a consistent shape with `name` and `message` -- **Flat**: Fields are spread directly on the error object — no nesting +- **Flat**: Fields are spread directly on the error object, no nesting - **Serializable**: Works across all JavaScript boundaries - **Type-safe**: Full TypeScript discrimination support - **Debuggable**: Rich fields for troubleshooting diff --git a/docs/core/implementation.mdx b/docs/core/implementation.mdx deleted file mode 100644 index 2d8a237..0000000 --- a/docs/core/implementation.mdx +++ /dev/null @@ -1,241 +0,0 @@ ---- -title: 'How It Works' -description: 'Understanding the implementation details of wellcrafted' -icon: 'code' ---- - -# How It Works: The Core Implementation - -> **💡 TL;DR:** Replace `throw new Error()` with `return Err()` to make errors visible in your function signatures. - -This page reveals the elegant simplicity behind wellcrafted. Understanding the implementation helps you appreciate why the library is so lightweight and powerful. - -## The Complete Implementation - -Here's the entire core implementation of the Result type - it's simpler than you might think: - -```typescript -// The two possible outcomes -export type Ok = { data: T; error: null }; -export type Err = { error: E; data: null }; - -// Result is just a union of these two types -export type Result = Ok | Err; - -// Helper functions to create each variant -export const Ok = (data: T): Ok => ({ data, error: null }); -export const Err = (error: E): Err => ({ error, data: null }); -``` - -**That's it!** The entire foundation is built on this elegant simplicity. Let's break down why this design is so powerful. - -## The Discriminated Union Pattern - -The magic lies in how TypeScript's type system interprets this structure: - -- **`Ok`** always has `data: T` and `error: null` -- **`Err`** always has `error: E` and `data: null` -- **`Result`** is simply `Ok | Err` - -This creates a **discriminated union** where `error` is the reliable discriminator: - -- `error === null` → **Ok** (data is `T`) -- `error !== null` → **Err** (error is `E`) - -**Pro tip**: Check `error` with an exact null comparison. `error === null` means success; `error !== null` means failure. Checking `data` works only when your success type is non-nullable, but `error` handles all cases, including `Ok` where the success value itself is `null`. - -### How TypeScript Narrows Types - -```typescript -function handleResult(result: Result) { - if (result.error === null) { - // TypeScript knows this is Ok - console.log(result.data); // ✅ data is type T - // console.log(result.error); // ❌ TypeScript knows this is null - } else { - // TypeScript knows this is Err - console.log(result.error); // ✅ error is type E - // console.log(result.data); // ❌ TypeScript knows this is null - } -} -``` - -The beauty is in the mutual exclusivity - TypeScript's control-flow analysis can definitively determine which variant you're dealing with based on a simple null check. - -## Why This Design? - -### 1. **Zero Runtime Overhead** - -The entire implementation compiles down to simple object creation: - -```javascript -// TypeScript -const success = Ok(42); -const failure = Err("Something went wrong"); - -// Compiles to JavaScript -const success = { data: 42, error: null }; -const failure = { error: "Something went wrong", data: null }; -``` - -No classes, no prototypes, no complex machinery - just plain objects. - -### 2. **Perfect Serialization** - -Since Results are plain objects, they serialize perfectly: - -```typescript -const result = Ok({ id: 1, name: "Alice" }); -const serialized = JSON.stringify(result); -// {"data":{"id":1,"name":"Alice"},"error":null} - -const deserialized = JSON.parse(serialized); -// Still a valid Result! -``` - -This solves a critical problem: **Error instances lose their prototype chain when crossing serialization boundaries** (JSON.stringify/parse, network requests, worker threads), breaking `instanceof` checks. - -This is crucial for: -- Sending errors over HTTP -- Storing results in localStorage -- Passing data between workers -- Logging to external services - -### 3. **Familiar Destructuring Pattern** - -The design enables the elegant destructuring pattern: - -```typescript -const { data, error } = await someOperation(); - -if (error !== null) { - // Handle error -} else { - // Use data -} -``` - -> 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. - -This pattern is already familiar to developers using modern libraries and feels natural in JavaScript. - -### 4. **Type Safety Through Simplicity** - -Because the types are so simple, TypeScript can: -- Provide perfect IntelliSense -- Catch errors at compile time -- Narrow types automatically -- Work with strict mode without issues - -## Comparison with Alternatives - -### Boolean Flag Approach - -Some libraries use: -```typescript -type Result = - | { ok: true; value: T } - | { ok: false; error: E }; -``` - -Our approach is superior because: -- Direct access to `data` and `error` without intermediate properties -- Cleaner destructuring -- More intuitive null checks - -### Class-Based Approach - -Traditional OOP might use: -```typescript -class Ok { - constructor(public data: T) {} -} -class Err { - constructor(public error: E) {} -} -``` - -Our approach avoids: -- Prototype chain complexity -- `instanceof` checks that break across realms -- Serialization issues with classes -- Larger bundle size - -## The Power of Transparency - -By showing you the complete implementation, we demonstrate: - -1. **No Hidden Complexity**: What you see is what you get -2. **Easy to Understand**: New developers can grasp it immediately -3. **Easy to Debug**: Simple objects are simple to inspect -4. **Easy to Extend**: Build your own utilities on top - -## Building on the Foundation - -This simple foundation enables powerful patterns: - -### Type Guards - -```typescript -export function isOk(result: Result): result is Ok { - return result.error === null; -} - -export function isErr(result: Result): result is Err { - return result.error !== null; -} -``` - -### Wrapping Unsafe Operations - -```typescript -export function trySync(config: { - try: () => T; - catch: (error: unknown) => Err; -}): Result { - try { - return Ok(config.try()); - } catch (error) { - return config.catch(error); - } -} -``` - -### Result Utilities - -```typescript -export function unwrap(result: Result): T { - if (result.error !== null) { - throw result.error; - } - return result.data; -} -``` - -## The Philosophy Behind the Implementation - -This implementation embodies our core principles: - -1. **Embrace JavaScript**: We use plain objects, not exotic patterns -2. **Leverage TypeScript**: Let the type system do the heavy lifting -3. **Stay Transparent**: No magic, no hidden behavior -4. **Keep It Simple**: Complexity should be in your business logic, not your tools - -> **The beauty is in the transparency** - you can see exactly how it works under the hood, yet it provides powerful type safety and ergonomics. - -We only introduce new abstractions where JavaScript has a clear and significant weakness. The Result type addresses the fundamental problem that function signatures don't reveal what errors they might throw. - -## Summary - -The entire wellcrafted library is built on 6 lines of type definitions and 2 lines of helper functions. This radical simplicity is its greatest strength: - -- **Predictable**: No surprises, no edge cases -- **Performant**: No overhead beyond object creation -- **Portable**: Works everywhere JavaScript runs -- **Understandable**: You can hold the entire mental model in your head - -When you use wellcrafted, you're not buying into a complex framework - you're adopting a simple pattern that makes your code more reliable and your errors more manageable. - - -Want to see how this simple foundation enables powerful error handling patterns? Check out the [Result Pattern Deep Dive](/core/result-pattern) or explore [real-world examples](/patterns/real-world). - diff --git a/docs/core/result-pattern.mdx b/docs/core/result-pattern.mdx index 586a38b..64eafa0 100644 --- a/docs/core/result-pattern.mdx +++ b/docs/core/result-pattern.mdx @@ -56,9 +56,9 @@ if (result.error !== null) { } ``` -The edge case is `Ok` — when your success value is intentionally `null`. In this case, `data === null` doesn't mean failure; it means success with a null value. +The edge case is `Ok`, 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`. Checking `data` works great when you know your success type is non-nullable, but `error` is the safer, more consistent choice. +**Bottom line**: Check `error` first; it works reliably in every scenario, including `Ok`. 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 @@ -182,12 +182,12 @@ function processUser(result: Result) { ### Exhaustive Pattern Matching -With discriminated unions, TypeScript can ensure you handle all cases: +With discriminated unions, a `never` check in `default` turns a missing case into a compile error: ```typescript type AuthError = Readonly<{ name: "AuthError"; message: string }>; type NetworkError = Readonly<{ name: "NetworkError"; message: string }>; -type ValidationError = Readonly<{ name: "ValidationError"; message: string }>; +type ValidationError = Readonly<{ name: "ValidationError"; message: string; field: string }>; type AppError = AuthError | NetworkError | ValidationError; @@ -199,11 +199,17 @@ function handleError(error: AppError): string { return "Check your internet connection"; case "ValidationError": return `Invalid ${error.field}`; - // TypeScript ensures all cases are handled! + default: { + // add a variant without a case and this line stops compiling + const _exhaustive: never = error; + return _exhaustive; + } } } ``` +A plain `switch` does not enforce this on its own; the `never` assignment in `default` is what opts you in. + ## Working with Results ### Early Returns Pattern @@ -476,7 +482,7 @@ The discriminated union design with `null` as the discriminant provides the perf See complete examples using Results in production applications - Build robust services using Result-returning functions + Build services using Result-returning functions Combine Results with brand types for maximum type safety diff --git a/docs/core/typescript-patterns.mdx b/docs/core/typescript-patterns.mdx deleted file mode 100644 index e19d348..0000000 --- a/docs/core/typescript-patterns.mdx +++ /dev/null @@ -1,691 +0,0 @@ ---- -title: 'TypeScript Best Practices' -description: 'Advanced TypeScript patterns and best practices for wellcrafted applications' -icon: 'code' ---- - -# TypeScript Best Practices with wellcrafted - -Wellcrafted provides excellent TypeScript support through discriminated unions, advanced type inference, and utility types. This guide covers best practices and advanced patterns for maximum type safety. - -## Result Type Inference - -### Basic Type Inference - -TypeScript automatically infers Result types from your functions: - -```typescript -// TypeScript infers: Result -async function getUser(id: string) { - const user = await database.findUser(id); - if (!user) { - return DatabaseError.NotFound({ - userId: id, - }); - } - return Ok(user); -} - -// Usage with proper type narrowing -const userResult = await getUser("123"); -if (userResult.error) { - // userResult.error is DatabaseError - console.log(userResult.error.message); -} else { - // userResult.data is User - console.log(userResult.data.email); -} -``` - -### Function Return Type Annotations - -Always annotate function return types for public APIs: - -```typescript -// ✅ Good: Explicit return type makes API clear -export async function createUser( - input: CreateUserInput -): Promise> { - // Implementation... -} - -// ❌ Avoid: Inferred types are unclear to API consumers -export async function createUser(input: CreateUserInput) { - // Return type is inferred but not obvious -} -``` - -### Generic Constraints with Results - -Use generic constraints for flexible, type-safe functions: - -```typescript -// Generic function that works with any Result type -function logResult( - result: Result, - operation: string -): T | undefined { - if (result.error) { - console.error(`${operation} failed: ${result.error.message}`); - return undefined; - } - console.log(`${operation} succeeded`); - return result.data; -} - -// Works with any error type that has a message property -const user = logResult(await getUser("123"), "Get User"); -const order = logResult(await getOrder("456"), "Get Order"); -``` - -## Advanced Error Type Patterns - -### Union Error Types - -Define separate error namespaces per domain concern. TypeScript unions compose them at function boundaries — no wrapper type needed: - -```typescript -import { defineErrors, type InferErrors } from 'wellcrafted/error'; - -// Each namespace groups related errors -const ValidationError = defineErrors({ - InvalidField: ({ field }: { field: string }) => ({ - message: `Invalid value for ${field}`, - field, - }), -}); -type ValidationError = InferErrors; - -const NetworkError = defineErrors({ - RequestFailed: ({ cause }: { cause: unknown }) => ({ - message: `Network request failed: ${extractErrorMessage(cause)}`, - cause, - }), -}); -type NetworkError = InferErrors; - -const AuthError = defineErrors({ - NotAuthenticated: () => ({ - message: 'Not authenticated', - }), -}); -type AuthError = InferErrors; - -// The union happens in the function signature — this IS the composition -export async function updateUserProfile( - userId: string, - updates: UserProfileUpdates -): Promise> { - if (!updates.email?.includes('@')) { - return ValidationError.InvalidField({ field: 'email' }); - } - - if (!isAuthenticated()) { - return AuthError.NotAuthenticated(); - } - - return tryAsync({ - try: () => api.updateUser(userId, updates), - catch: (error) => NetworkError.RequestFailed({ cause: error }) - }); -} -``` - -### Discriminated Error Handling - -Use destructuring and TypeScript's discriminated unions for exhaustive error handling: - -```typescript -async function handleUserUpdate(userId: string, updates: UserProfileUpdates) { - const { data: profile, error } = await updateUserProfile(userId, updates); - - if (error) { - switch (error.name) { - case 'InvalidField': - showFieldError(error.field, error.message); - break; - case 'RequestFailed': - showNetworkError(error.message); - break; - case 'NotAuthenticated': - redirectToLogin(); - break; - default: - const _exhaustive: never = error; - } - return; - } - - showSuccessMessage(`Profile updated: ${profile.email}`); -} -``` - -## Service Function Typing Patterns - -### Factory Function Types - -Export types for factory-created services: - -```typescript -export function createRecorderService(options: RecorderOptions) { - return { - async startRecording(): Promise> { - // Implementation... - }, - - async stopRecording(): Promise> { - // Implementation... - }, - - getRecordingState(): RecordingState { - // Implementation... - } - }; -} - -// Export the service type for dependency injection and testing -export type RecorderService = ReturnType; - -// Use in other services or components -export function createTranscriptionService( - recorder: RecorderService, // Type-safe dependency - options: TranscriptionOptions -) { - return { - async transcribeCurrentRecording() { - const recordingResult = await recorder.stopRecording(); - if (recordingResult.error) { - return Err(recordingResult.error); - } - // Continue with transcription... - } - }; -} -``` - -### Interface vs Implementation - -Define interfaces for complex services with multiple implementations: - -```typescript -// Interface definition -export interface TranscriptionService { - transcribe(blob: Blob, options: TranscriptionOptions): Promise>; - getSupportedLanguages(): string[]; - isAvailable(): boolean; -} - -// OpenAI implementation -export function createOpenAITranscriptionService(apiKey: string): TranscriptionService { - return { - async transcribe(blob, options) { - return tryAsync({ - try: () => openai.audio.transcriptions.create({ - file: blob, - model: options.model, - language: options.language, - }), - catch: (error) => TranscriptionError.OpenAI({ - model: options.model, cause: error, - }) - }); - }, - - getSupportedLanguages: () => ['en', 'es', 'fr', 'de'], - isAvailable: () => Boolean(apiKey) - }; -} - -// Groq implementation -export function createGroqTranscriptionService(apiKey: string): TranscriptionService { - // Similar implementation... -} -``` - -## Query Layer Type Patterns - -### Parameterized Query Types - -Type-safe parameterized queries with proper inference: - -```typescript -// Accessor type for reactive parameters -type Accessor = () => T; - -// Parameterized query with proper typing -function createUserQuery( - userId: Accessor -) { - return defineQuery({ - queryKey: ['users', userId()] as const, - queryFn: (): Promise> => { - return services.users.getUserById(userId()); - }, - enabled: userId() !== '', // Type-safe check - }); -} - -// Usage with type inference (Svelte 5 requires accessor wrapper) -const userId: Accessor = () => route.params.id; -const userQuery = createQuery(() => createUserQuery(userId).options); -// userQuery.data is User | undefined -// userQuery.error is UserServiceError | null -``` - -### Query Key Type Safety - -Create type-safe query key factories: - -```typescript -// Query key factory with proper typing -const createQueryKeys = (baseKey: T) => ({ - all: baseKey, - lists: () => [...baseKey, 'list'] as const, - list: (filters: string) => [...baseKey, 'list', filters] as const, - details: () => [...baseKey, 'detail'] as const, - detail: (id: string) => [...baseKey, 'detail', id] as const, -}); - -// Usage with type safety -const recordingKeys = createQueryKeys(['recordings'] as const); - -// TypeScript knows these are properly typed query keys -const allRecordings = recordingKeys.all; // readonly ['recordings'] -const recordingDetail = recordingKeys.detail('123'); // readonly ['recordings', 'detail', '123'] - -// Use in queries -const recordingQuery = defineQuery({ - queryKey: recordingKeys.detail(recordingId), - queryFn: () => services.recordings.getById(recordingId), -}); -``` - -## Utility Type Usage - -### Extracting Types from Results - -Use wellcrafted's utility types for type extraction: - -```typescript -import type { UnwrapOk, UnwrapErr, ExtractOkFromResult } from 'wellcrafted/result'; - -// Extract success type from Result -type UserResult = Result; -type User = UnwrapOk; // User -type DatabaseError = UnwrapErr; // DatabaseError - -// Extract from complex nested Results -type ServiceResponse = Result<{ - users: User[]; - pagination: PaginationInfo; -}, NetworkError | ValidationError>; - -type ResponseData = UnwrapOk; -// { users: User[]; pagination: PaginationInfo; } -``` - -### Conditional Types with Results - -Create conditional types based on Result patterns: - -```typescript -// Conditional type that extracts data or provides fallback -type ResultDataOr = T extends Result ? U : Fallback; - -// Helper type for optional Result unwrapping -type OptionalResultData = T extends Result ? U | undefined : T; - -// Usage in function signatures -function processApiResponse>( - result: T -): OptionalResultData { - if (isResult(result) && isOk(result)) { - return result.data; - } - return undefined; -} -``` - -## Advanced Query Patterns - -### Settings-Dependent Query Typing - -Type-safe queries that depend on settings: - -```typescript -// Settings type -interface AppSettings { - 'transcription.provider': 'openai' | 'groq' | 'whisper'; - 'transcription.openai.model': string; - 'transcription.groq.model': string; - 'apiKeys.openai': string; - 'apiKeys.groq': string; -} - -// Settings accessor with proper typing -interface SettingsStore { - value: AppSettings; -} - -// Query that uses settings with type safety -const transcribeBlob = defineMutation({ - mutationKey: ['transcription', 'transcribe'], - mutationFn: async ( - blob: Blob - ): Promise> => { - const provider = settings.value['transcription.provider']; - - switch (provider) { - case 'openai': { - const apiKey = settings.value['apiKeys.openai']; - const model = settings.value['transcription.openai.model']; - return services.transcription.openai.transcribe(blob, { apiKey, model }); - } - case 'groq': { - const apiKey = settings.value['apiKeys.groq']; - const model = settings.value['transcription.groq.model']; - return services.transcription.groq.transcribe(blob, { apiKey, model }); - } - case 'whisper': { - return services.transcription.whisper.transcribe(blob); - } - default: { - // TypeScript ensures exhaustive checking - const _exhaustive: never = provider; - return TranscriptionError.UnsupportedProvider({ - provider, - }); - } - } - }, -}); -``` - -### Generic Service Wrappers - -Create generic wrappers for common service patterns: - -```typescript -// Generic CRUD service wrapper -interface CRUDService { - create(input: CreateInput): Promise>; - getById(id: ID): Promise>; - update(id: ID, input: UpdateInput): Promise>; - delete(id: ID): Promise>; - getAll(): Promise>; -} - -// Implementation for recordings -export function createRecordingService(): CRUDService< - Recording, - string, - CreateRecordingInput, - UpdateRecordingInput, - DatabaseError -> { - return { - async create(input) { - return tryAsync({ - try: () => database.recordings.create(input), - catch: (error) => DatabaseError.Create({ - cause: error, - }) - }); - }, - - async getById(id) { - return tryAsync({ - try: () => database.recordings.findById(id), - catch: (error) => DatabaseError.Lookup({ - id, cause: error, - }) - }); - }, - - // ... other methods - }; -} -``` - -## Testing Type Patterns - -### Mock Service Types - -Create properly typed mocks for testing: - -```typescript -import { vi, type MockedFunction } from 'vitest'; - -// Create mock with proper typing -const mockRecorderService: RecorderService = { - startRecording: vi.fn(), - stopRecording: vi.fn(), - getRecordingState: vi.fn(), -}; - -// Type-safe mock implementation -const startRecordingMock = mockRecorderService.startRecording as MockedFunction< - typeof mockRecorderService.startRecording ->; - -startRecordingMock.mockResolvedValue(Ok(undefined)); - -// Test with proper typing -test('handles recording start success', async () => { - const result = await mockRecorderService.startRecording(); - expect(isOk(result)).toBe(true); - if (isOk(result)) { - // TypeScript knows result.data is void - expect(result.data).toBeUndefined(); - } -}); -``` - -### Test Type Assertions - -Use type assertions for comprehensive testing: - -```typescript -import { expectType, type Equal, type Expect } from 'tsd'; - -// Test that function returns correct Result type -const userResult = await getUser("123"); -type UserResultType = typeof userResult; -type _UserResultTest = Expect>>; - -// Test discriminated union narrowing -if (userResult.error) { - expectType(userResult.error); - expectType(userResult.data); -} else { - expectType(userResult.data); - expectType(userResult.error); -} -``` - -## Performance and Type Optimization - -### Type Assertion Patterns - -Use type assertions carefully with Results: - -```typescript -// ✅ Good: Type assertion with runtime check -function assertOk(result: Result): T { - if (result.error) { - throw new Error(`Expected Ok but got Err: ${result.error}`); - } - return result.data; -} - -// Usage in tests or scenarios where you know result is Ok -const user = assertOk(await getUser("123")); - -// ❌ Avoid: Unsafe type assertion -const unsafeUser = (await getUser("123")) as Ok; -``` - -### Lazy Type Evaluation - -Use lazy evaluation for expensive type computations: - -```typescript -// Lazy type evaluation for complex computations -type LazyResult = () => Promise>; - -function createLazyQuery( - computation: LazyResult -): { execute: LazyResult } { - return { - execute: computation - }; -} - -// Usage -const expensiveQuery = createLazyQuery(async () => { - // Expensive computation only runs when called - return Ok(await performExpensiveOperation()); -}); -``` - -## Common TypeScript Mistakes - -### ❌ Over-Specific Error Types - -```typescript -// DON'T: Too specific, hard to handle -type SpecificError = - | { type: 'NETWORK_TIMEOUT'; timeout: number } - | { type: 'NETWORK_CONNECTION_REFUSED'; port: number } - | { type: 'NETWORK_DNS_ERROR'; hostname: string }; - -// DO: Broad categories, specific context via defineErrors -const ApiError = defineErrors({ - Network: ({ message }: { message: string }) => ({ message }), -}); -// Use fields for specifics -``` - -### ❌ String Literal Unions as Sub-Discriminants - -```typescript -// DON'T: string literal union inside a variant forces double narrowing -const FileError = defineErrors({ - Operation: ({ kind }: { kind: 'not_found' | 'permission_denied' | 'disk_full' }) => ({ - message: `File operation failed: ${kind}`, - kind, - }), -}); - -// Consumers must narrow twice: -if (error.name === 'Operation' && error.kind === 'not_found') { /* ... */ } - -// DO: each failure case is its own variant -const FileError = defineErrors({ - NotFound: ({ path }: { path: string }) => ({ - message: `File not found: ${path}`, - path, - }), - PermissionDenied: ({ path, user }: { path: string; user: string }) => ({ - message: `Permission denied for "${path}"`, - path, - user, - }), - DiskFull: ({ volumeName }: { volumeName: string }) => ({ - message: `Disk full on volume "${volumeName}"`, - volumeName, - }), -}); - -// Consumers narrow once: -switch (error.name) { - case 'NotFound': console.log(error.path); break; - case 'PermissionDenied': console.log(error.user); break; - case 'DiskFull': console.log(error.volumeName); break; -} -``` - -When each failure case is its own variant, TypeScript gives you the right fields automatically after a single `switch` — no second level of narrowing, no optional fields that "some cases don't use." If you are coming from Rust, this is the same instinct as making each case its own enum variant rather than nesting enums. - -### ❌ Transforming Cause at the Call Site - -```typescript -// DON'T: every call site must remember to transform -const AudioError = defineErrors({ - PlaySound: ({ reason }: { reason: string }) => ({ - message: `Failed to play sound: ${reason}`, - reason, - }), -}); - -try { /* ... */ } catch (error) { - return AudioError.PlaySound({ reason: extractErrorMessage(error) }); -} -``` - -```typescript -// DO: constructor accepts raw cause, transforms internally -const AudioError = defineErrors({ - PlaySound: ({ cause }: { cause: unknown }) => ({ - message: `Failed to play sound: ${extractErrorMessage(cause)}`, - cause, - }), -}); - -try { /* ... */ } catch (error) { - return AudioError.PlaySound({ cause: error }); -} -``` - -The constructor owns the message template, so it should own the cause transformation too. Accepting `cause: unknown` keeps the raw error available for programmatic inspection while producing a clean message. This mirrors Rust's `#[from]` pattern where the enum variant handles conversion. - -### ❌ Missing Result Annotations - -```typescript -// DON'T: Unclear what errors can occur -export async function updateUser(id: string, updates: any) { - // What can go wrong here? -} - -// DO: Explicit error types in signature -export async function updateUser( - id: string, - updates: UserUpdates -): Promise> { - // Clear what can go wrong -} -``` - -### ❌ Ignoring Discriminated Unions - -```typescript -// DON'T: Missing discriminated union benefits -if (result.error) { - handleError(result.error); // Generic error handling -} - -// DO: Use discriminated unions for specific handling -if (result.error) { - switch (result.error.name) { - case 'Validation': - showValidationErrors(result.error); - break; - case 'Network': - showRetryOption(); - break; - } -} -``` - -## Best Practices Summary - -1. **Always annotate public function return types** with explicit Result types -2. **Use discriminated unions** for comprehensive error handling -3. **Export service types** from factory functions for dependency injection -4. **Leverage TypeScript's type narrowing** with Result type guards -5. **Create type-safe query key factories** for maintainable query keys -6. **Use utility types** to extract types from complex Result unions -7. **Test your types** with type-level assertions -8. **Prefer broad error categories** with specific context over hyper-specific error types - -These patterns ensure you get maximum type safety and excellent developer experience when working with wellcrafted in TypeScript applications. diff --git a/docs/docs.json b/docs/docs.json index 17fb16e..d4a940c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -26,8 +26,7 @@ "pages": [ "index", "getting-started/installation", - "getting-started/quick-start", - "getting-started/core-concepts" + "getting-started/quick-start" ] }, { @@ -35,8 +34,7 @@ "pages": [ "core/result-pattern", "core/error-system", - "core/brand-types", - "core/implementation" + "core/brand-types" ] }, { @@ -44,18 +42,14 @@ "pages": [ "patterns/real-world", "patterns/optional-keys", - "patterns/service-layer", - "case-studies/whispering-architecture" + "patterns/service-layer" ] }, { "group": "\ud83d\udd0c Integrations", "pages": [ "integrations/validation-libraries", - "integrations/svelte-tanstack", - "integrations/react-tanstack", - "integrations/nextjs-app-router", - "integrations/nodejs-express", + "integrations/tanstack-query", "integrations/hono-serialization", "integrations/testing" ] diff --git a/docs/getting-started/core-concepts.mdx b/docs/getting-started/core-concepts.mdx deleted file mode 100644 index 201f7b0..0000000 --- a/docs/getting-started/core-concepts.mdx +++ /dev/null @@ -1,362 +0,0 @@ ---- -title: 'Core Concepts' -description: 'Understanding the fundamental ideas behind wellcrafted' -icon: 'lightbulb' ---- - -# Core Concepts - -wellcrafted is built on a few simple but powerful ideas. Understanding these concepts will help you get the most out of the library and write more reliable TypeScript applications. - -## Errors as Values, Not Control Flow - -### The Traditional Approach - -In traditional JavaScript/TypeScript, errors are handled through exceptions: - -```typescript -// Errors are invisible in the type signature -async function getUser(id: string): Promise { - const user = await db.query(`SELECT * FROM users WHERE id = ?`, [id]); - if (!user) { - throw new Error("User not found"); // Hidden control flow - } - return user; -} - -// Callers must remember to catch -try { - const user = await getUser("123"); -} catch (error) { - // What type is error? What errors are possible? -} -``` - -Problems with this approach: -- **Invisible failure modes**: A function signature `function doSomething(): User` doesn't tell you that it might throw a `NetworkError` or a `ValidationError`. Errors are invisible until they strike at runtime. -- **Lost type information**: Caught errors are type `unknown` -- **Action at a distance**: Errors can bubble up through multiple function calls -- **Broken serialization**: Error instances lose their prototype chain when crossing serialization boundaries (JSON.stringify/parse, network requests, worker threads), breaking `instanceof` checks. - -### The wellcrafted Approach - -With wellcrafted, errors are just data: - -```typescript -// Errors are visible in the type signature -async function getUser( - id: string -): Promise> { - const result = await db.query(`SELECT * FROM users WHERE id = ?`, [id]); - - if (!result) { - return Err({ - name: "UserNotFoundError", - message: "User not found", - userId: id, - }); - } - - return Ok(result); -} - -// Callers handle errors as data -const { data: user, error } = await getUser("123"); -if (error !== null) { - // TypeScript knows exactly what errors are possible - switch (error.name) { - case "UserNotFoundError": - return show404(); - case "DatabaseError": - return showRetryButton(); - } -} -``` - -## The Result Pattern - -The Result pattern represents operations that can either succeed or fail. It's a discriminated union with two variants: - -```typescript -type Result = Ok | Err - -type Ok = { data: T; error: null } -type Err = { data: null; error: E } -``` - -### Why This Design? - -1. **Error-side Discriminated Union**: `error` is the reliable discriminant. `error === null` means Ok, and `error !== null` means Err. This handles every success payload, including `Ok` (e.g., a cache lookup that succeeds but finds nothing). Error values themselves should be non-null; their presence is represented by `error !== null`. -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 - -### Mental Model - -Think of Result as a box that contains either: -- ✅ A success value (Ok) -- ❌ An error value (Err) - -But never both, and never neither. This mutual exclusivity is enforced by the type system. - -## Tagged Errors: Structured, Serializable, Type-Safe - -Traditional JavaScript errors have problems: -- They're instances with prototype chains -- They don't serialize well -- They can't be discriminated in unions - -wellcrafted's tagged error pattern solves these issues. Every tagged error is a readonly plain object with a `name` discriminant, a `message`, and any additional fields spread flat: - -```typescript -Readonly<{ name: TName; message: string } & TFields> -``` - -> Custom fields are spread directly onto the error object. Include the function's input parameters and any relevant variables as fields. This creates a complete picture of what data led to the error, making debugging straightforward. - -### The Power of the Tag - -The `name` field serves as a discriminant, enabling exhaustive pattern matching: - -```typescript -type AuthError = Readonly<{ name: "AuthError"; message: string }>; -type NetworkError = Readonly<{ name: "NetworkError"; message: string }>; -type ValidationError = Readonly<{ name: "ValidationError"; message: string }>; - -function handleError(error: AuthError | NetworkError | ValidationError) { - switch (error.name) { - case "AuthError": - // TypeScript knows this is AuthError - redirectToLogin(); - break; - case "NetworkError": - // TypeScript knows this is NetworkError - showRetryButton(); - break; - case "ValidationError": - // TypeScript knows this is ValidationError - highlightInvalidFields(error); - break; - // TypeScript ensures all cases are handled - } -} -``` - -## Brand Types: Nominal Typing in a Structural World - -TypeScript uses structural typing - types are compatible if they have the same shape. This can lead to errors: - -```typescript -function chargeCard(userId: string, cardId: string, amount: number) { - // ... -} - -const user = "user_123"; -const card = "card_456"; - -// Oops! Swapped the parameters, but TypeScript can't help -chargeCard(card, user, 100); // 💥 Runtime error -``` - -Brand types add nominal typing: - -```typescript -type UserId = string & Brand<"UserId">; -type CardId = string & Brand<"CardId">; - -function chargeCard(userId: UserId, cardId: CardId, amount: number) { - // ... -} - -const user = "user_123" as UserId; -const card = "card_456" as CardId; - -// TypeScript catches the error! -chargeCard(card, user, 100); // ❌ Type error at compile time -``` - -## Key Principles - -### 1. Make the Implicit Explicit - -Hidden behavior is the enemy of reliability. wellcrafted makes everything visible: - -- **Errors in signatures**: You can see what can go wrong -- **Validation state in types**: Validated data has a different type -- **Semantics in types**: Different IDs have different types - -### 2. Fail Fast, Fail Safe - -When something goes wrong, you want to know immediately and handle it gracefully: - -```typescript -const result = await fetchUser(id); -if (result.error !== null) { - // Handle error immediately, with full type information - return handleError(result.error); -} -// From here on, TypeScript knows result.data exists -``` - -### 3. Composition Over Complexity - -Simple primitives that compose well are better than complex abstractions: - -```typescript -// Compose Results with standard JavaScript -async function processMany(ids: string[]) { - const results = await Promise.all(ids.map(fetchUser)); - const errors = results.filter(r => r.error !== null); - - if (errors.length > 0) { - return Err({ - name: "BatchError", - message: `Failed to fetch ${errors.length} users`, - errors, - }); - } - - return Ok(results.map(r => r.data!)); -} -``` - -### 4. Work With the Language - -wellcrafted embraces JavaScript's strengths instead of fighting them: - -- **Plain objects** instead of classes -- **Destructuring** instead of method chaining - the familiar `const { data, error } = ...` pattern -- **Type narrowing** instead of type assertions -- **async/await** instead of custom control flow - -> We only introduce new abstractions where JavaScript has a clear and significant weakness. - -The beauty is in the transparency - you can see exactly how it works under the hood, yet it provides powerful type safety and ergonomics. - -## Putting It All Together - -Here's how these concepts work together in practice: - -```typescript -import { Result, Ok, Err, tryAsync } from "wellcrafted/result"; -import { defineErrors, type InferErrors } from "wellcrafted/error"; -import { type Brand } from "wellcrafted/brand"; - -// Brand types for safety -type UserId = string & Brand<"UserId">; -type OrderId = string & Brand<"OrderId">; - -// Tagged errors for clarity -type OrderNotFoundError = Readonly<{ name: "OrderNotFoundError"; message: string }>; -type PaymentError = Readonly<{ name: "PaymentError"; message: string }>; -type ShippingError = Readonly<{ name: "ShippingError"; message: string }>; - -// Result types make errors visible -async function fulfillOrder( - userId: UserId, - orderId: OrderId -): Promise> { - // Fetch order with explicit error handling - const orderResult = await fetchOrder(orderId); - if (orderResult.error) return orderResult; - - // Verify ownership - if (orderResult.data.userId !== userId) { - return Err({ - name: "OrderNotFoundError", - message: "Order not found", - orderId, - userId, - }); - } - - // Process payment with error transformation - const paymentResult = await tryAsync({ - try: () => paymentGateway.charge(orderResult.data), - catch: (error) => Err({ - name: "PaymentError" as const, - message: "Payment processing failed", - orderId, - amount: orderResult.data.total, - }) - }); - - if (paymentResult.error) return paymentResult; - - // Ship order - return shipOrder(orderResult.data); -} - -// Usage is clear and type-safe -const result = await fulfillOrder(userId, orderId); -if (result.error !== null) { - switch (result.error.name) { - case "OrderNotFoundError": - show404(); - break; - case "PaymentError": - showPaymentRetry(result.error); - break; - case "ShippingError": - notifySupport(result.error); - break; - } -} else { - showSuccessMessage(`Shipped! Tracking: ${result.data}`); -} -``` - -## Why These Patterns Matter - -### Predictability - -When errors are values and types are precise, your code becomes predictable: -- No surprise exceptions -- No runtime type confusion -- No "undefined is not a function" - -### Maintainability - -Explicit errors and strong types make code easier to maintain: -- New team members can see all failure modes -- Refactoring is safer with compiler assistance -- Tests can cover all error cases - -### Debuggability - -Structured errors with context make debugging straightforward: -- Error context shows exactly what went wrong -- Serializable errors work in all environments -- Type information is preserved throughout - -## Summary - -wellcrafted's core concepts work together to create a more reliable development experience: - -1. **Result Pattern**: Makes success and failure explicit in types -2. **Tagged Errors**: Provides structured, serializable, discriminated errors -3. **Brand Types**: Adds semantic meaning to primitive types -4. **Errors as Values**: Transforms hidden control flow into visible data flow - -These patterns aren't just about catching bugs - they're about designing systems where bugs are less likely to occur in the first place. - -## See Also - - - - Apply these concepts with hands-on examples and real code - - - See how these patterns scale in production applications - - - Master the discriminated union that powers wellcrafted - - - Deep dive into TaggedErrors and structured error handling - - - - -Ready to apply these concepts? Start with the [Quick Start guide](/getting-started/quick-start) for hands-on examples. - diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index e67ae52..fe93954 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -10,9 +10,8 @@ wellcrafted is available on npm and works with any TypeScript project. It has ze ## Requirements -- **TypeScript**: 4.5 or higher (for template literal types) -- **Node.js**: 14 or higher (for development) -- **Module System**: Works with both CommonJS and ES Modules +- **TypeScript**: 5.0 or higher (for `const` type parameters) +- **Module System**: ES Modules only (ESM) ## Package Manager Installation @@ -74,12 +73,11 @@ import { type Brand } from "wellcrafted/brand"; Avoid importing from the package root (`wellcrafted`) as this will import all modules and prevent tree-shaking. -## Module Systems +## Module System -### ES Modules (Recommended) +wellcrafted ships as ES Modules only. Import from the subpath exports: ```typescript -// ES Module syntax (recommended) import { Ok, Err } from "wellcrafted/result"; export async function fetchUser(id: string) { @@ -92,23 +90,7 @@ export async function fetchUser(id: string) { } ``` -### CommonJS - -```javascript -// CommonJS syntax -const { Ok, Err } = require("wellcrafted/result"); - -async function fetchUser(id) { - try { - const user = await api.getUser(id); - return Ok(user); - } catch (error) { - return Err(error); - } -} - -module.exports = { fetchUser }; -``` +There is no `require()` entry point. From a CommonJS module, load wellcrafted with a dynamic `import()`. ## Bundle Size @@ -142,7 +124,7 @@ const ServerError = defineErrors({ type ServerError = InferErrors; export async function createUser(data: FormData) { - return tryAsync({ + return tryAsync({ try: async () => { // Your server action logic }, @@ -244,8 +226,8 @@ Now that you have wellcrafted installed: Learn the basics in 5 minutes - - Understand the fundamental ideas + + How defineErrors and tagged errors work See real-world implementations diff --git a/docs/getting-started/quick-start.mdx b/docs/getting-started/quick-start.mdx index 9603b95..4bcd38e 100644 --- a/docs/getting-started/quick-start.mdx +++ b/docs/getting-started/quick-start.mdx @@ -139,13 +139,13 @@ async function fetchUser(id: number): Promise> { const response = await fetch(endpoint); if (!response.ok) { - // HTTP error — we have the status code + // HTTP error: we have the status code return ApiError.HttpError({ endpoint, statusCode: response.status }); } return Ok(await response.json() as User); } catch { - // fetch threw — network failure, no status code available + // fetch threw: network failure, no status code available return ApiError.NetworkError({ endpoint }); } } @@ -169,7 +169,7 @@ import { defineErrors, type InferErrors } from "wellcrafted/error"; // Separate error namespaces for validation vs submission. // Each namespace groups related errors; TypeScript unions -// compose them at function boundaries — no wrapper type needed. +// compose them at function boundaries; no wrapper type needed. const ValidationError = defineErrors({ InvalidType: ({ receivedType }: { receivedType: string }) => ({ message: `Expected form data, received ${receivedType}`, @@ -206,7 +206,7 @@ interface SignupForm { confirmPassword: string; } -// Validation function — only returns ValidationError +// Validation function: only returns ValidationError function validateSignupForm(data: unknown): Result { if (!data || typeof data !== 'object') { return ValidationError.InvalidType({ receivedType: typeof data }); @@ -229,7 +229,7 @@ function validateSignupForm(data: unknown): Result return Ok({ email, password, confirmPassword }); } -// Submission function — only returns SubmissionError +// Submission function: only returns SubmissionError async function submitSignup( form: SignupForm ): Promise> { @@ -252,7 +252,7 @@ async function submitSignup( }); } -// Using it all together — the union happens naturally at the call site +// Using it all together: the union happens naturally at the call site async function handleSignup(formData: unknown) { const { data: validForm, error: validationError } = validateSignupForm(formData); @@ -364,9 +364,6 @@ In just 5 minutes, you've learned how to: ## Next Steps - - Understand the theory behind the patterns - Master advanced Result techniques @@ -416,5 +413,5 @@ const id = "123" as MyId; ``` -Ready to dive deeper? Check out our [comprehensive guides](/guides/error-handling) or explore [framework integrations](/patterns/framework-integration). +Ready to dive deeper? Read the [error system](/core/error-system) design or explore the [TanStack Query integration](/integrations/tanstack-query). diff --git a/docs/index.mdx b/docs/index.mdx index 14bf795..50a0ab8 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,142 +1,91 @@ --- -title: Welcome to wellcrafted -description: 'Delightful TypeScript utilities for elegant, type-safe applications' +title: wellcrafted +description: 'Tagged errors and Result types as plain objects. Under 2KB, zero dependencies.' --- import { Card, CardGroup } from '@mintlify/components' -# Welcome to wellcrafted +# wellcrafted -**Make errors impossible to ignore.** A lightweight TypeScript library that transforms unpredictable exceptions into type-safe, serializable results. +Tagged errors and Result types as plain objects. Under 2KB, zero dependencies. -## What is wellcrafted? +wellcrafted gives you a Rust-inspired `Result` type and a `defineErrors` helper that puts the errors a function can return into its signature, instead of hiding them behind `throw`. The errors are plain frozen objects, so they survive `JSON.stringify`, a Web Worker, or an IPC boundary intact. -wellcrafted is a collection of simple, powerful TypeScript primitives that make your code more predictable, type-safe, and delightful to work with. At its core, it provides a Rust-inspired `Result` type that makes error handling explicit and visible in your function signatures. +If you just want the fast tour, the [README](https://github.com/wellcrafted-dev/wellcrafted) covers what, why, and how in one screen. These pages go deeper. - Get up and running in 30 seconds - - - Understand the fundamental ideas + The essential patterns, in five minutes - Deep dive into the Result type + A deep dive into the Result type - See real-world implementations + Real-world implementations -## Why wellcrafted? +## The problem it solves -### The Problem +Nothing in this signature tells you what can go wrong: ```typescript -// Which errors can this throw? 🤷 +// Which of these throws, and with what? async function saveUser(user: User): Promise { - await validate(user); // Throws ValidationError? - await checkPermissions(); // Throws AuthError? - await database.save(user); // Throws DatabaseError? + await validate(user); + await checkPermissions(); + await database.save(user); } ``` -### The Solution +With a `Result` return type, every failure is in the type, and the caller has to deal with it: ```typescript -// Every error is visible and typed ✨ -async function saveUser(user: User): Promise> { +async function saveUser( + user: User, +): Promise> { const validation = await validate(user); if (validation.error) return validation; - + const auth = await checkPermissions(); if (auth.error) return auth; - - return await database.save(user); -} -``` - -## Core Primitives - - - - Type-safe error handling with explicit success and failure states - - - Structured, serializable errors that work everywhere - - - Create distinct types from primitives for compile-time safety - - -## Key Benefits - -- **🎯 Explicit Error Handling**: All potential failures are visible in function signatures -- **📦 Serialization-Safe**: Errors are plain objects that work across all boundaries -- **✨ Elegant API**: Clean, intuitive patterns that feel natural in TypeScript -- **🔍 Zero Magic**: The entire core is ~50 lines of code you can understand -- **🚀 Lightweight**: Zero dependencies, tree-shakeable, less than 2KB minified - -## Quick Example - -```typescript -import { tryAsync } from "wellcrafted/result"; -import { defineErrors, type InferErrors } from "wellcrafted/error"; - -const ApiError = defineErrors({ - Fetch: ({ endpoint }: { endpoint: string }) => ({ - message: `Failed to fetch data from ${endpoint}`, - endpoint, - }), -}); -type ApiError = InferErrors; - -const { data, error } = await tryAsync({ - try: () => fetch('/api/user').then(r => r.json()), - catch: () => ApiError.Fetch({ endpoint: '/api/user' }) -}); - -if (error) { - console.error(`${error.name}: ${error.message}`); -} else { - console.log("User:", data); + return database.save(user); } ``` -## Next Steps +The core is about fifty lines of code you can read in one sitting. There is no runtime, no generators, and no pipe operators: just `{ data, error }`, `async/await`, and `switch`. + +## The primitives - - Install wellcrafted in your project + + Explicit success and failure states you check with `{ data, error }` - - Follow our step-by-step guide + + `defineErrors` variants: structured, serializable, switchable on `name` - - Migrate from traditional error handling + + Distinct types from primitives, caught at compile time -## Philosophy +## Why it works this way -wellcrafted is built on the principle of making the implicit explicit. Instead of hidden exceptions that can crash your application, errors become data that you can work with using TypeScript's powerful type system. The `defineErrors` API is directly inspired by Rust's `thiserror` crate — bringing the same structural clarity of enum-based error handling to TypeScript. +`defineErrors` is modeled on Rust's [thiserror](https://docs.rs/thiserror): name the handful of things that can go wrong in a domain, up front, as data. The rest of the design follows from picking shapes you already know (`{ data, error }` from Supabase and SvelteKit) over inventing new ones. -Learn more about our [design principles](/philosophy/design-principles), read about the [Rust inspiration](/philosophy/rust-inspiration) behind `defineErrors`, explore our [production reliability approach](/philosophy/production-reliability), and discover why we believe [developer experience](/philosophy/developer-experience) should be delightful. +Read more on the [design principles](/philosophy/design-principles), the [Rust inspiration](/philosophy/rust-inspiration) behind `defineErrors`, and why we lean on [developer experience](/philosophy/developer-experience) over machinery. -## Related Resources +## Going further - - Deep dive into TaggedErrors and structured error handling - - - Prevent bugs with compile-time type distinctions - - Production patterns for building robust services + Layered services that each own their error vocabulary - - Use wellcrafted with your favorite frameworks + + Result-returning queries and mutations, reactive or imperative - \ No newline at end of file + + Adopt it one function at a time + + diff --git a/docs/integrations/hono-serialization.mdx b/docs/integrations/hono-serialization.mdx index 6e37c57..15d949f 100644 --- a/docs/integrations/hono-serialization.mdx +++ b/docs/integrations/hono-serialization.mdx @@ -126,7 +126,7 @@ forEachAction(client, ({ workspaceId, actionName, action }) => { const query = c.req.query(); const input = Object.keys(query).length > 0 ? query : undefined; - const maybeResult = await action(input) as Result | unknown; + const maybeResult = await action(input) as Result | unknown; const data = isResult(maybeResult) ? maybeResult.data : maybeResult; const error = isResult(maybeResult) ? maybeResult.error : undefined; diff --git a/docs/integrations/react-tanstack.mdx b/docs/integrations/react-tanstack.mdx deleted file mode 100644 index bd2e113..0000000 --- a/docs/integrations/react-tanstack.mdx +++ /dev/null @@ -1,423 +0,0 @@ ---- -title: 'React + TanStack Query Integration' -description: 'Production-ready patterns for using wellcrafted with React and TanStack Query' -icon: 'react' ---- - -# React + TanStack Query Integration - -React-specific patterns for using wellcrafted with TanStack Query. For core concepts (reactive options, imperative helpers, query/mutation definitions, RPC namespace), see the [TanStack Query Integration](/integrations/tanstack-query) guide. - -## Using Query Results in JSX - -TanStack Query provides `isPending`, `error`, and `data` states that map cleanly to React rendering: - -```tsx -import { useQuery } from '@tanstack/react-query'; -import { rpc } from '../query'; - -export function UserProfile({ userId }: { userId: string }) { - const userQuery = useQuery(rpc.users.getUser(userId).options); - - if (userQuery.isPending) { - return ; - } - - if (userQuery.error) { - return ( - userQuery.refetch()} - /> - ); - } - - if (!userQuery.data) { - return
User not found
; - } - - return ( -
- - - -
- ); -} -``` - -## Custom Hooks - -Wrap wellcrafted queries in custom hooks to encapsulate related state and actions: - -```tsx -// hooks/useUserManagement.ts -import { useQuery, useMutation } from '@tanstack/react-query'; -import { rpc } from '../query'; -import { useToast } from './useToast'; - -export function useUserManagement(userId?: string) { - const toast = useToast(); - - const userQuery = useQuery({ - ...rpc.users.getUser(userId!).options, - enabled: !!userId, - }); - - const createUser = useMutation({ - ...rpc.users.createUser.options, - onSuccess: (user) => { - toast.success(`User ${user.name} created successfully`); - }, - onError: (error) => { - toast.error('Failed to create user', { description: error.message }); - }, - }); - - const updateUser = useMutation({ - ...rpc.users.updateUser.options, - onSuccess: (user) => { - toast.success(`User ${user.name} updated successfully`); - }, - onError: (error) => { - toast.error('Failed to update user', { description: error.message }); - }, - }); - - // Imperative actions for event handlers - const actions = { - async updateUserField(field: keyof User, value: string) { - if (!userQuery.data) return; - - const { error } = await rpc.users.updateUser({ - ...userQuery.data, - [field]: value, - }); - - if (error) { - toast.error(`Failed to update ${field}`, { description: error.message }); - } - }, - - async refreshUser() { - await userQuery.refetch(); - }, - }; - - return { - user: userQuery.data, - isLoading: userQuery.isPending || createUser.isPending || updateUser.isPending, - error: userQuery.error || createUser.error || updateUser.error, - createUser: createUser.mutate, - updateUser: updateUser.mutate, - actions, - }; -} -``` - -## Error Display Component - -A reusable component for rendering wellcrafted errors with contextual actions: - -```tsx -// components/ErrorDisplay.tsx -interface ErrorDisplayProps { - error: { name: string; message: string; operation?: string }; - onRetry?: () => void; - className?: string; -} - -export function ErrorDisplay({ error, onRetry, className }: ErrorDisplayProps) { - const getErrorAction = () => { - if (error.message.includes('not found')) { - return Browse all users; - } - if (error.message.includes('Unauthorized')) { - return Sign in; - } - - return onRetry ? ( - - ) : null; - }; - - return ( -
-
-

Something went wrong

-

{error.message}

- - {error.operation && ( -
- Technical details -
Operation: {error.operation}
-
- )} - -
{getErrorAction()}
-
-
- ); -} -``` - -## Mutation Handling: Form Submission - -Use `useMutation` for form submissions with loading states and error display: - -```tsx -// components/UserForm.tsx -import { useMutation } from '@tanstack/react-query'; -import { useState } from 'react'; -import { rpc } from '../query'; - -interface UserFormProps { - user?: User; - onSuccess?: (user: User) => void; -} - -export function UserForm({ user, onSuccess }: UserFormProps) { - const [formData, setFormData] = useState({ - name: user?.name || '', - email: user?.email || '', - }); - - const createMutation = useMutation(rpc.users.createUser.options); - const updateMutation = useMutation(rpc.users.updateUser.options); - - const isLoading = createMutation.isPending || updateMutation.isPending; - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - - if (user) { - const result = await updateMutation.mutateAsync({ ...user, ...formData }); - if (result && onSuccess) onSuccess(result); - } else { - const result = await createMutation.mutateAsync(formData); - if (result && onSuccess) onSuccess(result); - } - } - - return ( -
- setFormData(prev => ({ ...prev, name: e.target.value }))} - disabled={isLoading} - /> - - setFormData(prev => ({ ...prev, email: e.target.value }))} - disabled={isLoading} - /> - - - - {(createMutation.error || updateMutation.error) && ( - - )} - - ); -} -``` - -## End-to-End Example - -A complete flow from service to query layer to React component. - -### 1. Service Layer - -```typescript -// services/api/users.ts -import { tryAsync, type Result } from 'wellcrafted/result'; -import { defineErrors, type InferErrors } from 'wellcrafted/error'; - -const UserServiceError = defineErrors({ - Fetch: ({ userId }: { userId: string }) => ({ - message: `Failed to fetch user ${userId}`, - userId, - }), - Create: () => ({ - message: 'Failed to create user', - }), - InvalidEmail: () => ({ - message: 'Invalid email address', - }), -}); -type UserServiceError = InferErrors; - -export type User = { id: string; name: string; email: string }; -export type CreateUserInput = { name: string; email: string }; - -export async function getUser(id: string): Promise> { - return tryAsync({ - try: async () => { - const response = await fetch(`/api/users/${id}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return await response.json(); - }, - catch: () => UserServiceError.Fetch({ userId: id }), - }); -} - -export async function createUser(input: CreateUserInput): Promise> { - if (!input.email.includes('@')) { - return UserServiceError.InvalidEmail(); - } - - return tryAsync({ - try: async () => { - const response = await fetch('/api/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return await response.json(); - }, - catch: () => UserServiceError.Create(), - }); -} -``` - -### 2. Query Layer - -```typescript -// query/users.ts -import { defineQuery, defineMutation, queryClient } from './_factories'; -import * as userService from '../services/api/users'; -import type { User, CreateUserInput } from '../services/api/users'; - -export const users = { - getUser: (userId: string) => - defineQuery({ - queryKey: ['users', userId], - queryFn: () => userService.getUser(userId), - enabled: !!userId, - }), - - createUser: defineMutation({ - mutationKey: ['users', 'create'], - mutationFn: async (input: CreateUserInput) => { - const result = await userService.createUser(input); - if (result.error) return result; - - queryClient.setQueryData(['users'], (old: User[] | undefined) => - old ? [...old, result.data] : [result.data] - ); - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }), -}; -``` - -### 3. React Component - -```tsx -// pages/CreateUserPage.tsx -import { useMutation } from '@tanstack/react-query'; -import { useState } from 'react'; -import { rpc } from '../query'; - -export function CreateUserPage() { - const [formData, setFormData] = useState({ name: '', email: '' }); - const createMutation = useMutation(rpc.users.createUser.options); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const result = await createMutation.mutateAsync(formData); - if (result) { - setFormData({ name: '', email: '' }); - } - } - - return ( -
-

Create User

- -
- setFormData(prev => ({ ...prev, name: e.target.value }))} - disabled={createMutation.isPending} - /> - setFormData(prev => ({ ...prev, email: e.target.value }))} - disabled={createMutation.isPending} - /> - -
- - {createMutation.error && ( - createMutation.reset()} - /> - )} - - {createMutation.isSuccess && ( -

User created successfully!

- )} -
- ); -} -``` - -## Advanced Patterns - -### Dependent Queries - -```tsx -function UserPostsPage({ userId }: { userId: string }) { - const userQuery = useQuery(rpc.users.getUser(userId).options); - - const postsQuery = useQuery({ - ...rpc.posts.getUserPosts(userId).options, - enabled: !!userQuery.data, // Only fetch when user is loaded - }); - - // ... render logic -} -``` - -### Imperative Actions in React - -Use the imperative interface for event handlers that don't need reactive UI state: - -```tsx -function UserManagementPage() { - // Reactive for display - const usersQuery = useQuery(rpc.users.getAllUsers.options); - - // Imperative for one-off actions - async function handleBulkDelete(userIds: string[]) { - for (const id of userIds) { - const { error } = await rpc.users.deleteUser(id); - if (error) { - toast.error(`Failed to delete user ${id}: ${error.message}`); - break; - } - } - toast.success(`Deleted ${userIds.length} users`); - } -} -``` diff --git a/docs/integrations/svelte-tanstack.mdx b/docs/integrations/svelte-tanstack.mdx deleted file mode 100644 index f70e64b..0000000 --- a/docs/integrations/svelte-tanstack.mdx +++ /dev/null @@ -1,283 +0,0 @@ ---- -title: 'Svelte + TanStack Query Integration' -description: 'Battle-tested patterns from Whispering: 22,824 lines, 97% code sharing, zero crashes' -icon: 'svelte' ---- - -# Svelte + TanStack Query Integration - -This guide covers **Svelte-specific** patterns for using wellcrafted with TanStack Query. For core concepts like service layers, query factories, reactive options, imperative helpers, error transformation, and best practices, see the [TanStack Query Integration](/integrations/tanstack-query) guide, which applies to both React and Svelte. - - -These patterns are battle-tested in [Whispering](https://github.com/braden-w/whispering), a production desktop/web app with 22,824 lines of TypeScript, 97% code sharing between platforms, and zero runtime crashes. - - -## Svelte Component Patterns - -### Query Results in Templates - -Svelte's `{#if}` / `{:else if}` blocks map naturally to TanStack Query states: - -```svelte - - -{#if $recordings.isPending} - -{:else if $recordings.error} - $recordings.refetch()} - /> -{:else if $recordings.data} - {#each $recordings.data as recording (recording.id)} - - {/each} - - {#if $recordings.data.length === 0} -
-

No recordings yet

-

Start recording to see your audio files here.

-
- {/if} -{/if} - -{#if $recordings.isRefetching && $recordings.data} -
Updating recordings...
-{/if} -``` - -Key Svelte differences from React: -- Use `createQuery(() => ...)` with an accessor function (React uses `useQuery(...)` directly) -- Access query state via `$` prefix: `$recordings.data`, `$recordings.isPending` -- Use `{#if}` / `{:else if}` / `{:else}` blocks instead of ternaries or early returns - -### Mutations with Reactive UI Feedback - -When you need loading states in the template, use `createMutation`: - -```svelte - - -{#if $deleteRecording.isPending} - -{:else} - -{/if} -``` - -### Imperative Actions in Event Handlers - -For event handlers that don't need reactive UI state, call the mutation directly. No reactive mutation needed: - -```svelte - - - - -``` - -## Error Display Component - -A reusable Svelte component for rendering query errors with contextual actions: - -```svelte - - - -
-
-

Something went wrong

-

{error?.message || 'An unexpected error occurred'}

- -
- Technical details -
{JSON.stringify(error, null, 2)}
-
- - {#if action} - {#if action.href} - {action.label} - {:else if action.onClick} - - {/if} - {/if} -
-
-``` - -## End-to-End Example - -Here is a complete flow from service to query definition to Svelte component. - -### 1. Service (pure business logic) - -```typescript -// lib/services/recordings.ts -import { Ok, tryAsync } from 'wellcrafted/result'; -import { defineErrors } from 'wellcrafted/error'; - -const RecordingServiceError = defineErrors({ - List: () => ({ - message: 'Failed to list recordings', - }), - Delete: ({ recordingId }: { recordingId: string }) => ({ - message: `Failed to delete recording ${recordingId}`, - recordingId, - }), -}); - -export function createRecordingService(deps: { db: DbClient }) { - return { - getAllRecordings: () => - tryAsync({ - try: () => deps.db.select().from(recordings).orderBy(desc(recordings.createdAt)), - catch: () => RecordingServiceError.List(), - }), - - deleteRecording: (id: string) => - tryAsync({ - try: () => deps.db.delete(recordings).where(eq(recordings.id, id)), - catch: () => RecordingServiceError.Delete({ recordingId: id }), - }), - }; -} -``` - -### 2. Query definitions - -```typescript -// lib/query/recordings.ts -import { defineQuery, defineMutation } from './_client'; -import { services } from '$lib/services'; - -const getAllRecordings = defineQuery({ - queryKey: ['recordings'], - queryFn: () => services.recordings.getAllRecordings(), -}); - -const deleteRecording = defineMutation({ - mutationKey: ['recordings', 'delete'], - mutationFn: (id: string) => services.recordings.deleteRecording(id), -}); - -export const recordings = { getAllRecordings, deleteRecording }; -``` - -### 3. Svelte component - -```svelte - - - -{#if $recordings.isPending} -

Loading recordings...

-{:else if $recordings.error} - $recordings.refetch()} /> -{:else if $recordings.data} - {#each $recordings.data as recording (recording.id)} -
- {recording.title} - -
- {/each} -{/if} -``` - -## See Also - - - - Core concepts: query factories, reactive options, imperative helpers, error transformation, best practices - - - Learn the fundamentals of Result types and error handling - - - Understanding TaggedErrors and structured error handling - - diff --git a/docs/integrations/tanstack-query.mdx b/docs/integrations/tanstack-query.mdx index 7b0def3..4dc1831 100644 --- a/docs/integrations/tanstack-query.mdx +++ b/docs/integrations/tanstack-query.mdx @@ -6,7 +6,7 @@ icon: 'database' # TanStack Query Integration -Wellcrafted provides seamless integration with TanStack Query through the `wellcrafted/query` module. This integration combines the type safety of Result patterns with the reactive power of TanStack Query. +The `wellcrafted/query` module adapts your Result-returning functions for TanStack Query. Define the data-fetching logic once and use it either way: a query exposes `.options` for reactive components plus `.fetch()`/`.ensure()` for imperative calls, while a mutation is callable directly in an event handler and also exposes `.options`. ## Quick Start diff --git a/docs/integrations/testing.mdx b/docs/integrations/testing.mdx index 5e6fa5e..d3df89b 100644 --- a/docs/integrations/testing.mdx +++ b/docs/integrations/testing.mdx @@ -10,7 +10,7 @@ This guide covers how to test functions that return `Result`, custom match ## Testing Services That Return Results -Services returning Results are straightforward to test — assert on the `Ok` or `Err` value directly: +Services returning Results are straightforward to test: assert on the `Ok` or `Err` value directly: ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; diff --git a/docs/integrations/validation-libraries.mdx b/docs/integrations/validation-libraries.mdx index 5623168..9f69856 100644 --- a/docs/integrations/validation-libraries.mdx +++ b/docs/integrations/validation-libraries.mdx @@ -6,7 +6,7 @@ icon: 'puzzle-piece' # Using Brand with Validation Libraries -wellcrafted's `Brand` is a pure type utility. It doesn't care what runtime validator you use — ArkType, Zod, Valibot, or anything else. Define the branded type once, then create a runtime validator with whichever library you prefer. +wellcrafted's `Brand` is a pure type utility. It doesn't care what runtime validator you use: ArkType, Zod, Valibot, or anything else. Define the branded type once, then create a runtime validator with whichever library you prefer. ## The Lock-in Problem @@ -17,26 +17,26 @@ Every major validation library ships its own branding mechanism: ```typescript const FileId = z.string().brand<"FileId">(); type FileId = z.infer; - // FileId = string & z.$brand<"FileId"> — Zod-specific type + // FileId = string & z.$brand<"FileId"> (Zod-specific type) ``` ```typescript const FileId = type("string").brand("FileId"); type FileId = typeof FileId.infer; - // FileId = Brand — ArkType-specific type + // FileId = Brand (ArkType-specific type) ``` ```typescript const FileId = v.pipe(v.string(), v.brand("FileId")); type FileId = v.InferOutput; - // FileId = string & v.Brand<"FileId"> — Valibot-specific type + // FileId = string & v.Brand<"FileId"> (Valibot-specific type) ``` -Each produces a **library-specific** branded type. If you switch validators — or use multiple validators in the same project — your domain types break. Your branded types become coupled to your validation library. +Each produces a **library-specific** branded type. If you switch validators, or use multiple validators in the same project, your domain types break. Your branded types become coupled to your validation library. ## The Pattern @@ -45,7 +45,7 @@ Decouple the type from the validator: ```typescript import { type Brand } from "wellcrafted/brand"; -// 1. Define the type — framework-agnostic, zero dependencies +// 1. Define the type: framework-agnostic, zero dependencies type FileId = string & Brand<"FileId">; ``` @@ -82,7 +82,7 @@ The explicit return type annotation `(s): FileId => ...` is what bridges the run ## Same Name for Type and Value -TypeScript has two parallel namespaces — types and values. You can use the same PascalCase name for both the branded type and its runtime validator. TypeScript resolves which one you mean from context. +TypeScript has two parallel namespaces: types and values. You can use the same PascalCase name for both the branded type and its runtime validator. TypeScript resolves which one you mean from context. ```typescript /** @@ -93,27 +93,27 @@ type FileId = string & Brand<"FileId">; const FileId = type("string").pipe((s): FileId => s as FileId); ``` -This gives you a single hover experience. Hover over `FileId` anywhere in your codebase — in a function signature, a schema definition, or an import — and you see the same JSDoc. +This gives you a single hover experience. Hover over `FileId` anywhere in your codebase, whether in a function signature, a schema definition, or an import, and you see the same JSDoc. ```typescript -// In a function signature — hovering FileId shows the JSDoc above +// In a function signature: hovering FileId shows the JSDoc above function deleteFile(id: FileId): Promise { /* ... */ } -// In a schema definition — same hover, same docs +// In a schema definition: same hover, same docs const FileUpload = type({ id: FileId, name: "string" }); ``` -**No naming tax.** Contrast this with Zod's conventional pattern where you need two names — `fileIdSchema` for the validator and `FileId` for the type. With the dual-declaration pattern, one name flows through your entire system: type annotations, runtime validation, schema composition, and IDE hovers. +**No naming tax.** Contrast this with Zod's conventional pattern where you need two names: `fileIdSchema` for the validator and `FileId` for the type. With the dual-declaration pattern, one name flows through your entire system: type annotations, runtime validation, schema composition, and IDE hovers. You can also use a type-only brand when no runtime validation is needed: ```typescript -// Type-only — no runtime validator, just compile-time safety +// Type-only: no runtime validator, just compile-time safety type Guid = string & Brand<"Guid">; -// Dual-declaration — type + validator share the name +// Dual-declaration: type + validator share the name type FileId = Guid & Brand<"FileId">; const FileId = type("string").pipe((s): FileId => s as FileId); ``` @@ -123,7 +123,7 @@ const FileId = type("string").pipe((s): FileId => s as FileId); wellcrafted brands compose through intersection. Child types are assignable to parent types, but not vice versa. Combine this with the dual-declaration pattern to get a full hierarchy of types and runtime validators: ```typescript -/** Base identifier — any UUID v4 string. */ +/** Base identifier: any UUID v4 string. */ type Guid = string & Brand<"Guid">; const Guid = type("string").pipe((s): Guid => s as Guid); @@ -131,12 +131,12 @@ const Guid = type("string").pipe((s): Guid => s as Guid); type FileId = Guid & Brand<"FileId">; const FileId = type("string").pipe((s): FileId => s as FileId); -/** Image file identifier — a FileId that points to an image. */ +/** Image file identifier: a FileId that points to an image. */ type ImageId = FileId & Brand<"ImageId">; const ImageId = type("string").pipe((s): ImageId => s as ImageId); ``` -Each level is both a type and a runtime validator. The type hierarchy gives you compile-time subtyping — an `ImageId` is assignable anywhere a `FileId` or `Guid` is expected: +Each level is both a type and a runtime validator. The type hierarchy gives you compile-time subtyping: an `ImageId` is assignable anywhere a `FileId` or `Guid` is expected: ```typescript function getFile(id: FileId): Promise { /* ... */ } @@ -150,7 +150,7 @@ const fileId = FileId("file-xyz-789"); const bad: ImageId = fileId; // ❌ Parent not assignable to child ``` -This works because of wellcrafted's nested object structure — when brands intersect, their boolean markers merge: +This works because of wellcrafted's nested object structure: when brands intersect, their boolean markers merge: ``` ImageId's brand = { [brand]: { Guid: true, FileId: true, ImageId: true } } @@ -161,7 +161,7 @@ Guid's brand = { [brand]: { Guid: true } } `ImageId` has all of `FileId`'s markers (plus its own), so it satisfies the `FileId` constraint. -Zod's `.brand()`, ArkType's `.brand()`, and Valibot's `v.brand()` don't support hierarchical stacking. Their brand types are flat — you can't express "an ImageId is also a FileId" with built-in brands. +Zod's `.brand()`, ArkType's `.brand()`, and Valibot's `v.brand()` don't support hierarchical stacking. Their brand types are flat: you can't express "an ImageId is also a FileId" with built-in brands. ## Adding Real Validation diff --git a/docs/migration/from-try-catch.mdx b/docs/migration/from-try-catch.mdx index bb74873..24f0356 100644 --- a/docs/migration/from-try-catch.mdx +++ b/docs/migration/from-try-catch.mdx @@ -84,7 +84,7 @@ async function fetchUserOld(id: string): Promise { type ApiError = Readonly<{ name: "ApiError"; message: string }>; async function fetchUser(id: string): Promise> { - return tryAsync({ + return tryAsync({ try: () => fetchUserOld(id), catch: (error) => Err({ name: "ApiError", @@ -181,7 +181,7 @@ function parseConfig(json: string): Config { // After function parseConfig(json: string): Result { - return trySync({ + return trySync({ try: () => { const parsed = JSON.parse(json); return validateConfig(parsed); @@ -426,7 +426,7 @@ class UserService { if (existsResult.error) return existsResult; // Step 4: Hash password with error handling - const hashResult = await tryAsync({ + const hashResult = await tryAsync({ try: () => bcrypt.hash(input.password, 10), catch: (error) => Err({ name: "DatabaseError", @@ -479,7 +479,7 @@ class UserService { private async checkUserExists( email: string ): Result { - const result = await tryAsync({ + const result = await tryAsync({ try: () => this.db.findByEmail(email), catch: (error) => Err({ name: "DatabaseError", @@ -642,7 +642,7 @@ async function fetchUser(id: string): Promise { ### Pattern 4: Graceful Error Handling with Ok(undefined) -One powerful pattern with `tryAsync` is treating certain "errors" as successful outcomes. This is perfect for operations where the exception doesn't represent a true failure from a business logic perspective. +One useful pattern with `tryAsync` is treating certain "errors" as successful outcomes. This is perfect for operations where the exception doesn't represent a true failure from a business logic perspective. ```typescript // Before - treating all exceptions as errors @@ -786,5 +786,5 @@ Migrating from try-catch to Results is a journey that pays dividends in: Start small, migrate gradually, and enjoy more reliable code! -Ready to continue your journey? Learn about migrating from [fp-ts](/migration/from-fp-ts), [Effect](/migration/from-effect), or [neverthrow](/migration/from-neverthrow). +Ready to continue your journey? See why wellcrafted moves away from chained combinators in [From Effect to Pragmatic Errors](/philosophy/from-effect-to-pragmatic-errors), or revisit the [Error System Design](/core/error-system). \ No newline at end of file diff --git a/docs/patterns/error-transformation.mdx b/docs/patterns/error-transformation.mdx deleted file mode 100644 index 1ac4fa7..0000000 --- a/docs/patterns/error-transformation.mdx +++ /dev/null @@ -1,316 +0,0 @@ ---- -title: 'Error Transformation Patterns' -description: 'Transform service errors into user-friendly UI errors at the query layer' -icon: 'triangle-exclamation' ---- - -# Error Transformation Patterns - -Convert low-level service errors into user-friendly UI errors at the query layer. This creates a clean separation between business logic and user interface concerns. - -## The Three-Layer Architecture - -``` -┌─────────────┐ ┌─────────────┐ ┌──────────────┐ -│ UI │ --> │ Query │ --> │ Service │ -│ Layer │ │ Layer │ │ Layer │ -└─────────────┘ └─────────────┘ └──────────────┘ - ↑ │ │ - └─── UI Errors ──────┤ │ - │ │ - │── Service Errors ───┘ -``` - -### 1. Service Layer: Domain-Specific Errors - -Services return detailed, domain-specific errors: - -```typescript -import { defineErrors, type InferErrors } from 'wellcrafted/error'; -import { tryAsync, type Result } from 'wellcrafted/result'; - -const RecorderError = defineErrors({ - Start: ({ permissions, errorCode }: { permissions: string; errorCode: string }) => ({ - message: 'Failed to start recorder', - permissions, - errorCode, - }), -}); -type RecorderError = InferErrors; - -export function createRecorderService() { - return { - async startRecording(): Promise> { - return tryAsync({ - try: async () => { - await navigator.mediaDevices.getUserMedia({ audio: true }); - }, - catch: (error) => RecorderError.Start({ - permissions: 'microphone', - errorCode: error.code, - }), - }); - }, - }; -} -``` - -Service errors are technical, domain-specific, and **not suitable for direct UI display**. - -### 2. Query Layer: Transform to UI Errors - -```typescript -import { defineQuery, defineMutation } from './client'; - -type UIError = { - title: string; - description: string; - action?: { - type: 'retry' | 'more-details' | 'settings'; - error?: unknown; - }; -}; - -export const recorder = { - startRecording: defineMutation({ - mutationKey: ['recorder', 'start'], - mutationFn: async (): Promise> => { - const { error } = await services.recorder.startRecording(); - if (error) { - return Err({ - title: 'Unable to start recording', - description: 'Please check your microphone permissions and try again.', - action: { type: 'more-details', error }, - }); - } - return Ok(undefined); - }, - }), -}; -``` - -UI errors are user-friendly, consistently formatted, and **ready for toast notifications**. - -### 3. UI Layer: Display UI Errors - -```typescript -const startRecordingMutation = createMutation(() => recorder.startRecording.options); - -startRecordingMutation.mutate(undefined, { - onError: (uiError) => { - showToast(uiError.title, { - description: uiError.description, - action: uiError.action, - }); - }, -}); -``` - -## Real-World Example: Database Service - -### Service Layer - - -```typescript -import { defineErrors, type InferErrors } from 'wellcrafted/error'; - -const DbError = defineErrors({ - FindMany: ({ table }: { table: string }) => ({ - message: `Failed to query ${table}`, - table, - }), - Create: ({ table }: { table: string }) => ({ - message: `Failed to insert into ${table}`, - table, - }), -}); -type DbError = InferErrors; - -export async function getAllRecordings(): Promise> { - return tryAsync({ - try: () => database.recordings.findMany({ orderBy: { createdAt: 'desc' } }), - catch: () => DbError.FindMany({ table: 'recordings' }), - }); -} - -export async function createRecording(recording: Recording): Promise> { - return tryAsync({ - try: () => database.recordings.create({ data: recording }), - catch: () => DbError.Create({ table: 'recordings' }), - }); -} -``` - -### Query Layer - -```typescript -type RecordingUIError = { - title: string; - description: string; - action?: { type: 'retry' | 'more-details'; error?: DbError }; -}; - -export const recordings = { - getAll: defineQuery({ - queryKey: ['recordings'], - queryFn: async (): Promise> => { - const { data, error } = await services.db.getAllRecordings(); - if (error) { - return Err({ - title: 'Failed to load recordings', - description: 'Unable to fetch your recordings. Please check your connection and try again.', - action: { type: 'retry', error }, - }); - } - return Ok(data); - }, - }), - - create: defineMutation({ - mutationKey: ['recordings', 'create'], - mutationFn: async (recording: Recording): Promise> => { - const { data, error } = await services.db.createRecording(recording); - if (error) { - return Err({ - title: 'Failed to save recording', - description: 'Unable to save your recording. Please try again.', - action: { type: 'more-details', error }, - }); - } - return Ok(data); - }, - }), -}; -``` - -### UI Layer - -```svelte - - -{#if recordingsQuery.isPending} - -{:else if recordingsQuery.error} - -{:else if recordingsQuery.data} - {#each recordingsQuery.data as recording} - - {/each} -{/if} -``` - -## Contextual Error Messages - -The same service error can map to different UI messages depending on context: - -```typescript -export const auth = { - signIn: defineMutation({ - mutationFn: async (credentials) => { - const { error } = await services.auth.signIn(credentials); - if (error) { - return Err({ - title: 'Sign in failed', - description: 'Please check your email and password.', - action: { type: 'retry' }, - }); - } - return Ok(undefined); - }, - }), - - changePassword: defineMutation({ - mutationFn: async (passwords) => { - const { error } = await services.auth.changePassword(passwords); - if (error) { - return Err({ - title: 'Password change failed', - description: 'Your current password is incorrect.', - action: { type: 'retry' }, - }); - } - return Ok(undefined); - }, - }), -}; -``` - -## Error Action Types - -Standardize the actions your UI errors can suggest: - -```typescript -type ErrorAction = - | { type: 'retry'; retryFn?: () => void } - | { type: 'more-details'; error: unknown } - | { type: 'contact-support'; supportUrl?: string } - | { type: 'settings'; settingsPath?: string }; -``` - -A shared handler keeps toast logic out of individual components: - -```typescript -function handleUIError(error: UIError) { - const labels: Record = { - retry: 'Try Again', 'more-details': 'Show Details', - 'contact-support': 'Contact Support', settings: 'Open Settings', - }; - showToast(error.title, { - description: error.description, - action: error.action - ? { label: labels[error.action.type] ?? 'OK', onClick: () => handleErrorAction(error.action) } - : undefined, - }); -} -``` - -## Anti-Patterns - -**Don't return raw service errors from the query layer** -- always transform: - -```typescript -// Bad -const getUser = defineQuery({ - queryKey: ['users', id], - queryFn: () => services.getUser(id), -}); -``` - -**Don't transform errors in components** -- do it once in the query layer: - -```typescript -// Bad -if (userQuery.error) { - showToast(transformServiceError(userQuery.error)); -} -``` - -## Benefits - -- **Separation of concerns** -- services own business logic, queries own UI messaging -- **Consistent UX** -- all errors follow the same UI format -- **Debugging context** -- original errors preserved for development -- **Type safety** -- TypeScript enforces proper handling at each layer -- **Maintainability** -- UI error messages centralized in the query layer diff --git a/docs/patterns/factory-type-pattern.mdx b/docs/patterns/factory-type-pattern.mdx deleted file mode 100644 index dc4eecc..0000000 --- a/docs/patterns/factory-type-pattern.mdx +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: The InferError Pattern for TypeScript Factories -description: A cleaner way to extract types from factory functions ---- - -# The `InferError` Pattern for TypeScript Factories - -I was building error factories in wellcrafted and kept running into this annoying pattern: - -```typescript -const ApiError = defineErrors({ - Network: ({ message }: { message: string }) => ({ message }), -}); - -// Later, when I need the type... -type NetworkError = ReturnType; -// Wait, that's verbose. There must be a better way... -``` - -Every time I needed to extract the type from a specific error variant, I'd have to do this awkward dance with `ReturnType` and property access. It felt like I was fighting TypeScript instead of working with it. - -## The Problem - -Here's what I had: - -```typescript -const ApiError = defineErrors({ - Network: ({ message }: { message: string }) => ({ message }), - Timeout: ({ ms }: { ms: number }) => ({ message: `Timed out after ${ms}ms`, ms }), -}); - -// Using a variant -ApiError.Network({ message: "Connection refused" }); - -// But how do I get the Network variant type cleanly? -// This works but is verbose: -type NetworkError = ReturnType; -``` - -The type exists. TypeScript knows about it. But extracting it requires this verbose, indirect approach. - -## The Solution: InferError and InferErrors - -So I added two utility types: - -```typescript -import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error'; - -const ApiError = defineErrors({ - Network: ({ message }: { message: string }) => ({ message }), - Timeout: ({ ms }: { ms: number }) => ({ message: `Timed out after ${ms}ms`, ms }), -}); - -// Single variant type -type NetworkError = InferError; - -// Union of all variants -type AnyApiError = InferErrors; -``` - -## How It Works - -`InferError` extracts the return type from a single variant factory: - -```typescript -type InferError = T extends (...args: any[]) => infer R ? R : never; -``` - -`InferErrors` creates a union of all variant types from a `defineErrors` result: - -```typescript -type InferErrors = T extends Record infer R> ? R : never; -``` - -Both are clean, readable, and type-safe. - -## Other Places This Pattern Shows Up - -Once I noticed this pattern, I started seeing it everywhere: - -```typescript -// Zod schemas -const userSchema = z.object({ - name: z.string(), - age: z.number(), -}); -type User = z.infer; - -// tRPC -const appRouter = router({ - getUser: procedure.query(() => ({ name: "Alice" })), -}); -type AppRouter = typeof appRouter._def; // Internal type marker - -// Custom query builders -const query = createQuery("users"); -type QueryResult = typeof query._result; // Another phantom property -``` - -These libraries all solved the same problem: How do you give developers a clean way to extract types from runtime values? - -## The Lesson - -The `InferError` / `InferErrors` pattern is about making TypeScript ergonomic. Instead of requiring users to understand the internal structure of your factory returns and chain together `ReturnType` expressions, you provide clean utility types that do the heavy lifting. - -It's just another tool for making TypeScript more ergonomic. And that's what good library design is about: making the right thing easy to do. diff --git a/docs/patterns/optional-keys.mdx b/docs/patterns/optional-keys.mdx index 0e778ac..ae60340 100644 --- a/docs/patterns/optional-keys.mdx +++ b/docs/patterns/optional-keys.mdx @@ -8,7 +8,7 @@ icon: 'key' Optional keys (`?:`) in `defineErrors` constructors are almost always a design smell. The default stance: **no optional keys**. Each variant should carry exactly the fields it needs, all required. -But optionality isn't the only smell. A constructor parameter can also have the **wrong type** (accepting a pre-formatted string when it should accept raw data) or the **wrong granularity** (accepting decomposed fields when it should accept a whole object). These are orthogonal problems — a field can have one, two, or all three smells stacked on top of each other. +But optionality isn't the only smell. A constructor parameter can also have the **wrong type** (accepting a pre-formatted string when it should accept raw data) or the **wrong granularity** (accepting decomposed fields when it should accept a whole object). These are orthogonal problems: a field can have one, two, or all three smells stacked on top of each other. ## The Six Categories @@ -155,7 +155,7 @@ HttpError.Response({ }); ``` -The `bodyMessage: string` type is a lie — the real data is `unknown` (an unparsed response body), and the call site is doing work the constructor should own. +The `bodyMessage: string` type is a lie: the real data is `unknown` (an unparsed response body), and the call site is doing work the constructor should own. **Fix: accept raw data, format in the constructor.** @@ -205,7 +205,7 @@ HttpError.Response({ response, body: await response.json() }); ``` -**Why `body` is separate from `response`:** The `.json()` parse stays at the call site because it's `async` and constructors are sync — the parsed body must be passed as its own parameter. The `response` parameter uses structural typing (`{ status: number }`) rather than a platform-specific `Response` type, so browser `Response`, Tauri responses, and test doubles all satisfy the constraint. +**Why `body` is separate from `response`:** The `.json()` parse stays at the call site because it's `async` and constructors are sync; the parsed body must be passed as its own parameter. The `response` parameter uses structural typing (`{ status: number }`) rather than a platform-specific `Response` type, so browser `Response`, Tauri responses, and test doubles all satisfy the constraint. ### 6. Genuine Enrichment (The Exception) @@ -233,22 +233,22 @@ const ApiError = defineErrors({ **When this is OK:** - The data source genuinely may not provide the value (a response header that not all servers return, an external trace ID, a stack trace) -- The error is the same variant regardless — the optional field adds debugging context, not a different meaning +- The error is the same variant regardless: the optional field adds debugging context, not a different meaning - A JSDoc comment explains **why** the field is optional ## Orthogonal Problems These six categories are independent checks. A single field can have multiple smells stacked on top of each other. For example, a `bodyMessage?: string` parameter might have **three** problems at once: -1. **Wrong type** (category 4) — `string` instead of `unknown`, with the call site calling `extractErrorMessage()` -2. **Wrong decomposition** (category 5) — extracting `.status` from a response instead of passing the response -3. **Wrong optionality** (category 1) — marked `?` but always passed +1. **Wrong type** (category 4): `string` instead of `unknown`, with the call site calling `extractErrorMessage()` +2. **Wrong decomposition** (category 5): extracting `.status` from a response instead of passing the response +3. **Wrong optionality** (category 1): marked `?` but always passed -Fix each independently: change the type to `unknown`, pass the whole response object, and make it required. The categories in this doc are orthogonal — apply each check separately. +Fix each independently: change the type to `unknown`, pass the whole response object, and make it required. The categories in this doc are orthogonal; apply each check separately. ## The Rule -> If removing the optional key would force you to split the variant — **split the variant**. If the call site is doing work the constructor could own — **move it into the constructor**. Optional keys in `defineErrors` should be the exception, not the default. When they must exist, document why with JSDoc. +> If removing the optional key would force you to split the variant, **split the variant**. If the call site is doing work the constructor could own, **move it into the constructor**. Optional keys in `defineErrors` should be the exception, not the default. When they must exist, document why with JSDoc. ## Quick Checklist @@ -256,7 +256,7 @@ Fix each independently: change the type to `unknown`, pass the whole response ob |----------|-----------| | Is the field always passed at every call site? | Make it required | | Does the field change the error message or recovery path? | Split into separate variants | -| Do different call sites pass different subsets of fields? | You have N variants sharing a trenchcoat — split them | +| Do different call sites pass different subsets of fields? | You have N variants sharing a trenchcoat; split them | | Is the field a pre-formatted string derived from raw data? | Accept raw data (`unknown`), format in constructor | | Is the call site decomposing an object to extract the field? | Pass the object, let constructor extract | | Is the field truly unavailable in some scenarios? | Keep optional, add JSDoc explaining why | \ No newline at end of file diff --git a/docs/patterns/real-world.mdx b/docs/patterns/real-world.mdx index 344b797..15e92fc 100644 --- a/docs/patterns/real-world.mdx +++ b/docs/patterns/real-world.mdx @@ -373,15 +373,15 @@ export function createApplicationService( ## Key Takeaways -1. **Errors in signatures** — every example makes failures visible in the return type, no surprise runtime exceptions. -2. **Error transformation at boundaries** — database errors become service errors, service errors become HTTP responses. -3. **Branded types prevent mixups** — `UserId` and `SessionToken` can never be accidentally swapped. -4. **Framework agnostic** — these patterns work in Next.js, Svelte, plain Node, or anywhere TypeScript runs. +1. **Errors in signatures**: every example makes failures visible in the return type, no surprise runtime exceptions. +2. **Error transformation at boundaries**: database errors become service errors, service errors become HTTP responses. +3. **Branded types prevent mixups**: `UserId` and `SessionToken` can never be accidentally swapped. +4. **Framework agnostic**: these patterns work in Next.js, Svelte, plain Node, or anywhere TypeScript runs. -**Why split variants?** The examples above use specific variants like `LoginFailed`, `FileTooLarge`, and `InvalidFields` rather than a single catch-all `Operation` variant. This is the recommended pattern — each variant carries exactly the context it needs as required fields, and consumers can `switch` on `error.name` to handle each case distinctly. A single catch-all variant is only acceptable when *all* failures are handled identically by every consumer. For guidance on when optional keys are appropriate, see [Optional Keys in Error Definitions](/patterns/optional-keys). +**Why split variants?** The examples above use specific variants like `LoginFailed`, `FileTooLarge`, and `InvalidFields` rather than a single catch-all `Operation` variant. This is the recommended pattern: each variant carries exactly the context it needs as required fields, and consumers can `switch` on `error.name` to handle each case distinctly. A single catch-all variant is only acceptable when *all* failures are handled identically by every consumer. For guidance on when optional keys are appropriate, see [Optional Keys in Error Definitions](/patterns/optional-keys). -For larger-scale architecture, see the [Whispering case study](/case-studies/whispering-architecture) or the [service layer patterns](/patterns/service-layer). +For larger-scale architecture, see the [service layer patterns](/patterns/service-layer). diff --git a/docs/patterns/service-layer.mdx b/docs/patterns/service-layer.mdx index 6afa6b3..76df0fb 100644 --- a/docs/patterns/service-layer.mdx +++ b/docs/patterns/service-layer.mdx @@ -6,7 +6,7 @@ icon: 'layer-group' # Service Layer Pattern -Build robust, testable services using wellcrafted's Result types and error handling with the factory function pattern — no classes needed. +Build testable services using wellcrafted's Result types and error handling with the factory function pattern: no classes needed. ## Core Principles @@ -84,7 +84,7 @@ export const UserServiceLive = createUserService(databaseInstance); ```typescript import { defineErrors, type InferErrors } from 'wellcrafted/error'; -// Separate namespaces — payment and persistence are different concerns +// Separate namespaces: payment and persistence are different concerns const PaymentError = defineErrors({ PaymentFailed: ({ amount }: { amount: number }) => ({ message: 'Payment processing failed', diff --git a/docs/philosophy/brand-implementation.mdx b/docs/philosophy/brand-implementation.mdx index 0bb10f6..4c374d5 100644 --- a/docs/philosophy/brand-implementation.mdx +++ b/docs/philosophy/brand-implementation.mdx @@ -91,7 +91,7 @@ type Brand = { [k in K]: K }; interface UserId extends Brand<"UserId"> {} ``` -Effect maps `K` to itself (`{ UserId: "UserId" }`) rather than to `true`. Both approaches work for brand stacking; we chose boolean markers for simpler semantics—the marker's presence is what matters, not its value. +Effect maps `K` to itself (`{ UserId: "UserId" }`) rather than to `true`. Both approaches work for brand stacking; we chose boolean markers for simpler semantics: the marker's presence is what matters, not its value. ### ArkType @@ -113,7 +113,7 @@ We chose `true` as the marker value for several reasons: 2. **Conventional pattern**: Boolean flags are a common TypeScript idiom for feature detection 3. **Minimal surface**: `true` is the simplest possible "yes" value -The alternative of self-mapping (`{ UserId: "UserId" }`) works identically at runtime but adds conceptual overhead—why store the name twice? +The alternative of self-mapping (`{ UserId: "UserId" }`) works identically at runtime but adds conceptual overhead: why store the name twice? ## Practical Implications @@ -142,6 +142,6 @@ This is the same subtyping relationship you'd expect from class inheritance, but ## Further Reading -- [Brand Types](/core/brand-types) — Usage guide and common patterns -- [Effect-TS Brand module](https://github.com/Effect-TS/effect/blob/main/packages/effect/src/Brand.ts) — Alternative implementation with self-mapping -- [TypeScript Handbook: Branded Types](https://www.typescriptlang.org/play/#example/nominal-typing) — Official playground example +- [Brand Types](/core/brand-types): Usage guide and common patterns +- [Effect-TS Brand module](https://github.com/Effect-TS/effect/blob/main/packages/effect/src/Brand.ts): Alternative implementation with self-mapping +- [TypeScript Handbook: Branded Types](https://www.typescriptlang.org/play/#example/nominal-typing): Official playground example diff --git a/docs/philosophy/design-principles.mdx b/docs/philosophy/design-principles.mdx index f042bea..366e677 100644 --- a/docs/philosophy/design-principles.mdx +++ b/docs/philosophy/design-principles.mdx @@ -7,9 +7,9 @@ icon: 'lightbulb' # Design Principles > "The best programs are written not by adding features, but by removing them." -> — Antoine de Saint-Exupéry (paraphrased) +> Antoine de Saint-Exupéry (paraphrased) -wellcrafted is built on four core principles that guide every design decision. These aren't abstract ideals—they're practical philosophies proven in 22,824 lines of production TypeScript code. +wellcrafted is built on four core principles that guide every design decision. These aren't abstract ideals; they're practical philosophies proven in 22,824 lines of production TypeScript code. ## 1. Errors as Values, Not Control Flow @@ -82,7 +82,7 @@ if (error) { **Why this works better**: - **No surprise exceptions**: You see all failure modes upfront -- **Type safety**: TypeScript ensures you handle every error case +- **Type safety**: TypeScript lets you enforce that every error case is handled - **Debuggable**: Error context shows exactly what went wrong - **Serializable**: Errors are plain objects that work everywhere @@ -98,7 +98,7 @@ This shift from exceptional cases to explicit cases transforms error handling fr ### JavaScript's Hidden Strengths -JavaScript gets a lot of criticism, but it has some genuinely powerful features that wellcrafted embraces: +JavaScript gets a lot of criticism, but it has some genuinely useful features that wellcrafted embraces: **Plain Objects**: No classes, no prototypes, no inheritance complexity. Just `{ data, error }` objects that work everywhere. @@ -121,13 +121,13 @@ if (result.error) { ### What We Don't Do -**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 Method Chaining**: Rust's `.map()` and `.and_then()` read well 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. 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 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. -**No Magic**: Every pattern is explicit and visible. No hidden behavior, no surprising transformations. +**No Hidden Behavior**: Every pattern is explicit and visible. No concealed behavior, no surprising transformations. ### The Unix Philosophy for TypeScript @@ -313,7 +313,7 @@ Simple primitives that compose well scale from scripts to applications: ## Conclusion -wellcrafted's design principles aren't academic abstractions—they're practical philosophies proven in production code. By treating errors as values, working with JavaScript's strengths, making behavior explicit, and favoring composition over complexity, we create code that is: +wellcrafted's design principles aren't academic abstractions; they're practical philosophies proven in production code. By treating errors as values, working with JavaScript's strengths, making behavior explicit, and favoring composition over complexity, we create code that is: - **Reliable**: No hidden failure modes or surprise exceptions - **Maintainable**: Clear patterns that new developers can understand diff --git a/docs/philosophy/developer-experience.mdx b/docs/philosophy/developer-experience.mdx index d8906e3..b9f535b 100644 --- a/docs/philosophy/developer-experience.mdx +++ b/docs/philosophy/developer-experience.mdx @@ -137,7 +137,11 @@ async function handleApiCall(result: ApiResult) { case "ValidationError": return highlightValidationErrors(result.error.fields); - // TypeScript ensures you handle all cases + default: { + // add a variant without a case and this assignment stops compiling + const _exhaustive: never = result.error; + return _exhaustive; + } } } else { return processSuccessfulResult(result.data); @@ -260,18 +264,23 @@ type ApiError = NetworkError | AuthError | RateLimitError; TypeScript immediately shows you every place that needs updating: ```typescript -// Compilation error: switch statement is no longer exhaustive function handleApiError(error: ApiError) { switch (error.name) { case "NetworkError": return handleNetworkError(error); case "AuthError": return handleAuthError(error); - // TypeScript error: missing case for "RateLimitError" + default: { + // RateLimitError now reaches here, so `error` is not `never`: + const _exhaustive: never = error; // compile error until you add the case + return _exhaustive; + } } } ``` +The `never` assignment in `default` is what makes this a compile error; a plain `switch` would let the new variant fall through silently. + **Refactoring safety**: - **No missed error cases**: TypeScript finds every location that needs updating - **Gradual migration**: You can update one function at a time @@ -347,7 +356,7 @@ Because wellcrafted errors are explicit in your code, source maps point to the a ```typescript async function processPayment(order: Order): Promise> { const { data: validation, error } = await validatePayment(order); - if (error) return error; + if (error) return Err(error); const { data: payment, error: paymentError } = await chargeCard(order.total); if (paymentError) { @@ -549,7 +558,7 @@ async function getUser(id: string) { } ``` -This lets you adopt Result types incrementally — new code uses Results directly, legacy code gets wrapped at the boundary. The factory owns the message template and the `extractErrorMessage` call, so call sites stay clean. +This lets you adopt Result types incrementally: new code uses Results directly, legacy code gets wrapped at the boundary. The factory owns the message template and the `extractErrorMessage` call, so call sites stay clean. ### Framework Integration @@ -600,7 +609,7 @@ function UserProfile({ userId }: { userId: string }) { ## Conclusion -wellcrafted transforms error handling from a source of bugs into a source of confidence. The developer experience improvements go beyond syntax — they fundamentally change how you think about and work with failure scenarios. +wellcrafted transforms error handling from a source of bugs into a source of confidence. The developer experience improvements go beyond syntax; they fundamentally change how you think about and work with failure scenarios. **Mental model benefits**: - Shift from defensive exception handling to constructive outcome handling @@ -617,7 +626,7 @@ wellcrafted transforms error handling from a source of bugs into a source of con - Clear, explicit testing patterns for both success and failure paths - Source maps that point to meaningful business logic -Once you experience error handling that actually helps you write better code, it's hard to go back to hoping exceptions don't happen. You're not just writing more reliable code — you're writing code that helps future developers (including yourself) understand and maintain complex error scenarios. +Once you experience error handling that actually helps you write better code, it's hard to go back to hoping exceptions don't happen. You're not just writing more reliable code; you're writing code that helps future developers (including yourself) understand and maintain complex error scenarios. --- diff --git a/docs/philosophy/err-null-is-ok-null.md b/docs/philosophy/err-null-is-ok-null.md index 3e99fa3..6351be8 100644 --- a/docs/philosophy/err-null-is-ok-null.md +++ b/docs/philosophy/err-null-is-ok-null.md @@ -10,7 +10,7 @@ wellcrafted's Result shape has a blind spot. If you pass `null` to the `Err` con ## The shape -Wellcrafted's headline feature is that a Result looks like what you already know — the same `{ data, error }` shape Supabase and SvelteKit load functions use: +wellcrafted's headline feature is that a Result looks like what you already know: the same `{ data, error }` shape Supabase and SvelteKit load functions use: ```typescript type Ok = { data: T; error: null }; @@ -41,8 +41,8 @@ Same runtime object. Property order doesn't matter in JavaScript. `isErr` checks ```typescript const result = Err(null); -isOk(result); // true — wrong -isErr(result); // false — wrong +isOk(result); // true, wrong +isErr(result); // false, wrong ``` Your failure became a success. The type system said the variable was `Err`; the runtime said it was `Ok`. The discriminator lied. @@ -54,7 +54,7 @@ This isn't a bug we can patch. It's the shape telling you what it can and can't A tagged-union Result can: ```rust -// Rust — two variants with a discriminant byte +// Rust: two variants with a discriminant byte enum Result { Ok(T), Err(E) } let success: Result<(), ()> = Ok(()); @@ -64,7 +64,7 @@ matches!(failure, Err(_)) // true `Ok(())` and `Err(())` are runtime-distinguishable even when `T` and `E` are both the unit type. The discriminant byte holds the tag. `match` reads the byte, not the payload. -Wellcrafted can't do this without giving up the destructuring shape. No discriminant byte lives in `{ data, error }`. The shape *is* the discriminator, and when both slots are `null`, the shape has nothing to say. +wellcrafted can't do this without giving up the destructuring shape. No discriminant byte lives in `{ data, error }`. The shape *is* the discriminator, and when both slots are `null`, the shape has nothing to say. This isn't a universal property of Results. It's a consequence of the shape we chose. Rust disagrees with wellcrafted because Rust chose differently. @@ -87,10 +87,10 @@ We shipped it. Reviewed it. Reverted it. Here's why. The ban catches the literal case: `Err(null)` as written. Everything else slips through. ```typescript -Err(value as any) // bypassed — any cast defeats the constraint -Err(value as NonNullable) // bypassed — the cast is a lie if value is actually null +Err(value as any) // bypassed: any cast defeats the constraint +Err(value as NonNullable) // bypassed: the cast is a lie if value is actually null Err(value) // bypassed if typeof value permits null via a bad upstream type -({ error: null, data: null }) as Err // bypassed — direct object construction +({ error: null, data: null }) as Err // bypassed: direct object construction ``` Most damning: the migration for the ban itself used this pattern: @@ -102,7 +102,7 @@ catch (error) { } ``` -The `as NonNullable` cast silences TypeScript without preventing the runtime case. If `TError` includes null-thrown values (and `catch (e: unknown)` always does — `throw null` is legal JavaScript), this cast *is* the bug it claims to fix. The ban's own migration produced unsafe casts. +The `as NonNullable` cast silences TypeScript without preventing the runtime case. If `TError` includes null-thrown values (and `catch (e: unknown)` always does, since `throw null` is legal JavaScript), this cast *is* the bug it claims to fix. The ban's own migration produced unsafe casts. ### The cost is wide @@ -118,13 +118,13 @@ Multiply that by every `tryAsync`/`trySync` catch in every service file in every ### The teaching value is replaceable -What the ban *wanted* to teach: "don't pass raw `unknown` to `Err` — wrap it in a tagged error instead." +What the ban *wanted* to teach: "don't pass raw `unknown` to `Err`; wrap it in a tagged error instead." What the ban *actually* taught, most of the time: "add `as NonNullable` to make the type error go away." The fix at hand is a cast. The cast is wrong. The type error doesn't explain the right fix. So the ban teaches the wrong lesson more often than the right one. -The correct lesson — **use `defineErrors` and pass `{ cause: error }`** — is better taught by documentation than by a compile error. The tagged error is non-null by construction, so the shape's invariant holds even if the cause was `null`. Documentation + idiom enforces the rule more reliably than the constraint does. +The correct lesson (**use `defineErrors` and pass `{ cause: error }`**) is better taught by documentation than by a compile error. The tagged error is non-null by construction, so the shape's invariant holds even if the cause was `null`. Documentation + idiom enforces the rule more reliably than the constraint does. ## What we shipped instead @@ -132,7 +132,7 @@ Errs can still be constructed with any value. `Err` has no `NonNullable` cons The documented rule: -> `Err(null)` produces `{ data: null, error: null }` — structurally identical to `Ok(null)`. Under our shape, `isErr`/`isOk` read it as Ok, so `Err(null)` silently becomes success. `Err(undefined)` is also discouraged — the discriminator technically works (the error field is `undefined`, not `null`), but `undefined` is falsy so `if (error)` checks trip downstream, and the error carries no information. **Don't call `Err` with `null` or `undefined`.** Either: +> `Err(null)` produces `{ data: null, error: null }`, structurally identical to `Ok(null)`. Under our shape, `isErr`/`isOk` read it as Ok, so `Err(null)` silently becomes success. `Err(undefined)` is also discouraged: the discriminator technically works (the error field is `undefined`, not `null`), but `undefined` is falsy so `if (error)` checks trip downstream, and the error carries no information. **Don't call `Err` with `null` or `undefined`.** Either: > > - Use `Ok(null)`/`Ok(undefined)` (if what you meant was success-with-no-payload). > - Define a tagged error via `defineErrors` with a real name. @@ -157,11 +157,11 @@ const result = await tryAsync({ }); ``` -The tagged error `{ name: 'Unexpected', message, cause, ... }` is always non-null — it's a constructed object. `Err(taggedError)` produces `{ error: taggedError, data: null }`, which has a non-null error side. `isErr` reads it correctly. The shape's invariant is preserved, and the author didn't have to know the invariant existed. +The tagged error `{ name: 'Unexpected', message, cause, ... }` is always non-null; it's a constructed object. `Err(taggedError)` produces `{ error: taggedError, data: null }`, which has a non-null error side. `isErr` reads it correctly. The shape's invariant is preserved, and the author didn't have to know the invariant existed. ## The meta-lesson -Shape choices are invariant choices. When wellcrafted picked the destructure-friendly `{ data, error }` shape, it picked a discriminator (`error !== null`) that implicitly assumes error values are never null. That assumption isn't documented in the shape — the shape has no way to document it — so it has to live as a convention. +Shape choices are invariant choices. When wellcrafted picked the destructure-friendly `{ data, error }` shape, it picked a discriminator (`error !== null`) that implicitly assumes error values are never null. That assumption isn't documented in the shape (the shape has no way to document it), so it has to live as a convention. We tried to promote the convention into a type-level constraint. The attempt failed because: diff --git a/docs/philosophy/error-api-evolution.mdx b/docs/philosophy/error-api-evolution.mdx index 8ae55ed..245addd 100644 --- a/docs/philosophy/error-api-evolution.mdx +++ b/docs/philosophy/error-api-evolution.mdx @@ -80,7 +80,7 @@ Each method returned a new builder type with the constraint baked in. `.withCont 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() was a phantom call — purely type-level, disguised as a method +// .withFields() 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}`); diff --git a/docs/philosophy/for-the-pragmatic-fp-developer.mdx b/docs/philosophy/for-the-pragmatic-fp-developer.mdx index e14efe3..c38e33d 100644 --- a/docs/philosophy/for-the-pragmatic-fp-developer.mdx +++ b/docs/philosophy/for-the-pragmatic-fp-developer.mdx @@ -24,13 +24,13 @@ You tried an FP error handling library and it felt like writing in a foreign lan 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. +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. +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. @@ -75,7 +75,7 @@ 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 dependency injection.** Effect's service system is genuinely well-designed. 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. @@ -94,13 +94,13 @@ These are real things you give up. They're also things that most TypeScript appl | 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. +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. +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. diff --git a/docs/philosophy/from-effect-to-pragmatic-errors.mdx b/docs/philosophy/from-effect-to-pragmatic-errors.mdx index 15a5172..b239c63 100644 --- a/docs/philosophy/from-effect-to-pragmatic-errors.mdx +++ b/docs/philosophy/from-effect-to-pragmatic-errors.mdx @@ -106,7 +106,7 @@ These aren't features TypeScript chose not to include. They're features that req ## The Compromise That Actually Works -The insight behind wellcrafted: take the one idea from FP error handling that TypeScript can express well—typed, named error variants—and implement it using patterns the language is built for. +The insight behind wellcrafted: take the one idea from FP error handling that TypeScript can express well (typed, named error variants) and implement it using patterns the language is built for. ```typescript import { defineErrors, extractErrorMessage, type InferErrors } from "wellcrafted/error"; @@ -170,6 +170,6 @@ wellcrafted is not a functional programming library. It doesn't have `.map()` or If your team has bought into Effect and it's working, keep using it. If you need structured concurrency or runtime dependency injection, wellcrafted doesn't have it. Those are real capabilities for teams that need them. -But if you tried FP error handling, found it awkward, and went back to untyped `try-catch`—you don't have to stay there. The gap between "raw try-catch" and "full functional runtime" is wide, and there's a practical spot in the middle: typed errors, plain objects, patterns your team already knows. +But if you tried FP error handling, found it awkward, and went back to untyped `try-catch`, you don't have to stay there. The gap between "raw try-catch" and "full functional runtime" is wide, and there's a practical spot in the middle: typed errors, plain objects, patterns your team already knows. The problem was never that you couldn't learn generators or method chains. The problem was that TypeScript doesn't have the features that make those patterns worth the cost. diff --git a/docs/philosophy/production-reliability.mdx b/docs/philosophy/production-reliability.mdx index fe4fa8c..a4131c5 100644 --- a/docs/philosophy/production-reliability.mdx +++ b/docs/philosophy/production-reliability.mdx @@ -10,7 +10,7 @@ icon: 'shield-check' Production systems have a different relationship with errors than development environments. In development, you can restart, debug, and iterate quickly. In production, every failure impacts real users, costs money, and damages trust. -wellcrafted's error-handling patterns weren't designed in isolation—they evolved from real production pain points where traditional exception handling fell short. +wellcrafted's error-handling patterns weren't designed in isolation; they evolved from real production pain points where traditional exception handling fell short. ## The Hidden Cost of Exceptions @@ -53,7 +53,7 @@ async function updateProfile(userId: string, data: ProfileData) { **What went wrong?** The try-catch block silently swallowed errors. Callers had no way to know that the operation failed. Users saw "Profile updated successfully" while their data was never saved. -**How long to detect?** Days or weeks. Silent failures are the worst kind—they break user trust without triggering your monitoring systems. +**How long to detect?** Days or weeks. Silent failures are the worst kind: they break user trust without triggering your monitoring systems. ### Scenario 3: The Cascade Failure @@ -86,7 +86,17 @@ try { ```typescript type PaymentError = Readonly<{ name: "PaymentError"; message: string }>; type ValidationError = Readonly<{ name: "ValidationError"; message: string }>; -type GatewayError = Readonly<{ name: "GatewayError"; message: string }>; +type GatewayError = Readonly<{ + name: "GatewayError"; + message: string; + orderId: string; + amount: number; + currency: string; + paymentMethodId: string; + customerId: string; + gatewayResponse: number | undefined; + timestamp: string; +}>; async function processPayment( order: Order, @@ -105,17 +115,18 @@ async function processPayment( paymentMethodId: paymentMethod.id, customerId: order.customerId }), - catch: (error): GatewayError => ({ - name: "GatewayError", - message: "Payment gateway request failed", - orderId: order.id, - amount: order.total, - currency: order.currency, - paymentMethodId: paymentMethod.id, - customerId: order.customerId, - gatewayResponse: error.response?.status, - timestamp: new Date().toISOString(), - }) + catch: (error) => + Err({ + name: "GatewayError", + message: "Payment gateway request failed", + orderId: order.id, + amount: order.total, + currency: order.currency, + paymentMethodId: paymentMethod.id, + customerId: order.customerId, + gatewayResponse: (error as { response?: { status?: number } }).response?.status, + timestamp: new Date().toISOString(), + }) }); if (error) return Err(error); @@ -209,13 +220,13 @@ async function handleProfileUpdate(req: Request): Promise { } ``` -**What changed**: The calling code *must* handle the error case. TypeScript enforces this—you can't access `profile` without first checking if `error` is null. Silent failures become impossible. +**What changed**: The calling code *must* handle the error case. TypeScript enforces this: you can't access `profile` without first checking if `error` is null. Silent failures become impossible. ### Scenario 3 Solved: Discriminated Error Types ```typescript type ApiError = - | Readonly<{ name: "RateLimitError"; message: string }> + | Readonly<{ name: "RateLimitError"; message: string; retryAfter: string | null }> | Readonly<{ name: "NetworkError"; message: string }> | Readonly<{ name: "AuthError"; message: string }> | Readonly<{ name: "NotFoundError"; message: string }>; @@ -244,45 +255,38 @@ async function fetchUserData( return response.json(); }, - catch: (error): ApiError => { - if (typeof error === "object" && error?.type) { - switch (error.type) { - case "rate_limit": - return { - name: "RateLimitError", - message: "API rate limit exceeded", - userId: id, - retryAfter: error.retryAfter, - timestamp: new Date().toISOString(), - }; - case "auth": - return { - name: "AuthError", - message: "Authentication failed", - userId: id, - status: error.status, - }; - case "not_found": - return { - name: "NotFoundError", - message: "User not found", - userId: id, - }; - default: - return { - name: "NetworkError", - message: "Network request failed", - userId: id, - status: error.status, - }; - } + catch: (error) => { + const e = error as { type?: string; retryAfter?: string | null; status?: number }; + switch (e.type) { + case "rate_limit": + return Err({ + name: "RateLimitError" as const, + message: "API rate limit exceeded", + userId: id, + retryAfter: e.retryAfter ?? null, + timestamp: new Date().toISOString(), + }); + case "auth": + return Err({ + name: "AuthError" as const, + message: "Authentication failed", + userId: id, + status: e.status, + }); + case "not_found": + return Err({ + name: "NotFoundError" as const, + message: "User not found", + userId: id, + }); + default: + return Err({ + name: "NetworkError" as const, + message: e.type ? "Network request failed" : "Unexpected error", + userId: id, + status: e.status, + }); } - - return { - name: "NetworkError", - message: "Unexpected error", - userId: id, - }; } }); } @@ -452,12 +456,17 @@ function handleApiError(error: ApiError) { return redirectToLogin(); case "NetworkError": return showNetworkErrorDialog(); - // TypeScript error if you miss a case! + default: { + // every variant is handled, so `error` is `never` here; + // add a variant without a case and this line stops compiling + const _exhaustive: never = error; + return _exhaustive; + } } } ``` -**Team impact**: Code reviews can mechanically verify that all error cases are handled. No more "did we handle the timeout case?" discussions—TypeScript enforces completeness. +**Team impact**: Code reviews can mechanically verify that all error cases are handled. No more "did we handle the timeout case?" discussions: the `never` check in `default` enforces completeness. ## The Debugging Advantage @@ -527,7 +536,7 @@ const userError = { ### Structured Error Metrics -wellcrafted's consistent error structure enables powerful monitoring: +wellcrafted's consistent error structure enables structured monitoring: ```typescript // Automatic error categorization @@ -581,7 +590,7 @@ function updateSLI(error: BaseError) { ## The Production Reality Check -Production systems taught us that error handling isn't just about catching exceptions—it's about building systems that fail gracefully, provide actionable feedback, and maintain observability under stress. +Production systems taught us that error handling isn't just about catching exceptions: it's about building systems that fail gracefully, provide actionable feedback, and maintain observability under stress. wellcrafted's patterns emerged from these production lessons: @@ -591,7 +600,7 @@ wellcrafted's patterns emerged from these production lessons: - **Error messages should guide users toward resolution**, not just report that something broke - **Monitoring systems need structured error data** to provide meaningful insights -These aren't theoretical concerns—they're the difference between a system that scales gracefully and one that requires constant manual intervention. +These aren't theoretical concerns: they're the difference between a system that scales gracefully and one that requires constant manual intervention. ## Conclusion diff --git a/docs/philosophy/rust-inspiration.mdx b/docs/philosophy/rust-inspiration.mdx index 1af889f..08c6c78 100644 --- a/docs/philosophy/rust-inspiration.mdx +++ b/docs/philosophy/rust-inspiration.mdx @@ -30,7 +30,7 @@ enum HttpError { A few things to notice: -- **`HttpError` is the namespace.** The variants — `Connection`, `Response`, `Parse` — live under it. They are short, one-word names because the enum name already provides the context. +- **`HttpError` is the namespace.** The variants (`Connection`, `Response`, `Parse`) live under it. They are short, one-word names because the enum name already provides the context. - **Each variant is a struct with named fields.** `Connection` carries a `cause`. `Response` carries a `status` and an optional `body_message`. The fields are part of the type. - **`#[error("...")]` defines the display string** for each variant, interpolating fields by name. - **You construct** with `HttpError::Connection { cause: "timeout".into() }` and **discriminate** with `match`. @@ -104,7 +104,7 @@ switch (error.name) { ``` -The `name` field on each error object is the discriminant. It is stamped automatically from the key you provide — `'Connection'`, `'Response'`, `'Parse'`. You never write it by hand; `defineErrors` handles it. That is directly analogous to how Rust stamps the variant identity into the enum value at construction time. +The `name` field on each error object is the discriminant. It is stamped automatically from the key you provide: `'Connection'`, `'Response'`, `'Parse'`. You never write it by hand; `defineErrors` handles it. That is directly analogous to how Rust stamps the variant identity into the enum value at construction time. ## Five Places Where the Languages Diverge @@ -138,7 +138,7 @@ Both co-locate the message template with the variant definition. Both interpolat ### `Err<...>` Wrapping Instead of Direct Returns -In Rust, a function returning `Result` returns the error variant directly. Rust's `?` operator and return type tell the compiler which side of the Result you are on. TypeScript does not have that. `defineErrors` factories always return `Err<...>` — an object shaped `{ data: null, error: ... }` — so that `trySync` and `tryAsync` can tell errors apart from successful values without ambiguity. +In Rust, a function returning `Result` returns the error variant directly. Rust's `?` operator and return type tell the compiler which side of the Result you are on. TypeScript does not have that. `defineErrors` factories always return `Err<...>` (an object shaped `{ data: null, error: ... }`) so that `trySync` and `tryAsync` can tell errors apart from successful values without ambiguity. ### `Object.freeze` Instead of Ownership @@ -146,7 +146,7 @@ Rust's ownership model prevents mutation after construction. TypeScript has no o ### Discriminated Unions Instead of `match` -Rust's `match` is exhaustive by default — the compiler forces you to handle every variant. TypeScript has no native pattern matching yet, but discriminated unions on `error.name` get you most of the way there. A `switch` on a string literal union narrows the type in each branch, and you can use `never` checks for exhaustiveness if you want it. +Rust's `match` is exhaustive by default; the compiler forces you to handle every variant. TypeScript has no native pattern matching yet, but discriminated unions on `error.name` get you most of the way there. A `switch` on a string literal union narrows the type in each branch, and you can use `never` checks for exhaustiveness if you want it. | Difference | Rust | TypeScript | Why | |---|---|---|---| @@ -160,7 +160,7 @@ Rust's `match` is exhaustive by default — the compiler forces you to handle ev The thing that unlocked the `defineErrors` design comes straight from Rust: **the enum name is the namespace, the variant name is the discriminant.** -In Rust, you would never name a variant `ConnectionError` inside an enum called `HttpError`. That would be `HttpError::ConnectionError` — redundant. You name it `Connection`. The enum already tells you it is an `HttpError`. The variant tells you which kind. +In Rust, you would never name a variant `ConnectionError` inside an enum called `HttpError`. That would be `HttpError::ConnectionError`, redundant. You name it `Connection`. The enum already tells you it is an `HttpError`. The variant tells you which kind. The same logic applies in TypeScript: @@ -174,7 +174,7 @@ const HttpError = defineErrors({ `HttpError` is the context. `Connection` is the discriminant. The `name` on the error object will be `'Connection'`, not `'HttpConnectionError'` or `'HttpError.Connection'`. Short, unambiguous, and exactly what you `switch` on. -This is the pattern that was missing from TypeScript error handling. Not just a way to make errors with `name` fields — but a way to define a family of errors under a shared namespace with the same structural clarity that Rust's enum system provides. +This is the pattern that was missing from TypeScript error handling. Not just a way to make errors with `name` fields, but a way to define a family of errors under a shared namespace with the same structural clarity that Rust's enum system provides. **The enum name is the namespace. The variant name is the discriminant. Everything else follows from there.** @@ -206,7 +206,7 @@ enum NetworkError { Each failure case gets its own variant. The enum's discriminant does the work. There is no inner enum to match on separately. -The same principle applies in TypeScript. When a `defineErrors` variant has a string literal union field like `reason: 'timeout' | 'refused' | 'dns'`, that field is an inner enum in disguise. Consumers have to narrow twice — once on `error.name`, once on `error.reason` — which defeats the purpose of having a discriminated union in the first place. +The same principle applies in TypeScript. When a `defineErrors` variant has a string literal union field like `reason: 'timeout' | 'refused' | 'dns'`, that field is an inner enum in disguise. Consumers have to narrow twice (once on `error.name`, once on `error.reason`), which defeats the purpose of having a discriminated union in the first place. ```typescript // The Rust instinct is correct here: make each case a variant @@ -226,7 +226,7 @@ const NetworkError = defineErrors({ }); ``` -If you are coming from Rust, trust your instincts on this. The pattern that feels right in Rust — one variant per failure case, fields specific to that case — is exactly right here too. +If you are coming from Rust, trust your instincts on this. The pattern that feels right in Rust (one variant per failure case, fields specific to that case) is exactly right here too. ## Constructor Owns the Conversion (Rust's `#[from]`) @@ -243,14 +243,14 @@ enum AudioError { The call site never converts anything. You pass the raw `IoError` and the variant handles it: ```rust -// The variant owns the conversion — not the call site +// The variant owns the conversion, not the call site let raw: IoError = ...; let err: AudioError = raw.into(); // From impl does the work ``` This is a deliberate design choice. The error definition knows how to present itself. The call site just hands over the raw material. -The same principle applies with `defineErrors`. The constructor function is where conversion logic lives — not the call site: +The same principle applies with `defineErrors`. The constructor function is where conversion logic lives, not the call site: ```typescript import { defineErrors, extractErrorMessage } from 'wellcrafted/error'; @@ -262,7 +262,7 @@ const AudioError = defineErrors({ }), }); -// Call site — just pass the raw error, like Rust's From impl +// Call site: just pass the raw error, like Rust's From impl try { playSound(file); } catch (error) { @@ -275,7 +275,7 @@ The `extractErrorMessage` call lives inside the constructor, not at the call sit The anti-pattern is doing the conversion at the call site: ```typescript -// Don't do this — no Rust equivalent because Rust wouldn't let you +// Don't do this: no Rust equivalent because Rust wouldn't let you try { playSound(file); } catch (error) { @@ -304,7 +304,7 @@ const HttpError = defineErrors({ }), }); -// InferErrors extracts the discriminated union — no manual type definitions +// InferErrors extracts the discriminated union, no manual type definitions type HttpError = InferErrors; // = Readonly<{ name: 'Connection'; message: string; cause: unknown }> // | Readonly<{ name: 'Response'; message: string; status: number }> @@ -312,7 +312,7 @@ type HttpError = InferErrors; In Rust, the proc macro reads your enum definition and generates code. In TypeScript, `const` generic inference reads your config object and infers types. Both produce the same result: a namespace of constructors and a union type of all variants, derived from a single source of truth. The definition is the type. No duplication, no drift. -The ten lines of `defineErrors` runtime do what `thiserror`'s proc macro does: iterate the variants, stamp each one with its name, and produce ready-to-use constructors. The type system does the rest. That's the whole trick: plain functions for runtime, type inference for compile time, and the definition site looks like `thiserror` because it *is* `thiserror`—just without the macro. +The ten lines of `defineErrors` runtime do what `thiserror`'s proc macro does: iterate the variants, stamp each one with its name, and produce ready-to-use constructors. The type system does the rest. That's the whole trick: plain functions for runtime, type inference for compile time, and the definition site looks like `thiserror` because it *is* `thiserror`, just without the macro. --- diff --git a/docs/philosophy/why-name-and-message.mdx b/docs/philosophy/why-name-and-message.mdx index eb938f0..e239266 100644 --- a/docs/philosophy/why-name-and-message.mdx +++ b/docs/philosophy/why-name-and-message.mdx @@ -1,12 +1,12 @@ --- -title: "We Didn't Invent name and message—JavaScript Did" +title: "We Didn't Invent name and message: JavaScript Did" description: "Why wellcrafted errors follow JavaScript's Error convention instead of inventing new terminology" icon: 'js' --- -# We Didn't Invent `name` and `message`—JavaScript Did +# We Didn't Invent `name` and `message`: JavaScript Did -Every JavaScript `Error` has two properties: `.name` and `.message`. WellCrafted errors have the same two properties, for the same reasons, because we're not inventing a new error system. We're fixing the one JavaScript already has. +Every JavaScript `Error` has two properties: `.name` and `.message`. wellcrafted errors have the same two properties, for the same reasons, because we're not inventing a new error system. We're fixing the one JavaScript already has. ```typescript // JavaScript's Error class: @@ -27,7 +27,7 @@ error.name; // 'Connection' error.message; // 'Failed to connect: timeout' ``` -Same shape. Same semantics. `name` identifies what kind of error it is; `message` explains what happened in plain English. The difference is that WellCrafted errors are plain objects that actually serialize, carry typed fields, and work with TypeScript's discriminated unions. +Same shape. Same semantics. `name` identifies what kind of error it is; `message` explains what happened in plain English. The difference is that wellcrafted errors are plain objects that actually serialize, carry typed fields, and work with TypeScript's discriminated unions. ## JavaScript's Error Gets Two Things Right and One Thing Wrong @@ -38,7 +38,7 @@ What `Error` gets wrong is everything else. `JSON.stringify(new Error('oops'))` ```typescript // JavaScript's Error: name and message are right, serialization is broken const error = new TypeError('Expected a string'); -JSON.stringify(error); // '{}' — name and message are gone +JSON.stringify(error); // '{}', name and message are gone // WellCrafted: same name and message, serialization works const { error: wcError } = ValidationError.Type({ expected: 'string', received: 'number' }); @@ -46,7 +46,7 @@ JSON.stringify(wcError); // '{"name":"Type","message":"Expected string, got number","expected":"string","received":"number"}' ``` -WellCrafted keeps what works (`name` and `message`) and replaces what doesn't (classes, prototypes, non-serializable state) with plain frozen objects. +wellcrafted keeps what works (`name` and `message`) and replaces what doesn't (classes, prototypes, non-serializable state) with plain frozen objects. ## JavaScript Already Named These Fields @@ -61,11 +61,11 @@ switch (error.name) { } ``` -Inventing new field names would buy us nothing and cost us familiarity. A developer reading WellCrafted code for the first time already knows what `error.name` and `error.message` are. +Inventing new field names would buy us nothing and cost us familiarity. A developer reading wellcrafted code for the first time already knows what `error.name` and `error.message` are. ## Rust's Patterns Map to JavaScript Without New Abstractions -WellCrafted's `defineErrors` is directly inspired by Rust's `thiserror` crate: the namespace pattern, the variant naming, the co-located message templates. But Rust and JavaScript have different constraints, and pretending otherwise leads to APIs that fight the platform. +wellcrafted's `defineErrors` is directly inspired by Rust's `thiserror` crate: the namespace pattern, the variant naming, the co-located message templates. But Rust and JavaScript have different constraints, and pretending otherwise leads to APIs that fight the platform. Rust has compile-time procedural macros; JavaScript doesn't. Template literals are the natural equivalent: @@ -76,7 +76,7 @@ Connection { cause: String }, ``` ```typescript -// TypeScript: runtime template literal — same result, different mechanism +// TypeScript: runtime template literal, same result, different mechanism Connection: ({ cause }: { cause: unknown }) => ({ message: `Failed to connect: ${extractErrorMessage(cause)}`, cause, @@ -87,7 +87,7 @@ Both produce a human-readable message from structured fields. The Rust version r The same principle applies across every divergence: -| Rust has | JavaScript doesn't | WellCrafted uses instead | +| Rust has | JavaScript doesn't | wellcrafted uses instead | |---|---|---| | Procedural macros | No compile-time codegen | Template literals | | Ownership model | No ownership | `Object.freeze` + `Readonly` | @@ -109,12 +109,11 @@ JavaScript errors cross boundaries that Rust errors never touch. A Rust error li } ``` -This is a valid JSON object, a valid WellCrafted error, and a valid thing to log, send over HTTP, store in a database, or display in a UI. No special serialization logic, no custom `toJSON` methods, no lossy transforms. The error is the JSON. +This is a valid JSON object, a valid wellcrafted error, and a valid thing to log, send over HTTP, store in a database, or display in a UI. No special serialization logic, no custom `toJSON` methods, no lossy transforms. The error is the JSON. -That's why WellCrafted enforces that all fields are `JsonValue` types. If it can't survive a `JSON.stringify`/`JSON.parse` round-trip, it can't be a field on a WellCrafted error. +That works as long as you keep every field a plain `JsonValue`: a string, number, boolean, or nested object or array. This is a convention the library leans on, not a constraint it enforces at the type level (the only required field is `message: string`). Nothing stops you from putting a `Date` or a class instance on an error, but it will not round-trip cleanly, so reach for a string or number instead. ```typescript -// Fields must be JSON-serializable — TypeScript enforces this const FileError = defineErrors({ Write: ({ path, bytesWritten }: { path: string; bytesWritten: number }) => ({ message: `Write failed at byte ${bytesWritten}`, @@ -123,11 +122,11 @@ const FileError = defineErrors({ }), }); -// This is a type error — Date is not JSON-serializable +// Compiles, but a Date does not survive JSON.stringify; store a number instead: // Write: ({ path, timestamp }: { path: string; timestamp: Date }) => ... ``` -Error chains serialize too. When `cause` is another WellCrafted error, `JSON.stringify` produces a nested structure that preserves the full chain. No stack trace parsing, no custom serializers. This only works because the errors are plain objects, which brings us to the other half of the design. +Error chains serialize too. When `cause` is another wellcrafted error, `JSON.stringify` produces a nested structure that preserves the full chain. No stack trace parsing, no custom serializers. This only works because the errors are plain objects, which brings us to the other half of the design. ## Plain Objects Over Classes Is a JavaScript Decision @@ -144,24 +143,24 @@ class ConnectionError extends Error { const err = new ConnectionError('api.example.com'); const serialized = JSON.parse(JSON.stringify(err)); -serialized instanceof ConnectionError; // false — prototype is gone -serialized.name; // undefined — non-enumerable -serialized.message; // undefined — non-enumerable -serialized.host; // 'api.example.com' — only enumerable props survive +serialized instanceof ConnectionError; // false: prototype is gone +serialized.name; // undefined: non-enumerable +serialized.message; // undefined: non-enumerable +serialized.host; // 'api.example.com': only enumerable props survive ``` `Error` properties `name` and `message` are non-enumerable by default. They vanish on serialization. Custom subclasses lose their prototype chain. `instanceof` checks fail across iframes, Web Workers, and server/client boundaries. -WellCrafted errors don't have this problem because there's no prototype to lose. They're frozen plain objects where every property is enumerable. They work the same whether you're in a browser tab, a Web Worker, a Node.js server, or reading them back from a database. +wellcrafted errors don't have this problem because there's no prototype to lose. They're frozen plain objects where every property is enumerable. They work the same whether you're in a browser tab, a Web Worker, a Node.js server, or reading them back from a database. ## The Shape Is the Contract -WellCrafted errors make a simple bet: the shape of the object is the contract, not its class identity. If an object has `{ name: 'Connection', message: string, cause: unknown }`, it's a `Connection` error. You don't need `instanceof` to check. You don't need the original class definition in scope. You just read the `name` field. +wellcrafted errors make a simple bet: the shape of the object is the contract, not its class identity. If an object has `{ name: 'Connection', message: string, cause: unknown }`, it's a `Connection` error. You don't need `instanceof` to check. You don't need the original class definition in scope. You just read the `name` field. This is how discriminated unions work in TypeScript, and it's how data-oriented programming works in general. Identity lives in the data, not in the prototype chain. ```typescript -// The shape IS the type — TypeScript narrows on name, not instanceof +// The shape IS the type: TypeScript narrows on name, not instanceof function handle(error: HttpError) { if (error.name === 'Connection') { // TypeScript knows: error has cause field diff --git a/skills/patterns/SKILL.md b/skills/patterns/SKILL.md index c2bc2da..9690ca7 100644 --- a/skills/patterns/SKILL.md +++ b/skills/patterns/SKILL.md @@ -193,7 +193,7 @@ Services are factory functions that return objects with methods returning `Resul ```typescript import { defineErrors, extractErrorMessage, type InferErrors } from 'wellcrafted/error'; -import { Ok, tryAsync, type Result } from 'wellcrafted/result'; +import { Ok, Err, tryAsync, type Result } from 'wellcrafted/result'; // 1. Define domain errors const UserError = defineErrors({ @@ -221,7 +221,8 @@ function createUserService(db: Database) { try: () => db.users.findById(userId), catch: (cause) => UserError.FetchFailed({ cause }), }); - if (error) return error; + // error here is the raw tagged error, not an Err: wrap it before returning + if (error) return Err(error); if (!user) return UserError.NotFound({ userId }); return Ok(user); }, @@ -278,12 +279,17 @@ const HttpError = defineErrors({ // Layer 2: domain service wraps HTTP errors via cause const UserError = defineErrors({ + NotFound: ({ userId }: { userId: string }) => ({ + message: `User ${userId} not found`, + userId, + }), FetchFailed: ({ userId, cause }: { userId: string; cause: unknown }) => ({ message: `Failed to fetch user ${userId}: ${extractErrorMessage(cause)}`, userId, cause, }), }); +type UserError = InferErrors; // The HTTP error becomes cause in the domain error async function getUser(userId: string): Promise> { @@ -292,7 +298,8 @@ async function getUser(userId: string): Promise> { catch: (cause) => UserError.FetchFailed({ userId, cause }), // raw fetch error ^^^^ becomes cause }); - if (error) return error; + // error is the raw tagged error here: wrap it before returning + if (error) return Err(error); if (response.status === 404) return UserError.NotFound({ userId }); diff --git a/skills/query-factories/SKILL.md b/skills/query-factories/SKILL.md index 475c831..d852694 100644 --- a/skills/query-factories/SKILL.md +++ b/skills/query-factories/SKILL.md @@ -49,6 +49,7 @@ Same pattern for mutations: ```typescript const createPost = defineMutation({ + mutationKey: ['posts', 'create'], mutationFn: async (input: { title: string; body: string }) => { const { data, error } = await postService.create(input); if (error) { @@ -62,6 +63,8 @@ const createPost = defineMutation({ }); ``` +`mutationKey` is required on `defineMutation` and `mutationOptions`, just as `queryKey` is required on `defineQuery`. + ## Reactive Options and Imperative Helpers Every query and mutation provides reactive options plus imperative helpers. @@ -178,6 +181,7 @@ Optimistic updates for instant UI feedback: ```typescript const updateUser = defineMutation({ + mutationKey: ['users', 'update'], mutationFn: async (input: { userId: string; name: string }) => { const { data, error } = await userService.update(input); if (error) return Err({ title: 'Failed to update user', description: error.message }); diff --git a/skills/result-types/SKILL.md b/skills/result-types/SKILL.md index 34f5451..086c9bb 100644 --- a/skills/result-types/SKILL.md +++ b/skills/result-types/SKILL.md @@ -49,7 +49,7 @@ if (isErr(result)) { /* handle error */ } - Use `Ok(null)`/`Ok(undefined)` (if what you meant was success-with-no-payload). - Define a tagged error via `defineErrors` with a real name. -- Wrap a caught exception as `TaggedError.Unexpected({ cause: error })` — see below. +- Wrap a caught exception in one of your `defineErrors` variants, e.g. `MyError.Unexpected({ cause: error })` (see below). There is no `TaggedError` factory to import; you create the namespace yourself with `defineErrors`. At every `catch (error: unknown)` boundary, don't pass the raw `unknown` to `Err`. Wrap it in a tagged error via `defineErrors`. The tagged error is non-null by construction, so the shape's invariant holds regardless of what was thrown (including `throw null`). See `docs/philosophy/err-null-is-ok-null.md` for why this is a documentation rule rather than a type-level constraint. @@ -258,9 +258,9 @@ Use sparingly — `unwrap` throws, which defeats the purpose of Result types. Us ```typescript import { resolve } from 'wellcrafted/result'; -// If value is a Result, returns it as-is -// If value is not a Result, wraps it in Ok() -const result = resolve(maybeResult); +// If value is a Result: returns its data if Ok, throws if Err (like unwrap) +// If value is not a Result: returns the value unchanged +const data = resolve(maybeResult); ``` ### partitionResults — split an array of Results @@ -269,11 +269,13 @@ const result = resolve(maybeResult); import { partitionResults } from 'wellcrafted/result'; const results = await Promise.all(userIds.map(getUser)); -const { ok, err } = partitionResults(results); -// ok: User[] — just the successful values -// err: UserError[] — just the errors +const { oks, errs } = partitionResults(results); +// oks: Ok[] the successful Results (access .data on each) +// errs: Err[] the failed Results (access .error on each) ``` +The arrays hold the Result objects themselves, not the unwrapped values. Read `.data` off each `Ok` and `.error` off each `Err`. + ## Wrapping Summary | Scenario | Approach | diff --git a/src/README.md b/src/README.md index 0544f0c..29b2394 100644 --- a/src/README.md +++ b/src/README.md @@ -55,8 +55,8 @@ The implementation provides utility functions: - `Err(error: E)`: Create an Err data structure containing an error type - `isOk(result)`: Type guard to check for success - `isErr(result)`: Type guard to check for Err data structure -- `trySync({ try, catch })`: Execute a synchronous operation safely -- `tryAsync({ try, catch })`: Execute an asynchronous operation safely +- `trySync({ try, catch })`: Execute a synchronous operation safely +- `tryAsync({ try, catch })`: Execute an asynchronous operation safely ### Basic Usage @@ -73,7 +73,7 @@ if (isOk(result)) { ```typescript // Define an error type with defineErrors -const { ValidationError, ValidationErr } = defineErrors({ +const { ValidationError } = defineErrors({ ValidationError: ({ cause }: { cause: unknown }) => ({ message: `JSON parsing failed: ${extractErrorMessage(cause)}`, cause, @@ -83,7 +83,7 @@ const { ValidationError, ValidationErr } = defineErrors({ // Wrapping a potentially throwing operation const result = trySync({ try: () => JSON.parse(jsonString), - catch: (error) => ValidationErr({ cause: error }), + catch: (error) => ValidationError({ cause: error }), }); if (isErr(result)) { @@ -98,7 +98,7 @@ if (isErr(result)) { The implementation provides type inference helpers: - `UnwrapOk`: Extract the success type from a Result type -- `UnwrapError`: Extract the error type from a Result type +- `UnwrapErr`: Extract the error type from a Result type ## Best Practices diff --git a/src/result/result.ts b/src/result/result.ts index 624c1a6..e3a8fc6 100644 --- a/src/result/result.ts +++ b/src/result/result.ts @@ -83,7 +83,7 @@ export const Ok = (data: T): Ok => ({ data, error: null }); * * Wraps the provided `error` (the failure value) and sets `data` to `null`. * - * **Don't call `Err(null)`.** It produces `{ data: null, error: null }` — + * **Don't call `Err(null)`.** It produces `{ data: null, error: null }`, * structurally identical to `Ok(null)`. The built-in `isErr` check * (`result.error !== null`) reads it as Ok, silently misclassifying your * failure as success. `Err(undefined)` is also discouraged: the discriminator @@ -94,9 +94,9 @@ export const Ok = (data: T): Ok => ({ data, error: null }); * what you meant was success-with-no-payload. * * At `catch (error: unknown)` boundaries, wrap the caught value in a tagged - * error rather than passing it through — `TaggedError.X({ cause: error })` - * is always non-null by construction, so the discriminator works regardless - * of what was thrown. + * error rather than passing it through. A `defineErrors` factory like + * `MyError.Unexpected({ cause: error })` is always non-null by construction, + * so the discriminator works regardless of what was thrown. * * See `docs/philosophy/err-null-is-ok-null.md` for the full rationale. *