TypeScript Error Handling
Good error handling is essential for building reliable TypeScript applications.
This page goes from the basic try/catch block to more advanced patterns: custom error classes, type guards, async errors and best practices.
Basic Error Handling
Code that can fail goes inside a try block. If something is thrown there, execution jumps to the catch block, where you receive the thrown value.
Note: in TypeScript 4.0 and later (with useUnknownInCatchVariables, which strict turns on) the catch variable is typed unknown. Always narrow it before you read properties like message.
Try/Catch Blocks
The foundation of error handling in TypeScript: divide throws on division by zero, and the catch block reports it.
divide(a: number, b: number): number that throws new Error("Division by zero") when b is 0. Call divide(10, 0) inside a try block that would print the result, and in catch print An error occurred: followed by the error message with console.error.
Output: TypeScript file and captured output
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
try {
const result = divide(10, 0);
console.log(result);
} catch (error) {
console.error('An error occurred:', error.message);
}
Custom Error Classes
You can extend the built-in Error class to create errors that carry extra information for your domain, such as the field that failed validation or a database error code.
Creating Custom Error Classes
ValidationError and DatabaseError extend Error, set their own name, and restore the prototype chain so instanceof works after compilation to older targets.
Error.
Define ValidationError (message plus optional public field) and DatabaseError (message plus public numeric code); both set this.name and restore the prototype chain with Object.setPrototypeOf. Add a validateUser(user: any) function that throws a ValidationError for a missing name (field name) or an email without @ (field email). Only declarations — nothing is printed.
Output: TypeScript file and captured output
class ValidationError extends Error {
constructor(message: string, public field?: string) {
super(message);
this.name = 'ValidationError';
// Restore prototype chain
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
class DatabaseError extends Error {
constructor(message: string, public code: number) {
super(message);
this.name = 'DatabaseError';
Object.setPrototypeOf(this, DatabaseError.prototype);
}
}
// Usage
function validateUser(user: any) {
if (!user.name) {
throw new ValidationError('Name is required', 'name');
}
if (!user.email.includes('@')) {
throw new ValidationError('Invalid email format', 'email');
}
}
Type Guards for Errors
Because a caught value can be anything, write type guards — functions returning error is SomeType — to check what you actually caught before using it.
For more complex cases you can also use an assertion function (asserts error is Error), shown in the snippet below: after the call, TypeScript treats the value as an Error.
Type Predicates for Error Handling
Two type guards let the catch block handle a ValidationError, any object with a string message, or a completely unknown value.
unknown caught error safely.
Reuse a ValidationError class (message and optional field) and a validateUser function that throws it when name is missing. Write isErrorWithMessage(error: unknown): error is { message: string } (object, not null, has a string message) and isValidationError(error: unknown): error is ValidationError using instanceof. Call validateUser({}) in a try block; in catch (error: unknown) print Validation error in <field>: <message> for validation errors, An error occurred: <message> for other errors with a message, or An unknown error occurred.
Output: TypeScript file and captured output
class ValidationError extends Error {
constructor(message: string, public field?: string) {
super(message);
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
function validateUser(user: any) {
if (!user.name) {
throw new ValidationError('Name is required', 'name');
}
if (!user.email.includes('@')) {
throw new ValidationError('Invalid email format', 'email');
}
}
// Type guards
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 isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
// Usage in catch block
try {
validateUser({});
} catch (error: unknown) {
if (isValidationError(error)) {
console.error(`Validation error in ${error.field}: ${error.message}`);
} else if (isErrorWithMessage(error)) {
console.error('An error occurred:', error.message);
} else {
console.error('An unknown error occurred');
}
}
Assertion function for errors
assertIsError throws if the value is not an Error; otherwise TypeScript narrows the caught value to Error for the rest of the block.
function assertIsError(error: unknown): asserts error is Error {
if (!(error instanceof Error)) {
throw new Error('Caught value is not an Error instance');
}
}
try {
// ...
} catch (error) {
assertIsError(error);
console.error((error as Error).message); // TypeScript now knows error is Error
}
Async Error Handling
With async/await, errors from awaited promises are caught by a normal try/catch. With plain promises, chain a .catch() handler instead.
Never leave a promise rejection unhandled. Either chain .catch(), or use void to show that ignoring the result is intentional while still catching errors.
Handling Async/Await Errors
Wrapping an await in try/catch lets you log and re-throw; a promise chain can recover with a fallback value in .catch(). The network call is replaced by a local mock so the example runs anywhere.
.catch().
Define User and Post interfaces and a local mockFetch(url) that returns a resolved fake response (a John Doe user for /api/users/1, one post titled Hello World for the /posts URL) so no network is used. Write fetchUser(userId): Promise<User> that awaits the response, throws HTTP error! status: <status> when not ok, logs Failed to fetch user: in its catch and re-throws; and fetchUserPosts(userId): Promise<Post[]> using .then() with the same check and a .catch() that logs Failed to fetch posts: and returns []. Call both and print the user name and the posts array.
Output: TypeScript file and captured output
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
}
// Local stand-in for fetch so the example runs without a network
function mockFetch(url: string): Promise<{ ok: boolean; status: number; json: () => Promise<any> }> {
if (url.endsWith('/posts')) {
return Promise.resolve({ ok: true, status: 200, json: async () => [{ id: 1, title: 'Hello World' }] });
}
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ id: 1, name: 'John Doe', email: 'john@example.com' })
});
}
// Using async/await with try/catch
async function fetchUser(userId: number): Promise<User> {
try {
const response = await mockFetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as User;
} catch (error) {
if (error instanceof Error) {
console.error('Failed to fetch user:', error.message);
}
throw error; // Re-throw to allow caller to handle
}
}
// Using Promise.catch() for error handling
function fetchUserPosts(userId: number): Promise<Post[]> {
return mockFetch(`/api/users/${userId}/posts`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.catch(error => {
console.error('Failed to fetch posts:', error);
return []; // Return empty array as fallback
});
}
// Usage
fetchUser(1).then(user => console.log('User:', user.name));
fetchUserPosts(1).then(posts => console.log('Posts:', posts));
Handling promise rejections
A promise without .catch() is a bug waiting to happen; the two good forms handle the rejection or explicitly ignore the result with void.
// Bad: Unhandled promise rejection
fetchData().then(data => console.log(data));
// Good: Handle both success and error cases
fetchData()
.then(data => console.log('Success:', data))
.catch(error => console.error('Error:', error));
// Or use void for intentionally ignored errors
void fetchData().catch(console.error);
Error Boundaries in React
In a React application, an error boundary is a class component that catches JavaScript errors thrown anywhere in its child component tree and shows a fallback UI instead of crashing the whole page.
React error boundary component
An ErrorBoundary class with typed props and state that records the error in getDerivedStateFromError, logs it in componentDidCatch, and renders a fallback.
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
public state: ErrorBoundaryState = {
hasError: false
};
public static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
// Log to error reporting service
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<ErrorBoundary fallback={<div>Oops! Something broke.</div>}>
<MyComponent />
</ErrorBoundary>
);
}
Best Practices
Always handle errors. Never leave a catch block empty — at the very least, log the error.
Use specific error types. Create a custom error class for each kind of failure, so callers can tell them apart.
Handle errors at the right level. Catch an error where you have enough context to recover or to show a helpful message to the user — often not in the same function that throws it.
Never leave catch empty
A silent catch hides failures; logging the error is the minimum.
// Bad: Silent failure
try { /* ... */ } catch { /* empty */ }
// Good: At least log the error
try { /* ... */ } catch (error) {
console.error('Operation failed:', error);
}
Specific error classes
NetworkError carries an HTTP status and ValidationError the failing field name.
class NetworkError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'NetworkError';
}
}
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = 'ValidationError';
}
}
Handling errors at the right level
The data layer throws a NetworkError; the UI layer catches it and decides which message to show.
// In a data access layer
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new NetworkError(response.status, 'Failed to fetch user');
}
return response.json();
}
// In a UI component
async function loadUser() {
try {
const user = await getUser('123');
setUser(user);
} catch (error) {
if (error instanceof NetworkError) {
if (error.status === 404) {
showError('User not found');
} else {
showError('Network error. Please try again later.');
}
} else {
showError('An unexpected error occurred');
}
}
}
Common Pitfalls
Not handling promise rejections. Always attach a handler to promises, or you get unhandled rejection warnings and lost errors.
Catching without narrowing. In TypeScript 4.0+ the caught value is unknown; reading error.message without a check is a compile error.
Swallowing errors. Do not catch and ignore. Log the error and, where it makes sense, tell the user.
Summary — effective error handling in TypeScript means:
- Using
try/catchfor synchronous code - Handling promise rejections with
.catch(), ortry/catchwithasync/await - Creating custom error classes for domain-specific errors
- Using type guards to work safely with error objects
- Handling errors at the right level of the application
- Giving users meaningful error messages
Unhandled promise rejection
Calling an async function and dropping the promise versus attaching a .catch().
// Bad: Unhandled promise rejection
fetchData();
// Good: Handle the rejection
fetchData().catch(console.error);
Catching without type narrowing
Reading message from an unknown error fails; an instanceof Error check makes it safe.
// Bad: Error is of type 'unknown'
try { /* ... */ } catch (error) {
console.log(error.message); // Error: Property 'message' does not exist on type 'unknown'
}
// Good: Narrow the type
try { /* ... */ } catch (error) {
if (error instanceof Error) {
console.log(error.message); // OK
}
}
Swallowing errors
The first saveData hides failures; the second logs them and notifies the user.
// Bad: Error is silently ignored
function saveData(data: Data) {
try {
database.save(data);
} catch {
// Ignore
}
}
// Better: Log the error and/or notify the user
function saveData(data: Data) {
try {
database.save(data);
} catch (error) {
console.error('Failed to save data:', error);
showError('Failed to save data. Please try again.');
}
}