Error Handling in TypeScript
Error handling is a critical aspect of building robust applications. TypeScript provides powerful tools for handling errors in a type-safe manner, going beyond JavaScript's traditional try-catch mechanisms. In this lesson, we'll explore typed errors, the Result type pattern, custom error classes, and error boundaries for creating resilient applications.
Understanding Typed Errors
In JavaScript, the catch clause catches values of type any. TypeScript 4.4+ introduced the unknown type for catch clause variables, making error handling safer:
// Traditional approach (unsafe)
try {
riskyOperation();
} catch (error) {
// error is 'any' by default
console.log(error.message);
}
// Modern TypeScript (safe)
try {
riskyOperation();
} catch (error: unknown) {
if (error instanceof Error) {
console.log(error.message);
} else {
console.log('Unknown error:', error);
}
}
// With type guard helper
function isError(error: unknown): error is Error {
return error instanceof Error;
}
try {
riskyOperation();
} catch (error) {
if (isError(error)) {
console.log(error.message);
console.log(error.stack);
}
}
Note: Always use unknown instead of any for catch clause variables. This forces you to check the error type before using it, preventing runtime errors.
Custom Error Classes
Creating custom error classes allows you to define specific error types with additional context and type-safe handling:
// Base application error
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
class ValidationError extends AppError {
constructor(
message: string,
public readonly fields: Record<string, string[]>
) {
super(message, 'VALIDATION_ERROR', 400);
}
}
class NotFoundError extends AppError {
constructor(
resource: string,
public readonly id: string | number
) {
super(\`${resource} not found\`, 'NOT_FOUND', 404);
}
}
class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized access') {
super(message, 'UNAUTHORIZED', 401);
}
}
// Usage with type guards
function handleError(error: unknown): void {
if (error instanceof ValidationError) {
console.log('Validation failed:', error.fields);
} else if (error instanceof NotFoundError) {
console.log(\`Resource ${error.id} not found\`);
} else if (error instanceof UnauthorizedError) {
console.log('Access denied');
} else if (error instanceof AppError) {
console.log(\`Error ${error.code}:\`, error.message);
} else {
console.log('Unknown error');
}
}
// Example usage
function validateUser(data: unknown): void {
throw new ValidationError('Invalid user data', {
email: ['Email is required', 'Email format is invalid'],
password: ['Password must be at least 8 characters']
});
}
function getUser(id: number): void {
throw new NotFoundError('User', id);
}
Tip: Always call Error.captureStackTrace in custom error constructors to maintain accurate stack traces for debugging.
The Result Type Pattern
The Result type pattern is an alternative to throwing errors, providing explicit success/failure handling at compile time:
// Result type definition
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
// Helper functions
function ok<T>(value: T): Result<T, never> {
return { success: true, value };
}
function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
// Usage example
function parseJSON<T>(json: string): Result<T, Error> {
try {
const value = JSON.parse(json) as T;
return ok(value);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
// Pattern matching with Result
function processResult<T>(result: Result<T>): void {
if (result.success) {
console.log('Success:', result.value);
} else {
console.log('Error:', result.error.message);
}
}
// Real-world example
interface User {
id: number;
name: string;
email: string;
}
function fetchUser(id: number): Result<User, NotFoundError> {
const user = database.find(id);
if (user) {
return ok(user);
}
return err(new NotFoundError('User', id));
}
// Chaining Results
function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.success) {
return ok(fn(result.value));
}
return result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.success) {
return fn(result.value);
}
return result;
}
// Usage
const userResult = fetchUser(123);
const emailResult = map(userResult, user => user.email);
const upperEmailResult = map(emailResult, email => email.toUpperCase());
Note: The Result pattern makes error handling explicit in function signatures, forcing callers to handle both success and failure cases.
Option Type for Nullable Values
The Option type is similar to Result but specifically handles the presence or absence of a value:
// Option type definition
type Option<T> = Some<T> | None;
interface Some<T> {
readonly kind: 'some';
readonly value: T;
}
interface None {
readonly kind: 'none';
}
// Constructors
function some<T>(value: T): Option<T> {
return { kind: 'some', value };
}
function none(): Option<never> {
return { kind: 'none' };
}
// Helper functions
function isSome<T>(option: Option<T>): option is Some<T> {
return option.kind === 'some';
}
function isNone<T>(option: Option<T>): option is None {
return option.kind === 'none';
}
function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return isSome(option) ? option.value : defaultValue;
}
function mapOption<T, U>(
option: Option<T>,
fn: (value: T) => U
): Option<U> {
return isSome(option) ? some(fn(option.value)) : none();
}
// Usage example
function findUserById(id: number): Option<User> {
const user = database.find(id);
return user ? some(user) : none();
}
const userOption = findUserById(123);
const userName = getOrElse(
mapOption(userOption, user => user.name),
'Anonymous'
);
Error Boundaries Pattern
For React applications with TypeScript, error boundaries provide a way to catch errors in component trees:
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('Error caught by boundary:', error);
console.error('Component stack:', errorInfo.componentStack);
this.props.onError?.(error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
</div>
);
}
return this.props.children;
}
}
// Usage
function App(): JSX.Element {
return (
<ErrorBoundary
fallback={<ErrorFallback />}
onError={(error, errorInfo) => {
logErrorToService(error, errorInfo);
}}
>
<MainContent />
</ErrorBoundary>
);
}
Discriminated Union Errors
Use discriminated unions to create type-safe error hierarchies without classes:
// Error types as discriminated union
type ApiError =
| { type: 'network'; message: string; retryable: true }
| { type: 'validation'; fields: Record<string, string[]> }
| { type: 'unauthorized'; requiredRole: string }
| { type: 'notFound'; resource: string; id: string }
| { type: 'server'; statusCode: number; message: string };
// Error handlers with exhaustive checking
function handleApiError(error: ApiError): string {
switch (error.type) {
case 'network':
return \`Network error: ${error.message}. Retrying...\`;
case 'validation':
return \`Validation failed: ${Object.keys(error.fields).join(', ')}\`;
case 'unauthorized':
return \`Access denied. Required role: ${error.requiredRole}\`;
case 'notFound':
return \`${error.resource} with id ${error.id} not found\`;
case 'server':
return \`Server error (${error.statusCode}): ${error.message}\`;
default:
// TypeScript ensures this is never reached
const _exhaustive: never = error;
return _exhaustive;
}
}
// Factory functions for creating errors
const ApiErrors = {
network: (message: string): ApiError => ({
type: 'network',
message,
retryable: true
}),
validation: (fields: Record<string, string[]>): ApiError => ({
type: 'validation',
fields
}),
unauthorized: (requiredRole: string): ApiError => ({
type: 'unauthorized',
requiredRole
}),
notFound: (resource: string, id: string): ApiError => ({
type: 'notFound',
resource,
id
}),
server: (statusCode: number, message: string): ApiError => ({
type: 'server',
statusCode,
message
})
};
// Usage
const error = ApiErrors.validation({
email: ['Invalid format'],
password: ['Too short']
});
console.log(handleApiError(error));
Warning: Never use any for error types. Always use unknown and type guards to ensure type safety in error handling.
Async Error Handling
Combining Result types with async functions provides robust error handling:
// Async Result type
type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
// Helper to wrap async operations
async function tryCatch<T>(
fn: () => Promise<T>
): AsyncResult<T> {
try {
const value = await fn();
return ok(value);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
// Usage example
async function fetchUserSafely(id: number): AsyncResult<User, ApiError> {
const result = await tryCatch(async () => {
const response = await fetch(\`/api/users/${id}\`);
if (!response.ok) {
throw ApiErrors.server(response.status, response.statusText);
}
return response.json();
});
if (!result.success) {
if (result.error.message.includes('network')) {
return err(ApiErrors.network(result.error.message));
}
return err(ApiErrors.server(500, result.error.message));
}
return ok(result.value);
}
// Chaining async operations
async function getUserEmailSafely(id: number): AsyncResult<string, ApiError> {
const userResult = await fetchUserSafely(id);
if (!userResult.success) {
return userResult;
}
return ok(userResult.value.email);
}
Exercise:
- Create a custom error hierarchy for a blog application with errors for: PostNotFound, UnauthorizedEdit, InvalidContent, and PublishError.
- Implement a Result-based API client that handles network errors, validation errors, and server errors.
- Create an Option-based cache system with
get, set, and delete methods.
- Build a type-safe error logger that categorizes errors by severity (info, warning, error, critical) using discriminated unions.
- Implement an ErrorBoundary component with TypeScript that logs errors to an external service and shows different fallback UIs based on error type.
Summary
TypeScript error handling goes far beyond try-catch blocks. By leveraging typed errors, custom error classes, the Result and Option patterns, and discriminated unions, you can build applications with explicit, type-safe error handling. These patterns force you to handle errors at compile time, reducing runtime surprises and making your code more maintainable and robust.