TypeScript Error Type Handling in Try-Catch Blocks
You're debugging why your app crashes in production, but the error logs just show "Unknown error occurred." The catch block isn't giving you the error message, stack trace, or any useful context. You know the error is there, but TypeScript won't let you access error.message without throwing a compilation error.
This is the frustration of TypeScript's strict error typing. Since TypeScript 4.4, catch block errors default to unknown instead of any. While this makes your code safer, it also means you need to handle errors more explicitly. In this guide, you'll learn how to work with TypeScript's error types, extract useful information from caught errors, and build robust error handling that works in real applications.
Why TypeScript Uses unknown for Catch Blocks
In JavaScript, you can throw anything. Not just Error objects, but strings, numbers, objects, or even null. TypeScript's type system has to account for this reality.
// All of these are valid JavaScript
throw new Error('Something went wrong');
throw 'Server timeout';
throw 404;
throw { code: 'AUTH_FAILED', details: {...} };
This is why TypeScript 4.4+ defaults to typing catch block errors as unknown. You can't assume it's an Error object until you verify it at runtime.
Using TypeScript's unknown Type for Error Catching
The unknown type forces you to verify what you're dealing with before accessing any properties. Here's the basic pattern:
try {
await fetchUserProfile(userId);
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error('An unexpected error occurred:', error);
}
}
This approach prevents runtime crashes from assumptions about error structure. You're checking the type before accessing message, so TypeScript knows it's safe.
In TypeScript projects using Convex for the backend, you can leverage end-to-end type safety from your database schema to your React app. This approach helps ensure that errors from server functions are properly typed and handled in the client.
While typeof can help with basic type checking, instanceof is more reliable for checking custom error classes. You can also create custom type guards for more complex error validation.
Building a Practical Error Message Extractor
Rather than repeating error checks throughout your codebase, you can build a utility function that safely extracts error messages:
function getErrorMessage(error: unknown): string {
// Handle standard Error objects
if (error instanceof Error) {
return error.message;
}
// Handle string throws
if (typeof error === 'string') {
return error;
}
// Handle objects with a message property
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message;
}
// Fallback for unexpected types
try {
return JSON.stringify(error);
} catch {
// JSON.stringify can fail on circular references
return 'Unable to determine error message';
}
}
// Usage in your code
try {
await processPayment(orderId);
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Payment processing failed:', message);
showUserNotification(message);
}
This pattern handles the reality that libraries and legacy code might throw non-Error values. The function degrades gracefully, ensuring you always get something you can log or display to users.
Implementing Custom Error Classes
Custom error classes let you add context that generic Error objects can't provide. You can include additional properties that help you debug and handle specific failure scenarios.
class ValidationError extends Error {
field: string;
constructor(message: string, field: string) {
super(message);
this.field = field;
// Required for instanceof checks - see explanation below
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
class NetworkError extends Error {
statusCode: number;
endpoint: string;
constructor(message: string, statusCode: number, endpoint: string) {
super(message);
this.statusCode = statusCode;
this.endpoint = endpoint;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
Now when you catch errors, you have more information to work with:
function validateEmail(email: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new ValidationError('Invalid email format', 'email');
}
}
try {
validateEmail(userInput);
} catch (error: unknown) {
if (error instanceof ValidationError) {
// You know exactly which field failed
showFieldError(error.field, error.message);
} else {
showGenericError();
}
}
Using class inheritance with Error gives you type safety while maintaining JavaScript's error handling patterns. The constructor in custom error classes should always call super(message) to properly initialize the base Error properties.
When working with complex database systems, custom error classes can help distinguish between different failure modes when accessing related documents.
If you're using TypeScript with React, custom errors can improve how your application communicates failures to users, turning generic errors into actionable messages.
Why Object.setPrototypeOf Is Required
You might wonder why we need Object.setPrototypeOf(this, ValidationError.prototype) in every custom error constructor. This isn't just ceremony - it's fixing a real problem.
When TypeScript compiles your code to ES5 or ES3 (which is common if you need to support older environments), the instanceof operator breaks for custom errors. Without explicitly setting the prototype, this happens:
// Without Object.setPrototypeOf
class DatabaseError extends Error {
constructor(message: string) {
super(message);
}
}
const error = new DatabaseError('Connection failed');
console.log(error instanceof DatabaseError); // false (!)
console.log(error instanceof Error); // true
The instanceof DatabaseError check returns false even though you just created a DatabaseError instance. This breaks your error handling logic.
The Object.setPrototypeOf(this, DatabaseError.prototype) line fixes the prototype chain. If you're compiling to ES2015 or later, you don't need this workaround since ES2015 handles class inheritance correctly. But if you're targeting ES5, this line is critical for instanceof checks to work properly.
Handling Multiple Error Types with Type Guards
In real applications, different operations can fail in different ways. You'll often need to handle multiple custom error types in a single catch block:
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new NetworkError(
'Failed to fetch users',
response.status,
'/api/users'
);
}
const data = await response.json();
validateEmail(data.email);
} catch (error: unknown) {
if (error instanceof NetworkError) {
// Handle network failures differently based on status code
if (error.statusCode >= 500) {
logger.error('Server error:', error.endpoint);
showRetryButton();
} else if (error.statusCode === 404) {
showNotFoundMessage();
}
} else if (error instanceof ValidationError) {
// Show user which field has the problem
highlightField(error.field);
showErrorMessage(error.message);
} else {
// Fallback for unexpected errors
logger.error('Unexpected error:', error);
showGenericErrorMessage();
}
}
The try catch pattern in TypeScript is enhanced by type guards, which let the compiler know what properties are available on the error object. Using utility types with errors can also improve your error handling.
For backend development with Convex, you can benefit from schema validation to prevent many errors before they occur. This works well with TypeScript's typing system to create a more robust application.
When working with a backend like Convex, you can create application-specific error types that align with your system's domains. For example, you might create authentication errors, validation errors, or resource-not-found errors. Convex's error handling system provides built-in support for distinguishing between different error types. If you're using try catch with Convex functions, you can leverage Convex's structured approach to errors to distinguish between different types of failures and handle them accordingly in your client code.
Note that while you can't type the catch clause parameter as a specific union type like error: NetworkError | ValidationError, TypeScript will give you an error (TS1196). The error parameter must be unknown or any. This is because JavaScript allows throwing any value, so TypeScript can't guarantee what type you'll actually catch.
Convex's API generation system automatically creates TypeScript types for your backend functions, which makes error handling across the frontend-backend boundary more reliable.
Refining Error Types with Type Guards
Custom type guard functions let you encapsulate error type checking logic and reuse it throughout your application. This is especially useful when you need more sophisticated checks than simple instanceof:
// Simple instanceof wrapper
function isNetworkError(error: unknown): error is NetworkError {
return error instanceof NetworkError;
}
// More complex structural check
function isErrorWithCode(error: unknown): error is { code: string; message: string } {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
typeof (error as any).code === 'string' &&
typeof (error as any).message === 'string'
);
}
try {
await apiCall();
} catch (error: unknown) {
if (isNetworkError(error)) {
retryWithBackoff(error.endpoint);
} else if (isErrorWithCode(error)) {
// Handle errors from third-party libraries that don't extend Error
handleErrorCode(error.code, error.message);
} else {
logUnknownError(error);
}
}
The error is NetworkError return type is a type predicate. It tells TypeScript that if the function returns true, the error parameter can be treated as a NetworkError in the calling code.
Custom type guards are particularly valuable when dealing with errors from external APIs or libraries where you can't control the error types. Rather than assuming structure, you verify it explicitly.
For complex validation scenarios, consider using generics<T> with your error types to create flexible error handling patterns.
Choosing the Right Error Handling Approach
Different scenarios call for different error handling patterns. Here's when to use each approach:
| Approach | Best Used When | Benefits | Trade-offs |
|---|---|---|---|
Basic instanceof Error | Simple applications, logging errors, you don't control what gets thrown | Works with any Error subclass, handles standard Error objects | Can't distinguish between different error types, no custom properties |
| Custom Error Classes | You control the throw sites, need structured error data, building a library | Type-safe custom properties, clear error hierarchies, great IDE support | Requires Object.setPrototypeOf for ES5, more boilerplate code |
Utility Functions (getErrorMessage) | Working with third-party code, need consistent error messages for logging/display | Handles any thrown value safely, centralizes error extraction logic | Loses detailed type information, returns generic strings |
| Custom Type Guards | Handling errors from external APIs, checking structural properties, can't use instanceof | Works with plain objects, flexible validation, reusable logic | More verbose than instanceof, requires runtime property checks |
Here's how these patterns fit together in a real application:
// Define domain-specific errors
class PaymentError extends Error {
transactionId: string;
constructor(message: string, transactionId: string) {
super(message);
this.transactionId = transactionId;
Object.setPrototypeOf(this, PaymentError.prototype);
}
}
// Utility for safe error message extraction
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'An unexpected error occurred';
}
// Type guard for third-party API errors
function isStripeError(error: unknown): error is { type: string; message: string } {
return (
typeof error === 'object' &&
error !== null &&
'type' in error &&
'message' in error
);
}
// Use all patterns together
async function processPayment(orderId: string) {
try {
const result = await chargeCustomer(orderId);
return result;
} catch (error: unknown) {
// Handle custom errors with instanceof
if (error instanceof PaymentError) {
await refundTransaction(error.transactionId);
throw error;
}
// Handle third-party errors with type guards
if (isStripeError(error)) {
logger.error('Stripe error:', error.type);
throw new PaymentError(error.message, orderId);
}
// Fallback: extract whatever message we can
const message = getErrorMessage(error);
logger.error('Payment failed:', message);
throw new PaymentError(message, orderId);
}
}
Best Practices for TypeScript Error Handling
Based on what we've covered, here are the key rules to follow:
-
Always type catch block errors as
unknown- Don't useany. Force yourself to verify the error type before accessing properties. -
Provide a fallback for every error handler - Not everything thrown will be an Error object. Always have an
elseclause that handles unexpected values. -
Use
Object.setPrototypeOfin custom error constructors if targeting ES5 - Don't skip this line unless you're certain your compilation target is ES2015 or later. -
Extract error message utilities - Don't repeat error message extraction logic. Build utilities like
getErrorMessage()once and reuse them. -
Prefer
instanceofover property checks when possible - It's more reliable and clearer. Use custom type guards only wheninstanceofwon't work. -
Add context to custom errors - If you're creating a custom error class, include properties that help debug the problem (IDs, timestamps, endpoints, etc.).
Final Thoughts on TypeScript Error Handling
TypeScript's error handling forces you to think about edge cases. Yes, typing errors as unknown is more work upfront, but it prevents production bugs from assumptions about error structure.
When building applications with Convex, you benefit from end-to-end type safety that extends to error handling. This approach helps prevent issues before they reach production.
The best error handling strategy recognizes that anything can be thrown and builds resilient code that degrades gracefully. Your catch blocks shouldn't just handle the errors you expect - they should survive the errors you don't.