Chapter 38: TypeScript Error Handling
Error handling in TypeScript very calmly, step by step, like we’re sitting together with VS Code open, slowly writing examples, debugging together, and really understanding how TypeScript helps (and sometimes forces) us to handle errors better than plain JavaScript.
Error handling is one of the areas where TypeScript shines the most — especially in 2025–2026 when strict mode + good patterns are standard in serious projects.
1. The core reality in TypeScript (most important sentence)
TypeScript does NOT change how errors are thrown or caught at runtime (everything is still normal JavaScript throw / try…catch)
What TypeScript does change is:
- How well you can describe what kind of errors might be thrown
- How strictly you are forced to deal with possible failures
- How much safety you get around null / undefined / unexpected shapes
- How cleanly you can model success vs failure in return types
2. Three main kinds of errors you handle in TypeScript projects
| Type of error | Thrown by whom? | How TypeScript helps most | Typical 2026 pattern |
|---|---|---|---|
| Runtime exceptions | Your code, libraries, network, JSON.parse | try…catch + custom error classes | Throw AppError subclasses |
| Expected failures (API, DB…) | External systems | Union return types (Result<T>) or T | null | Result<T> or ApiResult<T> |
| Developer mistakes (null, wrong type) | Your own logic | strictNullChecks, noImplicitAny, narrowing | Narrowing + non-null assertions sparingly |
3. Classic try…catch — what changes with TypeScript
Plain JavaScript:
|
0 1 2 3 4 5 6 7 8 9 10 |
try { // something } catch (err) { console.log(err.message); // err is any → no safety } |
TypeScript (strict mode):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
try { // something } catch (err) { // err is unknown by default (huge safety improvement!) if (err instanceof Error) { console.log(err.message); // now string } else if (typeof err === "string") { console.log(err); } else { console.log("Unknown error", err); } } |
Modern 2026 best practice — almost never use any for caught errors
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function safeDivide(a: number, b: number): number { try { if (b === 0) throw new Error("Division by zero"); return a / b; } catch (err: unknown) { if (err instanceof Error) { throw new AppError("Math error", { cause: err }); } throw new AppError("Unexpected error"); } } |
4. Custom error classes — very common pattern in 2025–2026
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
class AppError extends Error { constructor( message: string, public code?: string, public status?: number, public cause?: unknown ) { super(message); this.name = "AppError"; } } class ValidationError extends AppError { constructor(message: string, public fields: Record<string, string>) { super(message, "VALIDATION_ERROR", 400); } } class NotFoundError extends AppError { constructor(resource: string) { super(`{resource} not found`, "NOT_FOUND", 404); } } |
Usage:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 |
async function getUser(id: number): Promise<User> { const user = await db.findUser(id); if (!user) { throw new NotFoundError("User"); } return user; } |
Now when you catch → you can narrow very cleanly:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
try { const user = await getUser(123); } catch (err) { if (err instanceof NotFoundError) { // err.status → 404 // err.code → "NOT_FOUND" } else if (err instanceof ValidationError) { // err.fields → Record<string, string> } else if (err instanceof AppError) { // generic app error } else { // unknown / third-party error } } |
5. The most popular modern pattern: Result / Either style (very common in 2026)
Instead of throwing → return a union that represents success or failure.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
type Result<T, E = AppError> = | { success: true; data: T } | { success: false; error: E }; async function fetchUserSafe(id: number): Promise<Result<User>> { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) { if (res.status === 404) { return { success: false, error: new NotFoundError("User") }; } throw new Error(`HTTP {res.status}`); } const data = await res.json() as User; return { success: true, data }; } catch (err) { return { success: false, error: err instanceof AppError ? err : new AppError("Network failure") }; } } // Usage — very clean & type-safe const result = await fetchUserSafe(123); if (result.success) { console.log("User:", result.data.name); } else { console.error("Failed:", result.error.message); if (result.error instanceof NotFoundError) { // special 404 UI } } |
Why many teams love this in 2026:
- No unhandled rejections
- Errors are values → easier to compose/map/filter
- Very explicit control flow
- Works beautifully with React Query, TanStack Query, tRPC, etc.
6. Quick cheat-sheet — error handling patterns 2026
| Pattern | Return type | Best for | Throwing? |
|---|---|---|---|
| Throw everything | Promise<T> | Internal business logic, CLI tools | Yes |
| Throw typed custom errors | Promise<T> | Most Node.js / backend APIs | Yes |
Return T |
nullorT | undefined | `Promise<T |
| Result / Either monad | Promise<Result<T>> | Public APIs, React Query, tRPC-like | No |
| Throw + global handler | Promise<T> + middleware | Express/NestJS global error middleware | Yes |
7. Your mini homework (try today)
- Create a small async function that fetches something
- Wrap it in try…catch with err: unknown
- Narrow the error with instanceof
- Then rewrite it using Result<T> style
- Compare which feels cleaner for your use-case
Which style feels most natural for your current project?
Want to go deeper into:
- Global error boundaries in Express / NestJS
- Typing React Query / SWR errors
- Custom error classes with cause & metadata
- Handling Promise.allSettled / Promise.all with types
- Common anti-patterns still seen in 2026
Just tell me — we’ll zoom right into that next! 😄
