| title | description | icon |
|---|---|---|
Error System Design |
Understanding wellcrafted's structured, serializable error system |
triangle-exclamation |
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.
**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.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.
Traditional JavaScript errors have fundamental problems:
- Serialization breaks them:
JSON.stringify(new Error())loses the message and stack trace - No standardization: Libraries throw strings, Error objects, custom classes, or even undefined
- Prototype chain complexity:
instanceofchecks fail across different realms (iframes, workers) - Poor TypeScript integration: Can't discriminate between different error types in unions
TaggedError solves all these issues:
- JSON-serializable: Plain objects that survive any serialization boundary
- Type-safe discrimination: The
namefield acts as a discriminant for TypeScript - Lightweight: No overhead of class instantiation or prototype chains
- Flat structure: Fields are spread directly on the error object — no nesting
Every tagged error has a name and message, plus any additional fields spread flat on the object:
Readonly<{ name: TName; message: string } & TFields>The minimal type for any tagged error is:
type AnyTaggedError = { name: string; message: string };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 for why we follow this convention. Use it in if statements and switch statements to handle different error types:
const AppError = defineErrors({
Validation: ({ field }: { field: string }) => ({
message: `Validation failed for field "${field}"`,
field,
}),
Network: ({ url }: { url: string }) => ({
message: `Network request to ${url} failed`,
url,
}),
File: ({ path }: { path: string }) => ({
message: `File operation failed for ${path}`,
path,
}),
});
type AppError = InferErrors<typeof AppError>;
function handleError(error: AppError) {
switch (error.name) {
case "Validation":
// TypeScript knows this is the Validation variant
console.log("Invalid input:", error.field);
break;
case "Network":
// TypeScript knows this is the Network variant
console.log("Network failed:", error.url);
break;
case "File":
// TypeScript knows this is the File variant
console.log("File issue:", error.path);
break;
}
}When using defineErrors, the constructor function computes the message from the input fields — the caller never passes message directly:
import { defineErrors } from 'wellcrafted/error';
const AuthError = defineErrors({
Validation: ({ email }: { email: string }) => ({
message: `Email address "${email}" must contain an @ symbol`,
email,
}),
});
return AuthError.Validation({ email: userInput });Additional data is spread directly on the error object, not nested under a context property. This makes access natural and concise:
const ProcessingError = defineErrors({
Process: ({ userId, timestamp, retryCount }: { userId: number; timestamp: string; retryCount: number }) => ({
message: 'User processing failed',
userId,
timestamp,
retryCount,
}),
});
function processUser(id: number): Result<User, InferError<typeof ProcessingError.Process>> {
return ProcessingError.Process({
userId: id,
timestamp: new Date().toISOString(),
retryCount: 3
});
}
// Access fields directly:
// error.userId, error.timestamp, error.retryCountThe defineErrors API supports three tiers of complexity:
For errors where the message is always the same and no additional data is needed:
const RecordingError = defineErrors({
RecorderBusy: () => ({
message: 'A recording is already in progress',
}),
});
// No arguments at the call site
RecordingError.RecorderBusy();
// → Err({ name: 'RecorderBusy', message: 'A recording is already in progress' })For errors that wrap a caught error. Accept cause: unknown and call extractErrorMessage inside the message template — not at the call site:
const AudioError = defineErrors({
PlaySound: ({ cause }: { cause: unknown }) => ({
message: `Failed to play sound: ${extractErrorMessage(cause)}`,
cause,
}),
});
AudioError.PlaySound({ cause: error });
// → Err({ name: 'PlaySound', message: 'Failed to play sound: Audio context not initialized', cause: <original error> })For errors that carry rich, typed debugging information:
const HttpError = defineErrors({
// 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}` : ''}`,
status,
reason,
}),
});
HttpError.Response({ status: 404 });
// → Err({ name: 'Response', message: 'HTTP 404', status: 404 })
HttpError.Response({ status: 500, reason: 'Internal server error' });
// → Err({ name: 'Response', message: 'HTTP 500: Internal server error', status: 500, reason: 'Internal server error' })When a catch block hands you unknown, the constructor should own the conversion — not the call site.
Accept cause: unknown, call extractErrorMessage(cause) in the message template, and store the raw cause for programmatic access:
import { defineErrors, extractErrorMessage } from '@wellcrafted/result/error';
const AudioError = defineErrors({
PlaySound: ({ cause }: { cause: unknown }) => ({
message: `Failed to play sound: ${extractErrorMessage(cause)}`,
cause,
}),
});
// Call site stays clean — just pass the raw error
try {
await audioContext.play(soundFile);
} catch (error) {
return AudioError.PlaySound({ cause: error });
}The resulting error carries both a human-readable message and the original cause:
{
"name": "PlaySound",
"message": "Failed to play sound: The AudioContext was not allowed to start",
"cause": { /* original error object */ }
}// Don't do this — every call site must remember to call extractErrorMessage
try {
await audioContext.play(soundFile);
} catch (error) {
return AudioError.PlaySound({ reason: extractErrorMessage(error) });
}This scatters presentation logic across every catch block instead of centralizing it in the one place that defines the message format.
- 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.
causestays available. Storingcause: unknownmeans consumers can inspect the original error programmatically, not just read a stringified version.- Same principle as Rust's
#[from]. Inthiserror, the#[from]attribute tells the variant to handle conversion automatically. The call site just wraps; the definition owns the transform.
There is no dedicated cause step. If you need to chain errors, model cause as just another field:
import { defineErrors, type InferError } from 'wellcrafted/error';
// Define errors for each layer
const NetworkError = defineErrors({
SocketTimeout: ({ host, port, timeout }: { host: string; port: number; timeout: number }) => ({
message: `Socket timeout after ${timeout}ms`,
host,
port,
timeout,
}),
});
type NetworkError = InferErrors<typeof NetworkError>;
const DbError = defineErrors({
Connection: ({ retries, cause }: { retries: number; cause: NetworkError }) => ({
message: 'Failed to connect to database',
retries,
cause,
}),
});
type DbError = InferErrors<typeof DbError>;
const UserServiceError = defineErrors({
FetchProfile: ({ userId, cause }: { userId: string; cause: DbError }) => ({
message: 'Could not fetch user profile',
userId,
cause,
}),
});
type UserServiceError = InferErrors<typeof UserServiceError>;
const ApiError = defineErrors({
Internal: ({ endpoint, statusCode, cause }: { endpoint: string; statusCode: number; cause: UserServiceError }) => ({
message: 'Internal server error',
endpoint,
statusCode,
cause,
}),
});
// Low-level network error
const networkError = NetworkError.SocketTimeout({
host: "db.example.com", port: 5432, timeout: 5000
});
// Wrapped by database layer
const dbError = DbError.Connection({
retries: 3, cause: networkError.error
});
// Wrapped by service layer
const serviceError = UserServiceError.FetchProfile({
userId: "123", cause: dbError.error
});
// Wrapped by API handler
const apiError = ApiError.Internal({
endpoint: "/api/users/123", statusCode: 500, cause: serviceError.error
});
// The entire error chain is JSON-serializable!
console.log(JSON.stringify(apiError, null, 2));The serialized output shows the complete chain:
{
"name": "Internal",
"message": "Internal server error",
"endpoint": "/api/users/123",
"statusCode": 500,
"cause": {
"name": "FetchProfile",
"message": "Could not fetch user profile",
"userId": "123",
"cause": {
"name": "Connection",
"message": "Failed to connect to database",
"retries": 3,
"cause": {
"name": "SocketTimeout",
"message": "Socket timeout after 5000ms",
"host": "db.example.com",
"port": 5432,
"timeout": 5000
}
}
}
}This chaining pattern provides:
- Complete error trace: See exactly how an error bubbled up through your stack
- Layer-specific fields: Each layer adds its own debugging information as flat fields
- JSON-serializable: Unlike JavaScript stack traces, this survives any serialization
- Type-safe: Each error in the chain maintains full TypeScript typing
Define a set of possible errors for each domain in your application:
import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error';
// Define error factories for this domain
const FileError = defineErrors({
NotFound: ({ path }: { path: string }) => ({
message: `The file at path "${path}" was not found`,
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,
}),
});
// Union of all possible errors for this domain
type FileError = InferErrors<typeof FileError>;
// Or extract a single variant's type
type FileNotFoundError = InferError<typeof FileError.NotFound>;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.
import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error';
// Define errors with constructor functions
const AppError = defineErrors({
// Static error — no input, fixed message
Network: () => ({
message: 'Network request failed',
}),
// Structured error — typed input, computed message
FileNotFound: ({ path }: { path: string }) => ({
message: `File not found: ${path}`,
path,
}),
});
// 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 }>
// Extract types
type AppError = InferErrors<typeof AppError>;
type FileNotFoundError = InferError<typeof AppError.FileNotFound>;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.
const FileError = defineErrors({
// Constructor computes message and returns fields
Write: ({ path }: { path: string }) => ({
message: `Write failed for ${path}`,
path,
}),
});
FileError.Write({ path: '/etc/passwd' });
// → Err({ name: 'Write', message: 'Write failed for /etc/passwd', path: '/etc/passwd' })
// For errors where the caller provides the message directly:
const GenericError = defineErrors({
Generic: ({ message, path }: { message: string; path: string }) => ({
message,
path,
}),
});
GenericError.Generic({ message: 'Failed to process file', path: '/etc/passwd' });When you know what information is always needed:
const FileError = defineErrors({
Write: ({ path }: { path: string }) => ({
message: `Write failed for ${path}`,
path,
}),
});
// Fields are REQUIRED — TypeScript enforces it
FileError.Write({ path: '/etc/passwd' });
// FileError.Write(); // Type error! fields are requiredUse optional properties only for genuine enrichment — data that may not be available at the call site:
const ParseError = defineErrors({
// 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}` : ''}`,
file,
line,
}),
});
// file is always required — you always know what you're parsing
ParseError.Log({ file: 'app.ts' });
// line is optional enrichment — provide it when available
ParseError.Log({ file: 'app.ts', line: 42 });
// ParseError.Log({ wrong: true }); // Type error! wrong shapeFields must be JSON-serializable primitives, arrays, or nested objects. This ensures errors survive any serialization boundary.
// Valid JSON-serializable fields
FileError.Write({ path: '/etc/passwd' });
// Not allowed — Date, functions, class instances are not JSON-serializable
// FileError.Write({ createdAt: new Date(), handler: () => {} }); // Type error!Use InferError to get the TypeScript type for a specific variant, or InferErrors to get the union of all variants:
import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error';
const HttpError = defineErrors({
Request: ({ url, status }: { url: string; status: number }) => ({
message: `Request to ${url} failed with ${status}`,
url,
status,
}),
});
// Union of all variants (useful for function signatures)
type HttpError = InferErrors<typeof HttpError>;
// Single variant type
type RequestError = InferError<typeof HttpError.Request>;
// RequestError = Readonly<{ name: 'Request'; message: string; url: string; status: number }>import { defineErrors, type InferError, type InferErrors } from 'wellcrafted/error';
const AppError = defineErrors({
// Tier 1: Static — no fields, no args at call site
Network: () => ({
message: 'Network request failed',
}),
// Tier 2: Cause-wrapping
Parse: ({ cause }: { cause: unknown }) => ({
message: `Parse failed: ${extractErrorMessage(cause)}`,
cause,
}),
// Tier 3: Structured data
FileNotFound: ({ path }: { path: string }) => ({
message: `File not found: ${path}`,
path,
}),
// 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}` : ''}`,
file,
line,
}),
});
// Access variants via the namespace — each returns Err<...> directly
// AppError.Network()
// AppError.Parse({ cause: error })
// AppError.FileNotFound({ path: '...' })
// AppError.Log({ file: '...', line: 42 })
// Extract types
type AppError = InferErrors<typeof AppError>;
type FileNotFoundError = InferError<typeof AppError.FileNotFound>;The factory pattern works seamlessly with error mapping. Each variant returns Err<...> directly, making it a natural fit for the catch callback:
const ApiError = defineErrors({
Fetch: ({ endpoint }: { endpoint: string }) => ({
message: `Failed to fetch data from ${endpoint}`,
endpoint,
}),
});
const result = await tryAsync({
try: () => fetch('/api/data').then(r => r.json()),
catch: () => ApiError.Fetch({ endpoint: '/api/data' })
});Unlike JavaScript's Error class, TaggedErrors are plain objects that serialize perfectly:
const ValidationError = defineErrors({
Range: ({ field, value, min, max }: { field: string; value: number; min: number; max: number }) => ({
message: `${field} must be between ${min} and ${max}`,
field,
value,
min,
max,
}),
});
const error = ValidationError.Range({
field: "age", value: -5, min: 0, max: 150
});
// Perfect serialization
const serialized = JSON.stringify(error);
console.log(serialized);
// {"name":"Range","message":"age must be between 0 and 150","field":"age","value":-5,"min":0,"max":150}
// Perfect deserialization
const deserialized = JSON.parse(serialized);
// Still a valid TaggedError!This enables:
- API responses: Send structured errors to clients
- Logging: Store complete error information
- Worker communication: Pass errors between threads
- State persistence: Save errors in localStorage or databases
Always start with the function's input parameters:
const DbError = defineErrors({
Query: ({ query, params, timestamp, connectionPool }: { query: string; params: unknown[]; timestamp: string; connectionPool: string }) => ({
message: `Database query failed: ${query}`,
query,
params,
timestamp,
connectionPool,
}),
});
// 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
timestamp: new Date().toISOString(), // Additional context
connectionPool: 'primary' // Debugging info
});Avoid generic error types:
// Too generic
Readonly<{ name: "Error"; message: string }>
// Specific and actionable
Readonly<{ name: "UserNotFound"; message: string; userId: string }>
Readonly<{ name: "InvalidCredentials"; message: string; username: string }>
Readonly<{ name: "SessionExpired"; message: string; sessionId: string }>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.
// Avoid: sub-discriminant inside a variant
const NetworkError = defineErrors({
Request: ({ reason }: { reason: 'timeout' | 'refused' | 'dns' }) => ({
message: `Request failed: ${reason}`,
reason,
}),
});
// Prefer: each failure is its own variant
const NetworkError = defineErrors({
Timeout: ({ duration }: { duration: number }) => ({
message: `Request timed out after ${duration}ms`,
duration,
}),
ConnectionRefused: ({ host }: { host: string }) => ({
message: `Connection refused by ${host}`,
host,
}),
DnsFailure: ({ hostname }: { hostname: string }) => ({
message: `DNS lookup failed for ${hostname}`,
hostname,
}),
});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.
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.
// Avoid: if/switch inside the constructor signals multiple errors hiding in one variant
const FormError = defineErrors({
Validation: ({ field, value, receivedType }: { field?: string; value?: string; receivedType?: string }) => ({
message: (() => {
if (receivedType) return "Invalid form data";
if (field === 'email') return "Please enter a valid email address";
if (field === 'password') return "Password must be at least 8 characters";
if (field === 'confirmPassword') return "Passwords do not match";
return "Validation failed";
})(),
field,
value,
receivedType,
}),
});The problems are the same as string literal unions, just harder to spot:
- Dishonest optionals:
field,value, andreceivedTypeare 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
namealone — they'd need to inspectfieldorreceivedTypeto 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:
// Prefer: each validation failure is its own variant with exactly the fields it needs.
// Validation and submission are separate concerns, so they get separate namespaces.
const ValidationError = defineErrors({
InvalidEmail: ({ value }: { value: string }) => ({
message: "Please enter a valid email address",
value,
}),
WeakPassword: ({ value }: { value: string }) => ({
message: "Password must be at least 8 characters",
value,
}),
PasswordMismatch: () => ({
message: "Passwords do not match",
}),
InvalidFormData: ({ receivedType }: { receivedType: string }) => ({
message: "Invalid form data",
receivedType,
}),
});
type ValidationError = InferErrors<typeof ValidationError>;
const SubmissionError = defineErrors({
SubmissionFailed: ({ email }: { email: string }) => ({
message: `Failed to create account for ${email}`,
email,
}),
});
type SubmissionError = InferErrors<typeof SubmissionError>;
// TypeScript unions compose them at the function boundary:
function handleForm(data: unknown): Result<User, ValidationError | SubmissionError> { ... }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.
Make all possible errors visible:
type AuthError = InferErrors<typeof AuthError>;
function authenticateUser(
credentials: Credentials
): Result<User, AuthError> {
// Implementation makes it clear what can go wrong
}Transform errors where you can add context:
const UserServiceError = defineErrors({
FetchProfile: ({ userId, cause }: { userId: string; cause: DbError }) => ({
message: 'Failed to fetch user profile',
userId,
cause,
}),
});
async function getUserProfile(userId: string): Result<Profile, InferErrors<typeof UserServiceError>> {
const dbResult = await queryDatabase(`SELECT * FROM users WHERE id = ?`, [userId]);
if (dbResult.error) {
return UserServiceError.FetchProfile({
userId,
cause: dbResult.error
});
}
return Ok(transformToProfile(dbResult.data));
}Structure your errors to answer these questions:
- What went wrong? (message)
- Where did it happen? (name)
- What data caused it? (fields)
- What was the root cause? (cause field if chained)
const PaymentError = defineErrors({
Processing: ({ orderId, amount, cardLast4, cause }: { orderId: string; amount: number; cardLast4: string; cause: CardValidationError }) => ({
message: 'Payment processing failed',
orderId,
amount,
cardLast4,
cause,
}),
});
function processPayment(order: Order, card: Card): Result<Receipt, InferErrors<typeof PaymentError>> {
const validation = validateCard(card);
if (validation.error) {
return PaymentError.Processing({
orderId: order.id,
amount: order.total,
cardLast4: card.number.slice(-4),
cause: validation.error
});
}
// ... rest of implementation
}The most common and readable pattern:
async function createOrder(input: OrderInput): Promise<Result<Order, OrderError>> {
const userResult = await getUser(input.userId);
if (userResult.error) return userResult;
const validationResult = validateOrderInput(input);
if (validationResult.error) return validationResult;
const inventoryResult = await checkInventory(input.items);
if (inventoryResult.error) return inventoryResult;
// All checks passed, create the order
return Ok(await saveOrder({
user: userResult.data,
items: inventoryResult.data,
...validationResult.data
}));
}When you need to collect multiple errors:
const FormError = defineErrors({
// 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}"`,
field,
value,
}),
});
type FormError = InferErrors<typeof FormError>;
function validateForm(input: FormInput): Result<ValidatedForm, FormError[]> {
const validationErrors: FormError[] = [];
if (!input.email?.includes('@')) {
validationErrors.push(FormError.Validation({ field: 'email', value: input.email }).error!);
}
if (!input.password || input.password.length < 8) {
validationErrors.push(FormError.Validation({ field: 'password' }).error!);
}
if (validationErrors.length > 0) {
return Err(validationErrors);
}
return Ok(input as ValidatedForm);
}Implement retry logic with typed errors:
const NetworkError = defineErrors({
Request: ({ url, attempt, maxRetries }: { url: string; attempt: number; maxRetries: number }) => ({
message: `Request failed (attempt ${attempt}/${maxRetries})`,
url,
attempt,
maxRetries,
}),
});
type NetworkError = InferErrors<typeof NetworkError>;
async function fetchWithRetry<T>(
url: string,
maxRetries = 3
): Promise<Result<T, NetworkError>> {
let lastError: NetworkError | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await tryAsync<T, NetworkRequestError>({
try: () => fetch(url).then(r => r.json()),
catch: () => NetworkError.Request({ url, attempt, maxRetries })
});
if (result.data) return result;
lastError = result.error;
// Exponential backoff
if (attempt < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
return Err(lastError!);
}TaggedErrors are designed to work seamlessly with the Result type:
import { Result, Ok, Err, tryAsync } from "wellcrafted/result";
import { defineErrors, type InferError, type InferErrors } from "wellcrafted/error";
const UserError = defineErrors({
DatabaseFailure: ({ userId }: { userId: string }) => ({
message: `Database lookup failed for user ${userId}`,
userId,
}),
NotFound: ({ userId }: { userId: string }) => ({
message: `User with ID ${userId} not found`,
userId,
}),
});
type UserError = InferErrors<typeof UserError>;
async function getUser(id: string): Promise<Result<User, UserError>> {
const result = await tryAsync<User, InferError<typeof UserError.DatabaseFailure>>({
try: () => database.users.findById(id),
catch: () => UserError.DatabaseFailure({ userId: id })
});
if (result.error) return result;
if (!result.data) {
return UserError.NotFound({ userId: id });
}
return Ok(result.data);
}The TaggedError system transforms error handling from an afterthought to a first-class concern:
- Structured: Every error has a consistent shape with
nameandmessage - 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
- Composable: Errors can be chained via cause fields
By treating errors as data rather than control flow, you gain predictability, testability, and maintainability in your error handling.
Understand how TaggedErrors work with the Result discriminated union See TaggedErrors in production authentication, validation, and API code Learn error transformation patterns in service architecture How Rust's enum-based error handling inspired the defineErrors API Ready to see TaggedErrors in action? Explore our [real-world patterns](/patterns/real-world) or check out the [service layer guide](/patterns/service-layer).