Skip to main content

How to use a try-catch block in TypeScript

A simple and effective way to manage errors in TypeScript is by using try-catch blocks. These blocks let you run code that might fail and catch errors to prevent crashes. In this article, we'll look at how to use try-catch blocks in TypeScript, manage errors in asynchronous code, catch specific error types, and maintain type safety.

Using Try-Catch Blocks in TypeScript

Try-catch blocks in TypeScript enhance JavaScript's error handling with TypeScript's type system. This combination lets you catch and respond to errors while maintaining type safety.

try {
// Code that might throw an error
const result = someRiskyOperation();
processResult(result);
} catch (error) {
// Handle the error
console.error('An error occurred:', error);
}

When the code inside the try block throws an error, execution immediately jumps to the catch block, preventing your application from crashing. This pattern is particularly useful when working with operations that might fail, such as API calls, file operations, or complex calculations. By default, TypeScript types the error parameter as unknown in newer versions (instead of any), encouraging proper type checking before using the caught error. This helps prevent runtime issues and ensures your error handling is as type-safe as the rest of your code.

Managing Asynchronous Errors

For asynchronous errors, use try-catch with async/await. This makes handling errors in asynchronous code straightforward and similar to handling synchronous errors.

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);
}

// You might want to rethrow or return a default value
return null;
}
}

When working with asynchronous operations, proper error handling becomes even more crucial as errors can occur at different stages - during network requests, data parsing, or downstream processing. You can also use try-catch with Promise<T> directly if you prefer the .then()/.catch() syntax:

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

For complex async operations, consider creating custom error classes to distinguish between different error types, making your error handling more precise and informative.

Catching Specific Error Types

Catching specific error types is important for precise handling. You can create custom error classes in TypeScript that extend the Error class and use instanceof to check the error type.

// Custom error classes for specific error 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 {
// Code that might throw different errors
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) {
// Type-specific error handling
if (error instanceof NetworkError) {
// Handle network-specific errors
console.error(`Network error (${error.statusCode}):`, error.message);
// Attempt retry or fallback strategy
} else if (error instanceof ValidationError) {
// Handle validation-specific errors
console.error(`Validation error for ${error.field}:`, error.message);
// Perhaps return default values or request correction
} else {
// Handle unknown errors
console.error('Unexpected error:', error);
}
return null;
}
}

This approach works well with the TypeScript's type system, allowing you to create error types that carry additional context about what went wrong. When testing this code, you can use Convex's testing library to simulate different error conditions and verify your error handling logic works correctly.

Using Try-Catch-Finally Blocks

The try-catch-finally structure lets you catch errors and run cleanup code no matter what happens. This is useful for closing files or connections.

function processFile(filename: string): string {
let fileHandle: any = null;

try {
// Open the file
fileHandle = openFile(filename);

// Process the file contents
const data = readFileContents(fileHandle);
return processData(data);
} catch (error) {
// Handle errors during file processing
console.error(`Error processing file ${filename}:`, error);
return '';
} finally {
// Clean up resources even if an error occurred
if (fileHandle) {
closeFile(fileHandle);
console.log(`File ${filename} closed successfully`);
}
}
}

The finally block is particularly valuable for:

  • Closing database connections

  • Releasing file handles

  • Freeing up system resources

  • Resetting state variables

  • Logging completion (successful or not) This pattern ensures that your application doesn't leak resources even when errors occur, which is essential for building reliable systems. The finally block executes in all cases - whether the try block completes normally, an exception is caught, or even if an exception is re-thrown from the catch block.

Custom Error Classes

Custom error classes allow you to define specific errors for better handling. Extend the Error class to create errors with unique messages.

// 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 environments
Error.captureStackTrace(this, this.constructor);
}
}

// Specific error types
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}`);
}
}

When using these custom errors, you benefit from:

  1. More descriptive error messages with contextual details

  2. Ability to use instanceof checks to identify error types

  3. Additional properties specific to each error scenario

  4. Better debugging through specialized stack traces

Custom errors work particularly well in complex TypeScript applications where you need to distinguish between different failure modes and potentially handle them differently based on their type.

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

Maintaining Type Safety in Try-Catch

TypeScript's type system can be challenging to maintain in try-catch blocks because errors are typed as unknown by default (in TypeScript 4.4+). This safety mechanism prevents accidental usage of error properties without verification, but requires proper type narrowing.

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

For more complex scenarios, create type guards to handle specific error structures:

// 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 now knows error has code and message properties
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 ensures your error handling remains type-safe even when dealing with external APIs or libraries that might throw unexpected error types. By combining type guards with null checks, you can build robust error handling that catches all potential issues while maintaining type safety.

Debugging TypeScript Errors

Debugging involves logging or tracking errors to see what went wrong. Catch errors and log their messages and stack traces to better understand issues.

try {
// Code that might fail
const result = complexOperation();
return result;
} catch (error) {
// Enhanced error logging
console.error('Error details:');

if (error instanceof Error) {
console.error(`Name: ${error.name}`);
console.error(`Message: ${error.message}`);
console.error(`Stack: ${error.stack}`);

// 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);
}

// You might want to report to a monitoring service here

// Rethrow or return default value based on context
throw error; // Or: return defaultValue;
}

For more structured debugging, you can integrate with error tracking services and create helper functions to standardize error reporting:

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

// Log locally during development
console.error(JSON.stringify(errorInfo, null, 2));

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

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

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

Common Challenges and Solutions

When working with try-catch in TypeScript, developers frequently encounter specific challenges. Here are practical solutions to common issues:

Handling Promise Rejections

// Problem: Missed promise rejections
const promises = [fetchUser(), fetchProducts(), fetchOrders()];

// Better approach: Use Promise.all with try-catch
try {
const [user, products, orders] = await Promise.all(promises);
// Process results
} catch (error) {
// Single error handler for any rejected promise
console.error('Failed to fetch data:', error);
}

Re-throwing with Added Context

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

try {
return await fetchData();
} catch (error) {
// Only retry on network errors, not validation errors
if (error instanceof NetworkError && retryCount < MAX_RETRIES) {
console.log(`Retrying (${retryCount + 1}/${MAX_RETRIES})...`);
return await fetchWithRetry(url, retryCount + 1);
}
throw error; // Re-throw other errors
}

Handling Errors in Loops

// Process items even if some 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) {
results.push({
item,
status: 'error',
error: error instanceof Error ? error.message : String(error)
});
}
}

return results;
}

Final Thoughts on TypeScript Try-Catch

Try-catch blocks help handle errors and write more robust code by preventing crashes and providing ways to respond to different error scenarios. By using type guards, custom error classes, and proper async error handling, you can build applications that gracefully handle failures while maintaining TypeScript's type safety benefits.