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 match (from awaitly) or 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. Both forms dispatch on .type (string literals match themselves; class names match TaggedError instances).
The root match(result, { ok, NotFoundError, ... }) works on full Results. TaggedError.match(error, { ... }) works on a single error value.
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. The canonical discriminant is .type; ._tag is a deprecated alias with the same value during the migration window:
const error = new NotFoundError({ resource: 'User', id: '123' });
console.log(error instanceof TaggedError); // trueconsole.log(error instanceof NotFoundError); // trueconsole.log(error.type); // 'NotFoundError'console.log(error._tag); // 'NotFoundError' (deprecated alias)console.log(error.resource); // 'User'console.log(error.message); // 'NotFoundError'JSON boundaries
Section titled “JSON boundaries”JSON.stringify calls toJSON on a tagged error and keeps its type, message, and enumerable props. The parsed value is plain data, so code on the receiving side should validate it and match on type. See Errors Across the Wire for server actions, RPC, and cache examples.
Slug spine fields
Section titled “Slug spine fields”When you pass slug and hint to the TaggedError factory, instances carry three readonly fields populated automatically, code, hint, and docsUrl, alongside type, message, and the deprecated _tag alias. These fields participate in the slug spine so the same canonical identifier appears on the runtime error, the lint rule that would catch a related mistake statically, the awaitly-analyze --doctor diagnostic, and the docs page.
class CheckoutFailed extends TaggedError('CheckoutFailed', { slug: 'runtime-saga-compensation', hint: 'Compensation already ran. Surface to the user; do not retry.', message: (p: { orderId: string }) => `Checkout ${p.orderId} could not be compensated`,}) {}
const e = new CheckoutFailed({ orderId: 'o-1' });e.code; // 'runtime-saga-compensation' (typed as AwaitlySlug)e.hint; // 'Compensation already ran. Surface to the user; do not retry.'e.docsUrl; // 'https://jagreehal.github.io/awaitly/rules/#runtime-saga-compensation'slug and hint are optional. If you set slug, hint is required: the factory throws TypeError at class-definition time if hint is missing or empty.
The six awaitly-system errors (TimeoutError, RetryExhaustedError, RateLimitError, CircuitBreakerOpenError, CompensationError, UnexpectedError) all participate in the spine. User-domain conveniences (ValidationError, NotFoundError, UnauthorizedError, NetworkError) deliberately do not. They represent your domain failures, not awaitly internals.
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 |
|---|---|
type |
Canonical discriminant for pattern matching |
_tag |
Deprecated alias of type (migration window) |
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;}> {}