Handle API Errors in TypeScript
Learn to type and handle API errors safely in TypeScript. Covers error narrowing, discriminated result types, status-code handling, and typed error boundaries.
Handling API errors in TypeScript means more than wrapping fetch in a try/catch. It means typing the errors so the compiler helps you handle each failure case correctly. A network timeout, a 404 response, and a 500 server error are different problems that need different handling. Your types should reflect that.
The starting point is always the same: the error in a catch block is typed as unknown in strict mode. From there, you narrow based on what actually went wrong.
The Unknown Error Starting Point
Every catch block begins with an error of type unknown. This is by design, because JavaScript lets you throw anything.
async function fetchUser(id: number) {
try {
const response = await fetch(`/api/users/${id}`);
return await response.json();
} catch (error) {
// error is unknown here
}
}The first thing you must do is narrow the type. The simplest check is instanceof Error, which covers the vast majority of cases where something throwable was actually an Error object.
async function fetchUser(id: number) {
try {
const response = await fetch(`/api/users/${id}`);
return await response.json();
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}Once narrowed, the compiler allows access to message, stack, and other Error properties. Without the check, those accesses are compile errors.
Creating Typed API Error Classes
A generic Error tells you something went wrong but not what the server said. Create custom error classes that carry HTTP context.
class ApiError extends Error {
constructor(
message: string,
public status: number,
public body: unknown
) {
super(message);
this.name = "ApiError";
}
}The status and body properties carry the HTTP response details. Now a catch block can distinguish between a 404 and a 500.
function handleUserLoadError(error: unknown): void {
if (error instanceof ApiError) {
if (error.status === 404) {
// Resource not found
} else if (error.status >= 500) {
// Server error, maybe retry
}
}
}Throw ApiError from your fetch wrapper whenever the response is not ok. The compiler then knows exactly what properties are available on the error object in every catch block.
Using Discriminated Result Types Instead of Throwing
Some teams prefer returning error information instead of throwing. A discriminated union makes this type-safe.
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; message: string };The caller must narrow on the ok field before accessing any result data. The compiler enforces this by making data inaccessible on the error variant, and message inaccessible on the success variant.
const result = await safeFetch<User>("/api/users/1");
if (result.ok) {
console.log(result.data.name);
} else {
console.error(`${result.status}: ${result.message}`);
}The diagram shows the two paths. The compiler guarantees that callers cannot access data without first checking ok, and cannot miss handling the error case entirely.
Narrowing by HTTP Status Code
Different status code ranges mean different things. Narrow on ranges to handle each appropriately.
The first two cases handle authentication and authorization errors. These need immediate user action rather than retries.
function handleAuthError(error: ApiError): void {
if (error.status === 401) {
// Redirect to login
} else if (error.status === 403) {
// Show permission denied
}
}The remaining cases handle client and server errors by status code range. Client errors should not be retried because the request itself is wrong.
function handleStatusError(error: ApiError): void {
if (error.status >= 400 && error.status < 500) {
// Client error, do not retry
} else if (error.status >= 500) {
// Server error, safe to retry
}
}Each function checks the status field and responds differently based on what the range means for your application. Call the one that fits the situation from inside your catch block once error has been narrowed to ApiError.
Parsing Error Response Bodies
Many APIs return structured error bodies with codes and details. Type these bodies so you can extract useful information.
interface ErrorBody {
error: string;
code: string;
details?: string[];
}
function logErrorBody(error: ApiError): void {
const body = error.body as ErrorBody;
console.log(`${body.code}: ${body.error}`);
}The type assertion on the body is necessary because the raw response could be anything. If you control the API, document the error body shape and type it consistently across all error handlers.
Common Mistakes
Catching and ignoring errors silently. An empty catch block swallows failures and makes bugs impossible to trace. Always at least log the error, even if you do not recover from it.
Assuming the error is always an Error instance. Network failures can produce TypeError or DOMException. Status code errors need ApiError. Always narrow with instanceof before accessing properties.
Using any for the error type. Writing catch (error: any) disables the unknown safety net. Leave the annotation off and narrow inside the block instead. The unknown type forces you to be explicit about what you expect.
For building the client that throws these typed errors, see build a typed fetch wrapper. For adding retry logic on server errors, see retry failed API requests with TypeScript. For validating response data at runtime, see runtime validation in TypeScript.
Rune AI
Key Insights
- Caught errors are typed as unknown in strict mode. Narrow with instanceof.
- Create custom error classes to attach HTTP status codes and response bodies.
- Use discriminated result types to force callers to handle errors explicitly.
- Narrow errors by status code ranges to respond differently to 4xx vs 5xx.
- Never assume error.message exists without narrowing first.
Frequently Asked Questions
Why is the error in catch typed as unknown?
How do I handle different HTTP status codes in a typed way?
Should I use try/catch or .catch() for API error handling in TypeScript?
Conclusion
Handling API errors in TypeScript means treating the caught error as unknown, narrowing it based on what you know about the API, and giving callers typed information about what went wrong. Whether you throw typed errors, return discriminated results, or combine both patterns, the compiler ensures the error path is as type-safe as the success path.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.