Skip to main content

How to use a try-catch block in TypeScript

You're shipping a feature. Your tests pass. Then production breaks because an API returned null instead of user data, and your app tried to read undefined.email. TypeScript didn't catch it because the error happened at runtime, not compile time.

This is where try-catch blocks become essential. They let you intercept errors before they crash your application and respond gracefully. In this guide, we'll cover practical patterns for error handling in TypeScript—from basic syntax to advanced techniques like custom error classes, utility functions, and knowing when try-catch isn't the right tool.

Using Try-Catch Blocks in TypeScript

Try-catch blocks in TypeScript enhance JavaScript's error handling with TypeScript's type system. You wrap risky code in a try block, and if anything throws an error, execution jumps to the catch block.

try {
// Code that might throw an error
const response = await fetch('https://api.example.com/users/123');
const user = await response.json();
updateUserProfile(user);
} catch (error) {
// Handle the error
console.error('Failed to fetch user:', error);
}

When an error is thrown inside the try block, your app doesn't crash—it jumps straight to the catch block. You'll use this pattern constantly for API calls, database queries, JSON parsing, and any operation that can fail.

TypeScript types the error parameter as unknown by default (in TypeScript 4.4+). This forces you to perform type checking before accessing error properties, making your error handling as type-safe as the rest of your code.

Managing Asynchronous Errors

Async functions require try-catch with async/await. This makes error handling look just like synchronous code.

async function fetchUserData(userId: string) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);

if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}

const userData = await response.json();
return userData;
} catch (error) {
// Handle different types of errors
if (error instanceof TypeError) {
console.error('Network error occurred:', error.message);
} else {
console.error('Unexpected error:', error);
}

// Rethrow or return a default value
return null;
}
}

Errors in async code can happen at multiple stages—network requests, JSON parsing, or downstream processing. Each await is a potential failure point.

Handling Multiple Async Operations

When you need to fetch multiple resources, you have two main approaches:

Promise.all (fail-fast):

async function loadDashboard(userId: string) {
try {
// If ANY request fails, the whole operation fails
const [user, products, orders] = await Promise.all([
fetchUser(userId),
fetchProducts(),
fetchOrders(userId)
]);

return { user, products, orders };
} catch (error) {
// Single error handler for any failed request
console.error('Failed to load dashboard:', error);
return null;
}
}

Promise.allSettled (continue on failure):

async function loadDashboardPartial(userId: string) {
const results = await Promise.allSettled([
fetchUser(userId),
fetchProducts(),
fetchOrders(userId)
]);

// Process each result individually
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const products = results[1].status === 'fulfilled' ? results[1].value : [];
const orders = results[2].status === 'fulfilled' ? results[2].value : [];

// Dashboard loads even if some requests fail
return { user, products, orders };
}

Use Promise.all when all operations must succeed. Use Promise.allSettled when you want to display partial data rather than failing completely.

You can also use Promise<T> with .catch() if you prefer chaining:

fetchData()
.then(data => processData(data))
.catch(error => {
console.error('Error processing data:', error);
});

Catching Specific Error Types

Different errors need different responses. Custom error classes let you identify what went wrong and handle it appropriately.

// Custom error classes for specific scenarios
class NetworkError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
this.name = 'NetworkError';
}
}

class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = 'ValidationError';
}
}

async function processUserData(userId: string) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);

if (!response.ok) {
throw new NetworkError(response.status, 'Failed to fetch user data');
}

const data = await response.json();

if (!data.email) {
throw new ValidationError('email', 'Email is required');
}

return data;
} catch (error) {
// Handle each error type differently
if (error instanceof NetworkError) {
console.error(`Network error (${error.statusCode}):`, error.message);
// Retry logic or fallback
} else if (error instanceof ValidationError) {
console.error(`Validation error for ${error.field}:`, error.message);
// Show user-friendly validation message
} else {
console.error('Unexpected error:', error);
}
return null;
}
}

Custom error types carry context about what failed. A NetworkError tells you the HTTP status code. A ValidationError tells you which field is invalid. This makes debugging faster and lets you show better error messages to users.

When testing error handling, you can use Convex's testing library to simulate different error conditions.

Using Try-Catch-Finally Blocks

The finally block runs cleanup code whether your try block succeeds or fails. Use it to close connections, release resources, or reset state.

async function processFile(filename: string): Promise<string> {
let fileHandle: FileHandle | null = null;

try {
fileHandle = await openFile(filename);
const data = await readFileContents(fileHandle);
return processData(data);
} catch (error) {
console.error(`Error processing file ${filename}:`, error);
return '';
} finally {
// Always runs, even if error was thrown or return was called
if (fileHandle) {
await closeFile(fileHandle);
console.log(`File ${filename} closed`);
}
}
}

Common uses for finally:

  • Closing database connections
  • Releasing file handles
  • Stopping timers or intervals
  • Resetting loading states in UI
  • Logging operation completion

The finally block executes no matter what—even if you return early from the try block or rethrow an error from the catch block. This prevents resource leaks.

Custom Error Classes

Custom error classes let you add context and distinguish between error types. Extend the built-in Error class to create specialized errors.

// Base custom error class
class AppError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
// Maintains proper stack trace in Node.js
Error.captureStackTrace(this, this.constructor);
}
}

// Specific error types with additional properties
class DatabaseError extends AppError {
constructor(
public operation: string,
public tableName: string,
message: string
) {
super(`Database error during ${operation} on ${tableName}: ${message}`);
}
}

class AuthenticationError extends AppError {
constructor(
public username: string,
message: string
) {
super(`Authentication failed for ${username}: ${message}`);
}
}

Benefits of custom errors:

  1. More descriptive error messages with context
  2. Use instanceof checks to identify error types
  3. Attach additional properties (like operation or username)
  4. Better debugging with specialized stack traces

Custom errors shine in complex TypeScript applications where you need to distinguish between failure modes and handle them differently.

When building with Convex, custom errors help you differentiate between database errors, validation errors, and application logic errors.

Maintaining Type Safety in Try-Catch

TypeScript types caught errors as unknown by default (since TypeScript 4.4+). This forces you to verify the error type before accessing its properties.

try {
await riskyOperation();
} catch (error: unknown) {
// Type guard to safely handle error object
if (error instanceof Error) {
console.error('Error message:', error.message);
// TypeScript knows this is an Error object
} else {
console.error('Unknown error:', error);
}
}

For specific error structures, create custom type guards:

// Type guard for API error responses
function isApiError(error: unknown): error is {
code: number;
message: string
} {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error
);
}

try {
const response = await fetchData();
processResponse(response);
} catch (error: unknown) {
if (isApiError(error)) {
// TypeScript knows error has code and message
console.error(`API Error ${error.code}: ${error.message}`);
} else if (error instanceof Error) {
console.error('Standard error:', error.message);
} else {
console.error('Unknown error type:', error);
}
}

This approach aligns with TypeScript best practices and keeps your error handling type-safe, even when dealing with third-party code that might throw unexpected types.

Extracting Error Messages: A Utility Pattern

Repeating type guards in every catch block gets tedious. Extract this logic into a reusable utility function:

function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}

// Usage across your codebase
try {
await updateUserProfile(userId, data);
} catch (error) {
const message = getErrorMessage(error);
console.error('Profile update failed:', message);
showNotification(message);
}

For more robust handling, create a type guard that validates error structure:

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 toErrorWithMessage(maybeError: unknown): { message: string } {
if (isErrorWithMessage(maybeError)) return maybeError;

try {
return { message: JSON.stringify(maybeError) };
} catch {
// Fallback for circular references or non-serializable values
return { message: String(maybeError) };
}
}

function getErrorMessage(error: unknown): string {
return toErrorWithMessage(error).message;
}

This pattern handles edge cases like circular references and non-Error thrown values, giving you consistent error messages throughout your app.

Debugging TypeScript Errors

Good error logging helps you diagnose production issues. Log error messages, stack traces, and context to understand what went wrong.

try {
const result = await complexOperation();
return result;
} catch (error) {
if (error instanceof Error) {
console.error(`Name: ${error.name}`);
console.error(`Message: ${error.message}`);
console.error(`Stack: ${error.stack}`);

// Check for additional properties on custom errors
const anyError = error as any;
if (anyError.code) {
console.error(`Code: ${anyError.code}`);
}
} else {
console.error('Unknown error type:', error);
}

// Report to monitoring service or rethrow
throw error;
}

For consistent error reporting, create a helper function:

function logError(error: unknown, context?: Record<string, any>) {
const errorInfo = {
timestamp: new Date().toISOString(),
context: context || {},
error: error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack
}
: error
};

console.error(JSON.stringify(errorInfo, null, 2));

// In production, send to a logging service
// reportErrorToService(errorInfo);
}

// Usage
try {
await updateUserProfile(userId, newData);
} catch (error) {
logError(error, { userId, operation: 'updateUserProfile' });
throw error;
}

Preserving Error Context with cause

When you catch an error and throw a new one, you lose the original stack trace. Use the cause property to preserve it:

async function loadUserSettings(userId: string) {
try {
const response = await fetch(`/api/settings/${userId}`);
return await response.json();
} catch (originalError) {
// Wrap the original error with more context
throw new Error(`Failed to load settings for user ${userId}`, {
cause: originalError
});
}
}

// Higher up in your call stack
try {
await loadUserSettings('user-123');
} catch (error) {
if (error instanceof Error) {
console.error('Top-level error:', error.message);
// Access the original error
console.error('Original error:', error.cause);
}
}

The cause property chains errors together, preserving the full context from where the error originated. This makes debugging nested function calls much easier.

When working with Convex, you can leverage its testing tools to simulate error conditions and verify your error handling works correctly.

Common Pitfalls and How to Avoid Them

Re-throwing with Added Context

When you catch an error at a low level and need to add context, don't lose the original error:

try {
const data = await processUserData(userId);
return data;
} catch (error) {
// Add context before re-throwing
if (error instanceof Error) {
throw new Error(`Failed while processing user ${userId}: ${error.message}`);
}
throw error;
}

Conditional Error Recovery

Don't retry every error—only retry when it makes sense:

async function fetchWithRetry(url: string, retryCount = 0): Promise<Data> {
try {
return await fetchData(url);
} catch (error) {
// Only retry network errors, not validation or auth errors
if (error instanceof NetworkError && retryCount < MAX_RETRIES) {
console.log(`Retrying (${retryCount + 1}/${MAX_RETRIES})...`);
await delay(1000 * (retryCount + 1)); // Exponential backoff
return await fetchWithRetry(url, retryCount + 1);
}
throw error;
}
}

Handling Errors in Loops

If you're processing multiple items, decide whether to fail-fast or continue on errors:

// Continue processing even if some items fail
async function processItems(items: Item[]): Promise<ProcessResult[]> {
const results: ProcessResult[] = [];

for (const item of items) {
try {
const result = await processItem(item);
results.push({ item, status: 'success', result });
} catch (error) {
// Log but continue processing
results.push({
item,
status: 'error',
error: error instanceof Error ? error.message : String(error)
});
}
}

return results;
}

When Try-Catch Isn't the Answer

Try-catch is powerful, but it's not always the right tool. Here's when to avoid it:

Performance-Critical Loops

Try-catch blocks can impact performance in tight loops because JavaScript engines optimize them differently:

// Avoid: try-catch inside a hot loop
function processLargeArray(items: number[]) {
const results = [];
for (let i = 0; i < items.length; i++) {
try {
results.push(expensiveOperation(items[i]));
} catch (error) {
results.push(null);
}
}
return results;
}

// Better: Validate first, then process
function processLargeArray(items: number[]) {
const results = [];
for (let i = 0; i < items.length; i++) {
if (isValidInput(items[i])) {
results.push(expensiveOperation(items[i]));
} else {
results.push(null);
}
}
return results;
}

Control Flow

Don't use exceptions for normal program flow. They're meant for exceptional situations:

// Avoid: Using exceptions for control flow
function findUser(id: string) {
try {
const user = getUserById(id);
if (!user) throw new Error('User not found');
return user;
} catch {
return null;
}
}

// Better: Use explicit checks
function findUser(id: string) {
const user = getUserById(id);
return user ?? null;
}

Validation Logic

Validate inputs upfront rather than catching validation errors:

// Avoid: Relying on exceptions for validation
function createUser(email: string) {
try {
if (!email.includes('@')) {
throw new Error('Invalid email');
}
return saveUser(email);
} catch (error) {
return null;
}
}

// Better: Validate before processing
function createUser(email: string) {
if (!isValidEmail(email)) {
return { success: false, error: 'Invalid email' };
}
const user = saveUser(email);
return { success: true, user };
}

Try-catch is for handling unexpected runtime errors—network failures, parsing errors, and external API issues. For predictable conditions like validation or null checks, use explicit conditional logic instead.

Final Thoughts on TypeScript Try-Catch

Try-catch blocks prevent crashes by intercepting runtime errors before they bring down your application. Use them for operations that can fail—API calls, JSON parsing, file operations, and external dependencies.

The key patterns to remember:

  • Type caught errors as unknown and use type guards to access properties safely
  • Create custom error classes to distinguish between failure modes
  • Use Promise.all for fail-fast behavior and Promise.allSettled when partial success is acceptable
  • Preserve error context with the cause property when rethrowing
  • Extract error handling logic into reusable utility functions
  • Know when NOT to use try-catch: avoid it in performance-critical loops and for normal control flow

Master these patterns and you'll write TypeScript code that fails gracefully while maintaining full type safety.