Tagged Errors
String literal errors like 'NOT_FOUND' work for simple cases. When you need errors with contextual data, use TaggedError.
This guide progresses through: deciding when to use TaggedError → creating them → pattern matching → advanced usage.
Decision tree: When to use what
Section titled “Decision tree: When to use what”Do you need data attached to the error?├── No → Use string literals: 'NOT_FOUND' | 'UNAUTHORIZED'└── Yes → Do you have 3+ error variants to handle? ├── No → Object literal: { type: 'NOT_FOUND', id: string } └── Yes → TaggedError with match()| Use case | Recommendation |
|---|---|
| Simple distinct states | String literals: 'NOT_FOUND' | 'UNAUTHORIZED' |
| Errors with context | TaggedError: NotFoundError { id, resource } |
| Multiple variants to handle | TaggedError with match() |
| API responses | TaggedError for structured data |
When to migrate from string literals
Section titled “When to migrate from string literals”Start with string literals. They’re simpler and often sufficient:
// Good for simple casesconst fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND' | 'FORBIDDEN'> => { // ...};Migrate to TaggedError when:
- You need error context for debugging:
// ❌ Before: No context, hard to debugreturn err('NOT_FOUND');
// ✅ After: Rich contextreturn err(new NotFoundError({ resource: 'User', id, searchedAt: 'users_table' }));- You’re handling 3+ error types with different logic:
// ❌ Before: Verbose switch/caseif (error === 'NOT_FOUND') { ... }else if (error === 'FORBIDDEN') { ... }else if (error === 'RATE_LIMITED') { ... }else if (error === 'VALIDATION_FAILED') { ... }
// ✅ After: Exhaustive, type-safe matchTaggedError.match(error, { NotFoundError: (e) => { ... }, ForbiddenError: (e) => { ... }, RateLimitedError: (e) => { ... }, ValidationError: (e) => { ... },});- You want TypeScript to catch missing error handlers:
// With TaggedError.match(), forgetting a handler is a compile errorTaggedError.match(error, { NotFoundError: (e) => { ... }, // ForbiddenError: ... // TypeScript error: Missing handler!});Migration example
Section titled “Migration example”Before: String literals
type UserError = 'NOT_FOUND' | 'FORBIDDEN' | 'VALIDATION_FAILED';
const fetchUser = async (id: string): AsyncResult<User, UserError> => { if (!session.valid) return err('FORBIDDEN'); const user = await db.users.find(id); if (!user) return err('NOT_FOUND'); return ok(user);};
// Handlingif (!result.ok) { if (result.error === 'NOT_FOUND') { return res.status(404).json({ error: 'User not found' }); } // No context about WHICH user wasn't found}After: TaggedError
class UserNotFoundError extends TaggedError('UserNotFoundError')<{ userId: string;}> {}
class UserForbiddenError extends TaggedError('UserForbiddenError')<{ userId: string; reason: 'session_expired' | 'insufficient_permissions';}> {}
class UserValidationError extends TaggedError('UserValidationError')<{ field: string; message: string;}> {}
type UserError = UserNotFoundError | UserForbiddenError | UserValidationError;
const fetchUser = async (id: string): AsyncResult<User, UserError> => { if (!session.valid) { return err(new UserForbiddenError({ userId: id, reason: 'session_expired' })); } const user = await db.users.find(id); if (!user) { return err(new UserNotFoundError({ userId: id })); } return ok(user);};
// Handling - exhaustive and with contextif (!result.ok) { const response = TaggedError.match(result.error, { UserNotFoundError: (e) => ({ status: 404, body: { error: 'not_found', userId: e.userId }, }), UserForbiddenError: (e) => ({ status: 403, body: { error: 'forbidden', reason: e.reason }, }), UserValidationError: (e) => ({ status: 400, body: { error: 'validation', field: e.field, message: e.message }, }), }); return res.status(response.status).json(response.body);}Creating tagged errors
Section titled “Creating tagged errors”WHAT: Define error classes that extend TaggedError with typed properties.
WHY: Each error type becomes a distinct class with typed data, enabling pattern matching and rich debugging context.
import { TaggedError } from 'awaitly';
// Pattern 1: Props via genericclass NotFoundError extends TaggedError('NotFoundError')<{ resource: string; id: string;}> {}
// Pattern 2: Custom messageclass ValidationError extends TaggedError('ValidationError', { message: (p: { field: string; reason: string }) => `Validation failed for ${p.field}: ${p.reason}`,}) {}
// Pattern 3: No propsclass UnauthorizedError extends TaggedError('UnauthorizedError') {}Using tagged errors
Section titled “Using tagged errors”const fetchUser = async (id: string): AsyncResult<User, NotFoundError | UnauthorizedError> => { if (!session.isValid) { return err(new UnauthorizedError()); } const user = await db.users.find(id); if (!user) { return err(new NotFoundError({ resource: 'User', id })); } return ok(user);};Pattern matching with match()
Section titled “Pattern matching with match()”WHAT: Use TaggedError.match to handle each error variant with exhaustive type checking.
WHY: TypeScript ensures you handle every error type - forget one and you get a compile error.
TaggedError.match forces exhaustive handling:
const workflow = createWorkflow('workflow', { fetchUser, updateProfile });
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('123')); return await step('updateProfile', () => deps.updateProfile(user.id, data));});
if (!result.ok) { const response = TaggedError.match(result.error, { NotFoundError: (e) => ({ status: 404, body: { error: 'not_found', resource: e.resource, id: e.id }, }), UnauthorizedError: () => ({ status: 401, body: { error: 'unauthorized' }, }), ValidationError: (e) => ({ status: 400, body: { error: 'validation', field: e.field, reason: e.reason }, }), });
return res.status(response.status).json(response.body);}Add a new error type? TypeScript errors until you handle it.
Partial matching
Section titled “Partial matching”Handle specific errors with a fallback:
const message = TaggedError.matchPartial( result.error, { RateLimitError: (e) => `Please wait ${e.retryAfter} seconds`, }, (e) => `Something went wrong: ${e.message}` // Fallback);Type helpers
Section titled “Type helpers”Extract type information from tagged errors for reuse:
import { type TagOf, type ErrorByTag } from 'awaitly';
type AllErrors = NotFoundError | ValidationError | RateLimitError;
// Extract tag literalstype Tags = TagOf<AllErrors>;// 'NotFoundError' | 'ValidationError' | 'RateLimitError'
// Extract specific varianttype NotFound = ErrorByTag<AllErrors, 'NotFoundError'>;// NotFoundErrorRuntime checks
Section titled “Runtime checks”Tagged errors support instanceof:
const error = new NotFoundError({ resource: 'User', id: '123' });
console.log(error instanceof TaggedError); // trueconsole.log(error instanceof NotFoundError); // trueconsole.log(error._tag); // 'NotFoundError'console.log(error.resource); // 'User'console.log(error.message); // 'NotFoundError'Error chaining
Section titled “Error chaining”Link to the original error via ErrorOptions.cause:
try { await fetch('/api');} catch (original) { throw new NetworkError( { url: '/api', statusCode: 500 }, { cause: original } // Chain to original error );}Real-world example
Section titled “Real-world example”Here’s a complete example showing TaggedError in a payment workflow:
// Define error typesclass PaymentDeclinedError extends TaggedError('PaymentDeclinedError', { message: (p: { reason: 'insufficient_funds' | 'card_expired' | 'fraud' }) => `Payment declined: ${p.reason}`,}) {}
class PaymentProviderError extends TaggedError('PaymentProviderError', { message: (p: { provider: string; statusCode: number }) => `${p.provider} returned ${p.statusCode}`,}) {}
// Use in workflowconst processPayment = async ( amount: number): AsyncResult<Receipt, PaymentDeclinedError | PaymentProviderError> => { const response = await paymentProvider.charge(amount);
if (response.declined) { return err(new PaymentDeclinedError({ reason: response.declineReason })); } if (!response.ok) { return err(new PaymentProviderError({ provider: 'Stripe', statusCode: response.status, })); } return ok(response.receipt);};
// Handle errorsif (!result.ok) { TaggedError.match(result.error, { PaymentDeclinedError: (e) => { switch (e.reason) { case 'insufficient_funds': notifyUser('Please use a different card'); break; case 'card_expired': notifyUser('Your card has expired'); break; case 'fraud': alertFraudTeam(e); break; } }, PaymentProviderError: (e) => { logToDatadog({ provider: e.provider, status: e.statusCode }); retryWithBackup(e.provider); }, });}Reserved keys
Section titled “Reserved keys”These property names are reserved and cannot be used in props:
| Key | Reason |
|---|---|
_tag | Discriminant for pattern matching |
name | Error.name (stack traces) |
message | Error.message (logs) |
stack | Error.stack (stack trace) |
// Don't do thisclass BadExample extends TaggedError('BadExample')<{ message: string; // Won't work - use 'details' instead}> {}
// Do thisclass GoodExample extends TaggedError('GoodExample')<{ details: string;}> {}