What is Error Handling in TypeScript?
Error handling in TypeScript is the practice of anticipating, catching, and responding to runtime failures using the language’s type system to enforce safety. While TypeScript eliminates many compile-time bugs through static types, runtime errors—such as network failures, invalid user input, file system exceptions, or logic mistakes—still occur. Effective error handling patterns leverage TypeScript’s types to model errors as explicit values, making failure cases visible and predictable. This approach helps developers build applications that fail gracefully, provide meaningful feedback, and are easier to debug and maintain.
Why Proper Error Handling Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Unhandled errors can crash applications, corrupt data, and create poor user experiences. In JavaScript, errors often surface as vague stack traces or silent failures, making debugging difficult. TypeScript adds a powerful layer: the ability to define precise error types and enforce their handling through the type checker. By treating errors as first-class citizens of your type system, you gain several advantages:
- Predictability: The compiler can warn when error cases are not covered, reducing the chance of unhandled rejections or unexpected crashes.
- Maintainability: Explicit error types serve as documentation, making it clear what can go wrong and how to react.
- Separation of concerns: Business logic can focus on the happy path while error handling remains composable and type-safe.
- Better developer experience: Autocompletion and type narrowing guide you to handle errors correctly.
Investing in robust error handling patterns early transforms fragile, crash-prone code into resilient, production-ready software.
Common Error Handling Patterns
TypeScript supports several patterns for managing errors, ranging from traditional try/catch blocks to more functional approaches. Below are five widely used patterns, each with practical code examples.
1. Try/Catch with Type Narrowing
The classic try/catch mechanism remains essential, but TypeScript improves it by enabling type narrowing on caught errors. Because catch parameters default to unknown, you must check the error’s type before accessing properties. This prevents accidental assumptions and encourages deliberate handling.
class NetworkError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'NetworkError';
}
}
class ValidationError extends Error {
constructor(message: string, public field: string) {
super(message);
this.name = 'ValidationError';
}
}
async function fetchData<T>(url: string): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new NetworkError(
`HTTP error ${response.status}`,
response.status
);
}
const data = await response.json();
return data as T;
} catch (error: unknown) {
if (error instanceof NetworkError) {
console.error(
`Network error [${error.statusCode}]: ${error.message}`
);
// Retry logic or fallback
throw error;
}
if (error instanceof ValidationError) {
console.error(`Invalid field '${error.field}': ${error.message}`);
// Inform the user about the specific field
throw error;
}
if (error instanceof Error) {
console.error(`General error: ${error.message}`);
throw new Error('Fetch failed', { cause: error });
}
// Handle non-Error throws (should be avoided, but defensive code)
throw new Error('Unknown error occurred');
}
}
Key benefits: custom error classes (extending Error) allow precise filtering, and the unknown catch parameter forces type checks, eliminating unsafe access to .message or .stack.
2. The Result (Either) Pattern
Inspired by functional programming, the Result pattern replaces thrown exceptions with a return type that explicitly carries either a success value or an error. This makes error handling a normal part of the control flow, not an exceptional jump. TypeScript’s discriminated unions model this beautifully.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function safeParse(json: string): Result<unknown, string> {
try {
const parsed = JSON.parse(json);
return { ok: true, value: parsed };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: `Invalid JSON: ${message}` };
}
}
// Usage
const result = safeParse('{"name": "Alice"}');
if (result.ok) {
console.log('Parsed:', result.value);
} else {
console.error('Parse error:', result.error);
}
// Composing Result operations
function fetchUser(id: string): Result<User, string> {
// Simulated lookup
const user = db.lookup(id);
if (!user) {
return { ok: false, error: `User ${id} not found` };
}
return { ok: true, value: user };
}
function processUser(data: unknown): Result<string, string> {
const parseResult = safeParse(JSON.stringify(data));
if (!parseResult.ok) return parseResult; // forward error
const userResult = fetchUser(parseResult.value.id);
if (!userResult.ok) return userResult;
return { ok: true, value: `Hello, ${userResult.value.name}` };
}
This pattern forces callers to inspect the ok flag before accessing the value. It eliminates the need for try/catch in high-level orchestration and makes error propagation explicit.
3. Discriminated Unions for Error States
Beyond function returns, discriminated unions can represent the state of an entire operation (loading, success, error). This is especially useful in UI development and asynchronous workflows.
type LoadingState = { status: 'loading' };
type SuccessState<T> = { status: 'success'; data: T };
type ErrorState = { status: 'error'; error: Error };
type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;
function renderState<T>(state: AsyncState<T>): string {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return `Data: ${JSON.stringify(state.data)}`;
case 'error':
return `Error: ${state.error.message}`;
default:
// Exhaustiveness check – TypeScript will error if a case is missing
const _exhaustive: never = state;
return 'Unknown state';
}
}
// Example usage in a React component (conceptual)
function UserProfile({ userId }: { userId: string }) {
const [profileState, setProfileState] = useState<AsyncState<User>>({
status: 'loading',
});
useEffect(() => {
fetchProfile(userId)
.then(data => setProfileState({ status: 'success', data }))
.catch(error => setProfileState({ status: 'error', error }));
}, [userId]);
return <div>{renderState(profileState)}</div>;
}
The never exhaustiveness check ensures all possible states are handled. If a new status is added later, TypeScript will flag the default branch as an error until you handle it explicitly.
4. Async/Await and Promise Error Handling
Asynchronous code introduces additional error handling needs. TypeScript helps by typing the results of Promise.allSettled and by enforcing proper rejection handling. Here’s a robust pattern for multiple concurrent requests.
async function fetchMultiple<T>(urls: string[]): Promise<Result<T, Error>[]> {
const promises = urls.map(async (url) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
const data = await response.json();
return { ok: true as const, value: data as T };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return { ok: false as const, error: err };
}
});
return Promise.all(promises); // all settle by construction
}
// Alternative using Promise.allSettled for partial results
async function fetchWithPartialResults(urls: string[]): Promise<void> {
const results = await Promise.allSettled(
urls.map(url => fetch(url).then(res => res.json()))
);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`URL ${urls[index]} succeeded:`, result.value);
} else {
console.error(`URL ${urls[index]} failed:`, result.reason);
}
});
}
By wrapping each fetch in its own try/catch, you can return a Result type and avoid mixed arrays of fulfilled and rejected promises. Promise.allSettled gives you full visibility into each outcome without failing fast.
5. Validation with Type Guards and Assertions
Preventing errors starts with validating input at the boundaries of your system. TypeScript’s type guards and assertion functions let you narrow unknown data into trusted types and throw early if the data is malformed.
interface User {
name: string;
age: number;
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'age' in obj &&
typeof (obj as any).name === 'string' &&
typeof (obj as any).age === 'number'
);
}
function assertUser(obj: unknown): asserts obj is User {
if (!isUser(obj)) {
throw new TypeError(
`Invalid user object: ${JSON.stringify(obj)}`
);
}
}
function processInput(input: unknown): void {
assertUser(input);
// TypeScript now knows `input` is User
console.log(`Hello ${input.name}, you are ${input.age} years old.`);
}
// Using a validation layer that aggregates errors
type ValidationResult<T> =
| { valid: true; data: T }
| { valid: false; errors: string[] };
function validateUser(input: unknown): ValidationResult<User> {
const errors: string[] = [];
if (typeof input !== 'object' || input === null) {
return { valid: false, errors: ['Expected an object'] };
}
const obj = input as any;
if (typeof obj.name !== 'string') errors.push('name must be a string');
if (typeof obj.age !== 'number') errors.push('age must be a number');
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, data: { name: obj.name, age: obj.age } };
}
Type guards (obj is User) return boolean predicates that TypeScript uses to narrow types inside conditionals. Assertion functions (asserts obj is User) throw if the condition fails, providing a concise way to validate and narrow in a single statement.
Best Practices for TypeScript Error Handling
- Use custom error classes extending
Error: Avoid throwing plain strings or numbers. Custom errors carry structured metadata and can be filtered byinstanceof. - Keep error types explicit: When using Result or discriminated union patterns, define precise error types (e.g.,
string,Error, or a custom error union) to guide handling. - Never swallow errors silently: An empty
catchblock hides problems. At minimum, log the error or forward it to a global handler. - Handle errors at the right level: Low-level functions should collect and return errors (e.g., via Result), while top-level orchestration decides whether to retry, fallback, or report to the user.
- Leverage exhaustiveness checking: Use the
nevertype anddefaultbranches in switch statements to ensure all error states are covered. - Validate early, throw or return errors immediately: Catch malformed data at system boundaries with type guards and assertion functions, preventing invalid data from propagating deeper into the application.
- Centralize global rejection handling: For unhandled promise rejections, register a global listener (e.g.,
process.on('unhandledRejection')in Node.js orwindow.onunhandledrejectionin browsers) to log and optionally crash gracefully. - Test error scenarios: Write unit tests for error paths just as you would for success paths. Ensure your error handling code is exercised and produces expected outcomes.
- Prefer composable error types: Avoid generic
Erroreverywhere. Use discriminated unions, Result types, or tagged errors that carry context, making error handling code easier to understand and refactor.
Conclusion
Error handling is not an afterthought—it is a core part of building reliable TypeScript applications. By leveraging the type system, you can turn unpredictable runtime failures into well-defined, manageable states. Patterns like typed try/catch, Result types, discriminated unions, and validation assertions give you the tools to express error scenarios clearly and enforce their handling at compile time. Adopting these patterns and adhering to best practices will result in code that is safer, easier to debug, and more resilient in the face of unexpected conditions. Start treating errors as values, and watch your TypeScript projects become significantly more robust.