home / skills / yanko-belov / code-craft / exception-hierarchies
This skill helps you design precise exception hierarchies in TypeScript, enabling targeted catch blocks and safer error handling across layers.
npx playbooks add skill yanko-belov/code-craft --skill exception-hierarchiesReview the files below or copy the command above to add this skill to your agents.
---
name: exception-hierarchies
description: Use when creating custom exceptions. Use when error handling feels chaotic. Use when catch blocks are too broad or too specific.
---
# Exception Hierarchies
## Overview
**Design exception hierarchies that enable precise catching and meaningful handling.**
Random exception classes lead to catch-all blocks or missed errors. A well-designed hierarchy lets callers catch at the right abstraction level.
## When to Use
- Creating custom exception classes
- Designing error handling strategy
- Refactoring scattered try/catch blocks
- Wrapping third-party library errors
- Debugging "unexpected error" catch-alls
## The Iron Rule
```
NEVER catch base Exception except at application boundaries.
```
**No exceptions:**
- Not for "I'll handle all cases"
- Not for "it's simpler"
- Not for "I don't know what to expect"
- Not for "the library throws too many types"
**Specific exceptions enable specific handling. Generic catches hide bugs.**
## The Three-Layer Hierarchy
Design exceptions in three layers:
```
ApplicationError (base)
│
┌────────────────┼────────────────┐
│ │ │
DomainError InfrastructureError ExternalError
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │ │ │
ValidationError │ DatabaseError │ APIError │
BusinessRuleError│ CacheError │ TimeoutError│
NotFoundError │ FileIOError │ RateLimitError
```
### Layer 1: Root Exception
```typescript
// All application errors inherit from this
class ApplicationError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
context: this.context,
};
}
}
```
### Layer 2: Category Exceptions
```typescript
// Domain logic errors
class DomainError extends ApplicationError {
constructor(message: string, code: string, context?: Record<string, unknown>) {
super(message, `DOMAIN.${code}`, context);
}
}
// Infrastructure failures
class InfrastructureError extends ApplicationError {
constructor(message: string, code: string, context?: Record<string, unknown>) {
super(message, `INFRA.${code}`, context);
}
}
// External service failures
class ExternalError extends ApplicationError {
constructor(
message: string,
code: string,
public readonly service: string,
context?: Record<string, unknown>
) {
super(message, `EXTERNAL.${code}`, { ...context, service });
}
}
```
### Layer 3: Specific Exceptions
```typescript
// Domain exceptions
class ValidationError extends DomainError {
constructor(public readonly fields: Record<string, string>) {
super('Validation failed', 'VALIDATION', { fields });
}
}
class BusinessRuleError extends DomainError {
constructor(rule: string, message: string) {
super(message, 'BUSINESS_RULE', { rule });
}
}
class NotFoundError extends DomainError {
constructor(entity: string, id: string) {
super(`${entity} not found: ${id}`, 'NOT_FOUND', { entity, id });
}
}
// Infrastructure exceptions
class DatabaseError extends InfrastructureError {
constructor(operation: string, cause?: Error) {
super(`Database ${operation} failed`, 'DATABASE', {
operation,
cause: cause?.message
});
}
}
// External service exceptions
class PaymentGatewayError extends ExternalError {
constructor(message: string, public readonly gatewayCode: string) {
super(message, 'PAYMENT', 'PaymentGateway', { gatewayCode });
}
}
```
## Correct Catching Pattern
Catch at the right abstraction level:
```typescript
// ✅ CORRECT: Specific catching
async function createOrder(data: OrderData): Promise<Order> {
try {
return await orderService.create(data);
} catch (error) {
// Catch what you can handle specifically
if (error instanceof ValidationError) {
// Can show field-specific errors to user
throw error; // Re-throw for controller to format response
}
if (error instanceof BusinessRuleError) {
// Log business rule violation, maybe alert
logger.warn('Business rule prevented order', { rule: error.context?.rule });
throw error;
}
if (error instanceof PaymentGatewayError) {
// Retry logic, fallback gateway, etc.
return await retryWithFallbackGateway(data);
}
// Unknown error - don't swallow, let it propagate
throw error;
}
}
// ❌ WRONG: Catch-all that hides errors
async function createOrder(data: OrderData): Promise<Order | null> {
try {
return await orderService.create(data);
} catch (error) {
logger.error('Order failed', error); // Lost specificity
return null; // Caller doesn't know why
}
}
```
## Boundary Handling
Only catch broadly at application boundaries:
```typescript
// ✅ CORRECT: HTTP boundary translates to responses
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
// This is the ONLY place catch-all is acceptable
if (error instanceof ValidationError) {
return res.status(400).json({
error: 'Validation failed',
fields: error.fields,
});
}
if (error instanceof NotFoundError) {
return res.status(404).json({
error: error.message,
});
}
if (error instanceof BusinessRuleError) {
return res.status(422).json({
error: error.message,
code: error.code,
});
}
if (error instanceof ExternalError) {
// Log full context, return generic message
logger.error('External service error', error.toJSON());
return res.status(502).json({
error: 'External service unavailable',
retryAfter: 30,
});
}
if (error instanceof InfrastructureError) {
logger.error('Infrastructure error', error.toJSON());
return res.status(503).json({
error: 'Service temporarily unavailable',
});
}
// Truly unexpected - log everything
logger.error('Unhandled error', {
error: error.message,
stack: error.stack,
request: { method: req.method, path: req.path }
});
return res.status(500).json({
error: 'Internal server error',
});
});
```
## Wrapping Third-Party Errors
Never let raw library errors leak:
```typescript
// ✅ CORRECT: Wrap at the adapter boundary
class PostgresUserRepository implements UserRepository {
async findById(id: string): Promise<User> {
try {
const result = await this.client.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
if (!result.rows[0]) {
throw new NotFoundError('User', id);
}
return this.mapToUser(result.rows[0]);
} catch (error) {
if (error instanceof NotFoundError) throw error;
// Wrap postgres-specific error
if (error instanceof pg.DatabaseError) {
throw new DatabaseError('query', error);
}
throw error;
}
}
}
// ❌ WRONG: Let pg.DatabaseError leak to controllers
// ❌ WRONG: Catch Error and throw generic Error
```
## Pressure Resistance Protocol
### 1. "Just Catch Exception, It's Simpler"
**Pressure:** "Don't overcomplicate with hierarchy"
**Response:** Catching `Exception` hides bugs and prevents specific handling. You'll debug for hours when a specific catch would have told you immediately.
**Action:** Create hierarchy. Catch specifically. Worth the upfront time.
### 2. "The Library Throws 10 Different Exceptions"
**Pressure:** "I can't anticipate all of them"
**Response:** Wrap at the boundary. Your code shouldn't know about library internals.
**Action:** Create adapter that catches library exceptions, throws your domain exceptions.
### 3. "I Don't Know What Errors to Expect"
**Pressure:** "Let me catch all and log"
**Response:** Logging isn't handling. If you don't know what to expect, let it propagate. The boundary handler will catch it.
**Action:** Don't catch what you can't handle specifically. Add catches as you learn.
### 4. "Error Codes Are Enough"
**Pressure:** "We use error codes, not exception types"
**Response:** Codes require string comparison, are easy to typo, and don't provide type safety.
**Action:** Exception types for control flow. Codes for serialization/logging.
## Red Flags - STOP and Reconsider
If you notice ANY of these, refactor:
- `catch (Exception e)` in non-boundary code
- `catch (Error e) { throw new Error(e.message) }` (losing type)
- Same exception type for different failure modes
- Library-specific exceptions in business logic
- No base exception for the application
- Catch blocks that log and continue
- `instanceof` checks for exception codes, not types
**All of these mean: Redesign the exception hierarchy.**
## Exception Design Checklist
| Requirement | Check |
|-------------|-------|
| All app errors inherit from base class | ☐ |
| Category exceptions for domain/infra/external | ☐ |
| Specific exceptions carry relevant context | ☐ |
| Third-party errors wrapped at boundary | ☐ |
| HTTP codes mapped in error middleware | ☐ |
| No raw `Exception` catches outside middleware | ☐ |
| Exceptions are immutable (readonly fields) | ☐ |
| `toJSON()` for structured logging | ☐ |
## Common Rationalizations (All Invalid)
| Excuse | Reality |
|--------|---------|
| "Hierarchy is over-engineering" | Hierarchy enables handling. Catch-all hides bugs. |
| "I'll just catch Exception" | You'll lose why it failed and how to handle it. |
| "Error codes work fine" | Types are compile-checked. Codes are strings. |
| "Library errors are fine to throw" | Library coupling spreads. Wrap at boundaries. |
| "I'll add types when I need them" | By then catch-all is everywhere. Start with types. |
| "Logging in catch is handling" | Logging isn't handling. Bubble or handle specifically. |
## Quick Reference
| Scenario | Action |
|----------|--------|
| New project | Create ApplicationError base + category classes |
| Using third-party library | Wrap in adapter, throw your exceptions |
| Don't know what to catch | Don't catch - let boundary handle it |
| Multiple failure modes | One exception class per mode |
| Catch block just logs | Remove catch, let it propagate |
| Need error details | Add context to exception constructor |
## The Bottom Line
**Design exception hierarchies that enable precise catching.**
Catch specifically what you can handle. Let everything else propagate to boundaries. Wrap third-party exceptions at adapters. Never catch base Exception in business logic.
This skill helps you design and apply TypeScript exception hierarchies so errors are caught at the right abstraction level and handled meaningfully. It prescribes a three-layer model (Application -> Category -> Specific) and practical patterns for wrapping third-party errors and boundary handling. Use it to make error handling predictable, debuggable, and testable.
The skill defines a base ApplicationError with code and context, category subclasses (DomainError, InfrastructureError, ExternalError), and concrete exceptions (ValidationError, NotFoundError, DatabaseError, PaymentGatewayError). It inspects where catches occur and recommends refactoring to catch specific types or let errors propagate to the application boundary. It also shows how to wrap library errors at adapter boundaries and map exceptions to HTTP responses.
Why not just use error codes or strings?
Error types are compile-time checked and allow instanceof checks; codes are prone to typos and forget type safety. Use codes for serialization but rely on types for control flow.
What if a library throws many error classes?
Wrap them at the adapter boundary. Convert library errors into your InfrastructureError or ExternalError subclasses so the rest of the app stays decoupled.