In the previous tutorial, we learned about template literal types. Now let’s learn about error handling — one of the most important topics in any real application.
By the end of this tutorial, you will know how to safely narrow the unknown error in catch blocks, create custom error classes, implement the Result type pattern, and follow best practices for error handling in TypeScript.
Why catch Gives You unknown, Not a Typed Value
You cannot write catch (error: SomeType) in TypeScript. There is no such syntax. The language does not allow you to annotate the catch parameter with a specific type.
Why? Because JavaScript can throw anything — a string, a number, an object, or an Error. TypeScript has no way to guarantee what the thrown value actually is.
In JavaScript (and older TypeScript), catch gave you any. TypeScript changed this in version 4.4 with the useUnknownInCatchVariables option (enabled by default in strict mode) — the catch parameter is now unknown:
try {
const data = JSON.parse(input);
} catch (error) {
// error is 'unknown' — you must check before using it
console.log(error.message); // Error: 'error' is of type 'unknown'
}
This is safer. unknown forces you to check the type before you use the value.
Narrowing the Error Type
To use the error, you must narrow it with instanceof or a type guard:
try {
const data = JSON.parse(input);
} catch (error) {
if (error instanceof Error) {
// TypeScript knows error is an Error here
console.log(error.message); // Safe
console.log(error.stack); // Stack trace available
} else if (typeof error === "string") {
// Someone threw a string
console.log(error);
} else {
console.log("Unknown error:", String(error));
}
}
There are no “typed catch blocks” in TypeScript. The only way to work with a typed error is to narrow unknown yourself.
Handling Unknown Errors
Always check the type of the error before using it:
try {
const data = JSON.parse(input);
} catch (error) {
if (error instanceof Error) {
console.log(error.message); // Safe — we know it's an Error
console.log(error.stack); // Stack trace available
} else {
console.log("Unknown error:", String(error));
}
}
Helper Function for Errors
Create a helper to extract error messages safely:
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "An unknown error occurred";
}
try {
riskyOperation();
} catch (error) {
console.log(getErrorMessage(error));
}
Custom Error Classes
Built-in Error is too generic. Custom error classes let you create specific error types:
class NotFoundError extends Error {
constructor(resource: string, id: string | number) {
super(`${resource} with id ${id} not found`);
this.name = "NotFoundError";
}
}
class ValidationError extends Error {
field: string;
constructor(field: string, message: string) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class AuthenticationError extends Error {
constructor(message = "Authentication required") {
super(message);
this.name = "AuthenticationError";
}
}
Using Custom Errors
// assume: type User = { id: number; name: string; email: string }
// assume: const database: User[] = [...]
function getUser(id: number): User {
const user = database.find(u => u.id === id);
if (!user) {
throw new NotFoundError("User", id);
}
return user;
}
function validateEmail(email: string): void {
if (!email.includes("@")) {
throw new ValidationError("email", "Email must contain @");
}
}
Catching Specific Errors
try {
const user = getUser(42);
validateEmail(user.email);
} catch (error) {
if (error instanceof NotFoundError) {
console.log("Not found:", error.message);
} else if (error instanceof ValidationError) {
console.log(`Field ${error.field}: ${error.message}`);
} else if (error instanceof AuthenticationError) {
console.log("Please log in");
} else {
console.log("Unexpected error:", getErrorMessage(error));
}
}
The Result Type Pattern
Throwing errors has a problem: the function signature does not tell you it can fail. The caller might forget to wrap it in try/catch.
The Result type pattern makes errors explicit in the return type:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Using the Result Type
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: "Cannot divide by zero" };
}
return { ok: true, value: a / b };
}
const result = divide(10, 0);
if (result.ok) {
console.log(result.value); // TypeScript knows this is number
} else {
console.log(result.error); // TypeScript knows this is string
}
Result Type with Custom Errors
type AppError =
| { type: "NOT_FOUND"; resource: string; id: string }
| { type: "VALIDATION"; field: string; message: string }
| { type: "UNAUTHORIZED" };
// assume: type User = { id: string; name: string; email: string }
// assume: const database: User[] = [...]
function getUser(id: string): Result<User, AppError> {
const user = database.find(u => u.id === id);
if (!user) {
return {
ok: false,
error: { type: "NOT_FOUND", resource: "User", id },
};
}
return { ok: true, value: user };
}
The caller must handle the error — TypeScript enforces it:
const result = getUser("123");
// TypeScript knows result might be an error
// You cannot access result.value without checking first
if (result.ok) {
console.log(result.value.name); // Safe
}
Helper Functions for Result
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Usage
function parseAge(input: string): Result<number, string> {
const age = parseInt(input, 10);
if (isNaN(age)) {
return err("Invalid number");
}
if (age < 0 || age > 150) {
return err("Age must be between 0 and 150");
}
return ok(age);
}
Async Error Handling
Async functions combine try/catch with await:
// assume: type User = { id: string; name: string; email: string }
async function fetchUser(id: string): Promise<Result<User, string>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err(`HTTP ${response.status}: ${response.statusText}`);
}
const user: User = await response.json();
return ok(user);
} catch (error) {
return err(getErrorMessage(error));
}
}
Wrapping Async Operations
Create a wrapper that catches errors automatically:
async function tryCatch<T>(
promise: Promise<T>
): Promise<Result<T, Error>> {
try {
const value = await promise;
return { ok: true, value };
} catch (error) {
if (error instanceof Error) {
return { ok: false, error };
}
return { ok: false, error: new Error(String(error)) };
}
}
// Usage
const result = await tryCatch(fetch("/api/users"));
if (result.ok) {
console.log(result.value);
} else {
console.log(result.error.message);
}
Error Handling with Discriminated Unions
Discriminated unions make error states explicit in your application state:
type ApiState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
// assume: type User = { id: string; name: string; email: string }
function renderUsers(state: ApiState<User[]>): string {
switch (state.status) {
case "idle":
return "Click to load users";
case "loading":
return "Loading...";
case "success":
return state.data.map(u => u.name).join(", ");
case "error":
return `Error: ${state.message}`;
}
}
This pattern is common in React applications and state management libraries.
Comparison with Other Languages
Go: Explicit Error Returns
Go returns errors as a second value:
user, err := getUser(id)
if err != nil {
return err
}
TypeScript’s Result type follows the same idea — errors are values, not exceptions.
Rust: Result<T, E>
Rust has a built-in Result type:
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
return Err("Cannot divide by zero".to_string());
}
Ok(a / b)
}
TypeScript’s Result pattern is inspired by Rust.
Best Practices
1. Fail Fast
Validate input early and return errors immediately:
type FieldError = { field: string; message: string };
// assume: type User = { id: string; name: string; email: string }
// assume: type CreateUserInput = { name: string; email: string }
// assume: function generateId(): string
function createUser(data: CreateUserInput): Result<User, FieldError[]> {
const errors: FieldError[] = [];
if (!data.name || data.name.length < 2) {
errors.push({ field: "name", message: "Name must be at least 2 characters" });
}
if (!data.email || !data.email.includes("@")) {
errors.push({ field: "email", message: "Invalid email" });
}
if (errors.length > 0) {
return err(errors);
}
// All validation passed — create the user
return ok({ id: generateId(), ...data });
}
2. Use Specific Error Types
Do not use generic Error for everything. Create specific error types so callers can handle each case differently.
3. Do Not Catch and Ignore
// Bad — error is silently lost
try {
await saveUser(user);
} catch {
// nothing
}
// Good — at least log it
try {
await saveUser(user);
} catch (error) {
console.error("Failed to save user:", getErrorMessage(error));
}
4. Choose One Pattern Per Layer
- Use Result types for business logic and service layers
- Use try/catch for infrastructure code (API calls, file I/O)
- Use discriminated unions for UI state management
Do not mix patterns in the same layer.
What’s Next?
In the next tutorial, we will learn about async/await and Promises — how to write type-safe asynchronous code in TypeScript.