Back to Blog
TypeScriptError HandlingPatternsBest Practices

TypeScript Error Handling Patterns

Handle errors elegantly in TypeScript. From Result types to error boundaries to typed error handling.

B
Bootspring Team
Engineering
July 5, 2022
6 min read

Good error handling makes code robust and debuggable. Here's how to handle errors elegantly in TypeScript.

Custom Error Classes#

1// Base application error 2class AppError extends Error { 3 constructor( 4 message: string, 5 public code: string, 6 public statusCode: number = 500, 7 public isOperational: boolean = true 8 ) { 9 super(message); 10 this.name = this.constructor.name; 11 Error.captureStackTrace(this, this.constructor); 12 } 13} 14 15// Specific error types 16class ValidationError extends AppError { 17 constructor(message: string, public fields?: Record<string, string>) { 18 super(message, 'VALIDATION_ERROR', 400); 19 } 20} 21 22class NotFoundError extends AppError { 23 constructor(resource: string) { 24 super(`${resource} not found`, 'NOT_FOUND', 404); 25 } 26} 27 28class UnauthorizedError extends AppError { 29 constructor(message = 'Unauthorized') { 30 super(message, 'UNAUTHORIZED', 401); 31 } 32} 33 34// Usage 35function getUser(id: string): User { 36 const user = db.users.find(u => u.id === id); 37 if (!user) { 38 throw new NotFoundError('User'); 39 } 40 return user; 41} 42 43// Type guard for error handling 44function isAppError(error: unknown): error is AppError { 45 return error instanceof AppError; 46} 47 48try { 49 const user = getUser('123'); 50} catch (error) { 51 if (isAppError(error)) { 52 console.log(error.code, error.statusCode); 53 } 54}

Result Type Pattern#

1// Result type for explicit error handling 2type Result<T, E = Error> = 3 | { success: true; data: T } 4 | { success: false; error: E }; 5 6// Helper functions 7function ok<T>(data: T): Result<T, never> { 8 return { success: true, data }; 9} 10 11function err<E>(error: E): Result<never, E> { 12 return { success: false, error }; 13} 14 15// Usage 16interface UserError { 17 code: 'NOT_FOUND' | 'INVALID_EMAIL' | 'DUPLICATE'; 18 message: string; 19} 20 21async function createUser(data: CreateUserInput): Promise<Result<User, UserError>> { 22 // Validate email 23 if (!isValidEmail(data.email)) { 24 return err({ code: 'INVALID_EMAIL', message: 'Invalid email format' }); 25 } 26 27 // Check for duplicate 28 const existing = await db.user.findUnique({ where: { email: data.email } }); 29 if (existing) { 30 return err({ code: 'DUPLICATE', message: 'Email already exists' }); 31 } 32 33 const user = await db.user.create({ data }); 34 return ok(user); 35} 36 37// Caller handles explicitly 38async function handleCreateUser(data: CreateUserInput) { 39 const result = await createUser(data); 40 41 if (!result.success) { 42 switch (result.error.code) { 43 case 'INVALID_EMAIL': 44 return res.status(400).json({ error: result.error.message }); 45 case 'DUPLICATE': 46 return res.status(409).json({ error: result.error.message }); 47 default: 48 return res.status(500).json({ error: 'Unknown error' }); 49 } 50 } 51 52 return res.status(201).json(result.data); 53}

Option/Maybe Type#

1// Option type for nullable values 2type Option<T> = { some: true; value: T } | { some: false }; 3 4function some<T>(value: T): Option<T> { 5 return { some: true, value }; 6} 7 8function none<T>(): Option<T> { 9 return { some: false }; 10} 11 12// Helper methods 13const Option = { 14 map<T, U>(opt: Option<T>, fn: (value: T) => U): Option<U> { 15 return opt.some ? some(fn(opt.value)) : none(); 16 }, 17 18 flatMap<T, U>(opt: Option<T>, fn: (value: T) => Option<U>): Option<U> { 19 return opt.some ? fn(opt.value) : none(); 20 }, 21 22 getOrElse<T>(opt: Option<T>, defaultValue: T): T { 23 return opt.some ? opt.value : defaultValue; 24 }, 25 26 getOrThrow<T>(opt: Option<T>, error: Error): T { 27 if (!opt.some) throw error; 28 return opt.value; 29 }, 30}; 31 32// Usage 33function findUser(id: string): Option<User> { 34 const user = users.find(u => u.id === id); 35 return user ? some(user) : none(); 36} 37 38const user = findUser('123'); 39const name = Option.map(user, u => u.name); 40const displayName = Option.getOrElse(name, 'Anonymous');

Try-Catch Utilities#

1// Async try-catch wrapper 2async function tryCatch<T>( 3 promise: Promise<T> 4): Promise<[T, null] | [null, Error]> { 5 try { 6 const data = await promise; 7 return [data, null]; 8 } catch (error) { 9 return [null, error as Error]; 10 } 11} 12 13// Usage 14const [user, error] = await tryCatch(fetchUser(id)); 15 16if (error) { 17 console.error('Failed to fetch user:', error); 18 return; 19} 20 21console.log('User:', user.name); 22 23// Sync version 24function tryCatchSync<T>(fn: () => T): [T, null] | [null, Error] { 25 try { 26 return [fn(), null]; 27 } catch (error) { 28 return [null, error as Error]; 29 } 30} 31 32// With error transformation 33async function tryCatchMap<T, E>( 34 promise: Promise<T>, 35 mapError: (error: unknown) => E 36): Promise<[T, null] | [null, E]> { 37 try { 38 const data = await promise; 39 return [data, null]; 40 } catch (error) { 41 return [null, mapError(error)]; 42 } 43}

Error Boundary Pattern#

1// For React components 2import { Component, ReactNode } from 'react'; 3 4interface Props { 5 children: ReactNode; 6 fallback: ReactNode; 7 onError?: (error: Error, errorInfo: React.ErrorInfo) => void; 8} 9 10interface State { 11 hasError: boolean; 12 error: Error | null; 13} 14 15class ErrorBoundary extends Component<Props, State> { 16 state: State = { hasError: false, error: null }; 17 18 static getDerivedStateFromError(error: Error): State { 19 return { hasError: true, error }; 20 } 21 22 componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 23 this.props.onError?.(error, errorInfo); 24 25 // Log to error tracking service 26 logError(error, errorInfo); 27 } 28 29 render() { 30 if (this.state.hasError) { 31 return this.props.fallback; 32 } 33 34 return this.props.children; 35 } 36} 37 38// Usage 39function App() { 40 return ( 41 <ErrorBoundary 42 fallback={<ErrorPage />} 43 onError={(error) => console.error('Caught:', error)} 44 > 45 <MainContent /> 46 </ErrorBoundary> 47 ); 48}

Assertion Functions#

1// Type narrowing with assertions 2function assertDefined<T>( 3 value: T | undefined | null, 4 message?: string 5): asserts value is T { 6 if (value === undefined || value === null) { 7 throw new Error(message ?? 'Value is not defined'); 8 } 9} 10 11function assertNever(value: never): never { 12 throw new Error(`Unexpected value: ${value}`); 13} 14 15// Usage 16function processStatus(status: 'pending' | 'active' | 'completed') { 17 switch (status) { 18 case 'pending': 19 return 'Waiting...'; 20 case 'active': 21 return 'In progress'; 22 case 'completed': 23 return 'Done'; 24 default: 25 assertNever(status); // TypeScript error if case missing 26 } 27} 28 29// With type guards 30function assertIsUser(value: unknown): asserts value is User { 31 if (typeof value !== 'object' || value === null) { 32 throw new Error('Not an object'); 33 } 34 if (!('id' in value) || !('email' in value)) { 35 throw new Error('Missing required properties'); 36 } 37} 38 39const data: unknown = await fetchData(); 40assertIsUser(data); 41// data is now typed as User 42console.log(data.email);

Error Aggregation#

1// Collect multiple errors 2class AggregateError extends Error { 3 constructor(public errors: Error[], message?: string) { 4 super(message ?? `${errors.length} errors occurred`); 5 this.name = 'AggregateError'; 6 } 7} 8 9async function validateAll( 10 validators: Array<() => Promise<void>> 11): Promise<void> { 12 const results = await Promise.allSettled(validators.map(v => v())); 13 14 const errors = results 15 .filter((r): r is PromiseRejectedResult => r.status === 'rejected') 16 .map(r => r.reason); 17 18 if (errors.length > 0) { 19 throw new AggregateError(errors); 20 } 21} 22 23// Usage 24try { 25 await validateAll([ 26 () => validateEmail(email), 27 () => validatePassword(password), 28 () => checkDuplicate(email), 29 ]); 30} catch (error) { 31 if (error instanceof AggregateError) { 32 error.errors.forEach(e => console.log(e.message)); 33 } 34}

Best Practices#

Design: ✓ Use custom error classes ✓ Consider Result types for expected failures ✓ Make errors descriptive ✓ Include error codes Handling: ✓ Catch at appropriate levels ✓ Log with context ✓ Don't swallow errors silently ✓ Transform errors for different layers TypeScript: ✓ Use type guards ✓ Leverage discriminated unions ✓ Use assertion functions ✓ Handle unknown type properly

Conclusion#

TypeScript enables robust error handling patterns. Use custom error classes for operational errors, Result types for expected failures, and proper type guards for safe error handling. Choose patterns based on whether errors are expected or exceptional.

Share this article

Help spread the word about Bootspring