TypeScript Error Handling — Complete Pattern Guide
In this tutorial, you will learn about TypeScript Error Handling. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript error handling goes beyond try-catch with typed error classes, discriminated union return types, and the Result pattern — making error handling as type-safe as the rest of your application.
What You'll Learn
- Typed custom error classes
- Error-handling with discriminated unions
- The Result pattern (Either monad)
- Async error handling
- Error boundaries in React
- Logging and error reporting
Why It Matters
Untyped error handling — throwing and catching any — creates silent failures. A function might return null, throw "not found" as a string, or reject with an Error that has no statusCode property. TypeScript forces you to handle every case explicitly.
Real-World Use
The Durga Antivirus Pro license server processes millions of API requests daily with typed error handling. Every API endpoint returns a Result<T, AppError> type, ensuring that validation failures, database errors, and permission issues are handled explicitly — no unhandled errors reach the client.
Learning Path
flowchart LR A[SOLID Principles] --> B[Error Handling] B --> C[Async Patterns] B --> D[You Are Here] C --> E[Pattern Matching] D --> F[Performance]
Typed Custom Error Classes
Instead of throwing plain strings or generic Error, create typed error classes:
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super('NOT_FOUND', 404, `${resource} with id ${id} not found`);
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super('VALIDATION_ERROR', 400, message, details);
this.name = 'ValidationError';
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super('UNAUTHORIZED', 401, message);
this.name = 'UnauthorizedError';
}
}
// Usage — instanceof checks are type-safe
function handleError(error: unknown): never {
if (error instanceof NotFoundError) {
console.error(`Resource not found: ${error.message}`);
} else if (error instanceof ValidationError) {
console.error(`Validation failed:`, error.details);
} else if (error instanceof AppError) {
console.error(`App error ${error.code}: ${error.message}`);
} else {
console.error('Unknown error:', error);
}
throw error;
}
Why typed classes matter: The instanceof check narrows the error type, giving you access to .code, .statusCode, and .details without casting.
The Result Pattern
Instead of throwing exceptions, return success or failure as a discriminated union. This makes error handling explicit in the function's return type:
type Result<T, E = AppError> =
| { success: true; data: T }
| { success: false; error: E };
// Usage
async function getUserById(id: string): Promise<Result<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return {
success: false,
error: new NotFoundError('User', id),
};
}
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: new AppError('DATABASE_ERROR', 500, 'Failed to fetch user'),
};
}
}
// Caller MUST handle both cases
async function handleRequest(userId: string): Promise<void> {
const result = await getUserById(userId);
if (!result.success) {
// TypeScript narrows to the error branch
console.error(`Error ${result.error.code}: ${result.error.message}`);
return;
}
// TypeScript narrows to the success branch — data is typed as User
console.log(`Found user: ${result.data.name}`);
}
The compiler forces you to check result.success before accessing result.data — no more accidental undefined access.
Typed Try-Catch with Type Guards
TypeScript 4.0+ lets you type the catch variable using unknown. Combine with type guards for safe error handling:
function isErrorWithMessage(error: unknown): error is { message: string } {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
async function safeExecute<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (error) {
if (isAppError(error)) {
return { success: false, error };
}
if (isErrorWithMessage(error)) {
return {
success: false,
error: new AppError('UNKNOWN', 500, error.message),
};
}
return {
success: false,
error: new AppError('UNKNOWN', 500, 'An unknown error occurred'),
};
}
}
Async Error Handling Patterns
For async operations, the Result pattern shines because try-catch can't distinguish error types at compile time:
interface ApiError {
status: number;
message: string;
}
async function fetchData<T>(url: string): Promise<Result<T, ApiError>> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
success: false,
error: {
status: response.status,
message: `HTTP ${response.status}: ${response.statusText}`,
},
};
}
const data = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
status: 0,
message: error instanceof Error ? error.message : 'Network error',
},
};
}
}
// Composing multiple async operations
async function loadDashboardData(userId: string) {
const [userResult, postsResult, notificationsResult] = await Promise.all([
fetchData<User>(`/api/users/${userId}`),
fetchData<Post[]>(`/api/users/${userId}/posts`),
fetchData<Notification[]>(`/api/users/${userId}/notifications`),
]);
// Collect all errors
const errors = [
...(!userResult.success ? [userResult.error] : []),
...(!postsResult.success ? [postsResult.error] : []),
...(!notificationsResult.success ? [notificationsResult.error] : []),
];
if (errors.length > 0) {
return { success: false, errors } as const;
}
return {
success: true,
data: {
user: userResult.data,
posts: postsResult.data,
notifications: notificationsResult.data,
},
} as const;
}
Error Handling in Express with TypeScript
Typed error middleware for Express applications:
import { Request, Response, NextFunction } from 'express';
import { AppError } from './errors';
function errorMiddleware(
error: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({
error: {
code: error.code,
message: error.message,
...(error.details && { details: error.details }),
},
});
return;
}
// Log unexpected errors
console.error('Unhandled error:', error);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
// Wrapper for async route handlers
function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) {
return (req: Request, res: Response, next: NextFunction): void => {
fn(req, res, next).catch(next);
};
}
// Usage in routes
router.get(
'/users/:id',
asyncHandler(async (req: Request, res: Response) => {
const user = await getUserById(req.params.id);
if (!user) {
throw new NotFoundError('User', req.params.id);
}
res.json(user);
})
);
Error Handling in React with TypeScript
Error Boundaries in React with TypeScript:
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('Error caught by boundary:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? <h1>Something went wrong</h1>;
}
return this.props.children;
}
}
Common Mistakes
1. Throwing non-Error values
Throwing strings, numbers, or plain objects loses stack traces and makes instanceof checks impossible. Always throw Error instances.
2. Catching with any instead of unknown
catch (error: any) disables Type Checking. Use catch (error: unknown) and narrow with type guards.
3. Not distinguishing error types
A single catch block handling all errors equally loses specific context. Use typed error classes with instanceof checks.
4. Ignoring async error propagation
Promise rejections that aren't caught become unhandled promise rejections. Always add .catch() or use asyncHandler wrappers.
5. Swallowing errors silently
An empty catch {} hides bugs. At minimum, log errors. Better: return a typed result.
6. Exposing internal error details to clients
Stack traces and database error messages leak implementation details. Always sanitize errors before sending to clients.
7. Not using discriminated union return types
Functions that can fail should encode failure in their return type, not surprise callers with exceptions. The Result pattern makes this explicit.
Practice Questions
What's the advantage of
unknownoveranyin catch clauses?unknownforces you to narrow the type before using it.anydisables type checking, allowing unsafe access to error properties.How does the Result pattern differ from exceptions? Result makes error handling explicit in the return type. Exceptions are invisible in function signatures and can be accidentally unhandled.
What is an Error Boundary in React? A React component that catches JavaScript errors in its child component tree, logs them, and displays a fallback UI instead of crashing.
Why create custom error classes instead of using
Errordirectly? Custom classes enableinstanceofdifferentiation, carry additional context (statusCode, code, details), and self-document the error type.How do you handle errors in async Express routes? Use an
asyncHandlerwrapper that catches promise rejections and passes them to Express's error middleware vianext().
Challenge
Build a typed Result-based error handling system for a REST API client. Include typed error classes (NetworkError, TimeoutError, ValidationError, ServerError), automatic retry for transient errors, and a logger that records structured error data.
FAQ
Mini Project
Build an error handling system for a payment processing service:
- Error classes: InsufficientFundsError, CardDeclinedError, NetworkError, DuplicateTransactionError
- Result type:
PaymentResult<T>with success and error branches - Retry logic: Automatic retry with exponential backoff for transient errors
- Express middleware: Typed error handler that maps errors to HTTP responses
- Logger: Structured logging with error context
What's Next
You've mastered error handling with TypeScript. Now learn async patterns with {{< ref "52-async-await" >}}, or explore pattern matching with {{< ref "53-pattern-matching" >}}.
For performance optimization, see {{< ref "54-performance" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro