TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
102: Handling Errors in Typed Async Code
I've spent a lot of my career staring at production logs, trying to figure out why a promise rejected and why the error message was just [object Object] or, even worse, undefined. In TypeScript, we're great at typing the "happy path"—the data we expect to get back—but we often treat the "unhappy path" as an afterthought. Let's look at why that happens and how we can actually fix it.
The "Unknown" Trap
Let's say we're building a feature to fetch a user's account balance from an API. I'll start with the way most people write this. I've got a UserBalance type, and a function that returns it.
interface UserBalance {
amount: number;
currency: string;
}
async function fetchBalance(userId: string): Promise<UserBalance> {
const response = await fetch(`/api/balance/${userId}`);
if (!response.ok) {
throw new Error("Failed to fetch balance");
}
return response.json();
}
On the surface, this looks clean. But here is where I usually run into trouble. When I actually call this function, I have to wrap it in a try/catch. Look at what happens to the type of the error:
async function displayBalance(userId: string) {
try {
const balance = await fetchBalance(userId);
console.log(`Your balance is ${balance.amount}`);
} catch (err) {
// Wait... what is 'err'?
console.error(err.message); // ❌ TypeScript Error: 'err' is of type 'unknown'
}
}
TypeScript is actually doing me a favor here. It's telling me that in JavaScript, anything can be thrown. A string, a number, a custom error object, or even null. Because err is unknown, I can't just dive in and access .message. I'm stuck.
Narrowing the Chaos
My first instinct is usually to just cast it: (err as Error).message. But that's lying to the compiler. If the API throws a string instead of an Error object, my app crashes right there in the error handler. That's a great way to make a bad situation worse.
Instead, I need to prove to TypeScript what the error is. I'll use a type guard. I've found that creating a small helper for this saves me from writing the same if block a hundred times across a project:
function isError(error: unknown): error is Error {
return error instanceof Error;
}
// Now back in the display function...
} catch (err) {
if (isError(err)) {
console.error(err.message); // ✅ TypeScript knows this is an Error
} else {
console.error("An unexpected error occurred");
}
}
This works, and it's safe. But it's tedious. Every single async call in my app now needs this boilerplate. I'm starting to feel like I'm fighting the language rather than using it.
Making Failure a First-Class Citizen
What if we stop treating errors as "exceptions" that blow up the stack and start treating them as expected return values? This is a pattern often called the Result type. Instead of a promise that either resolves to a value or rejects, we return a promise that always resolves to an object telling us if it succeeded or failed.
Let's redefine how we handle the fetch. I'll use a tuple—a fixed-length array—which is a neat trick for keeping things concise:
type AsyncResult<T> = Promise<[T, null] | [null, Error]>;
async function safeFetchBalance(userId: string): AsyncResult<UserBalance> {
try {
const response = await fetch(`/api/balance/${userId}`);
if (!response.ok) throw new Error("Network response was not ok");
const data = await response.json();
return [data, null]; // Success path
} catch (err) {
return [null, isError(err) ? err : new Error(String(err))]; // Error path
}
}
Now, look at how the consumption side changes. I don't even need a try/catch block anymore. The type system forces me to check for the error before I can touch the data:
async function displayBalance(userId: string) {
const [balance, error] = await safeFetchBalance(userId);
if (error) {
console.error(`Error: ${error.message}`);
return;
}
// Because I checked 'error' above, TypeScript knows 'balance' is NOT null here.
console.log(`Your balance is ${balance.amount}`);
}
This is a massive shift in ergonomics. I've moved the error handling from a "catch-all" bucket at the bottom of my function to a deliberate check right where the data arrives. It's more explicit, it's type-safe, and it prevents those annoying "cannot read property of null" crashes in production.
📋 Practical Task
Exercise: The Robust User Wallet Fetcher
You are tasked with creating a safe wrapper for a legacy API that fetches user wallet details. The legacy API is unpredictable and sometimes throws strings instead of Error objects.
Requirements:
- Define a
Walletinterface withid (string)andbalance (number). - Create a
SafeResult<T>type that represents either a success[T, null]or a failure[null, Error]. - Implement a function
fetchWalletSafe(userId: string)that:- Calls a mock async function
legacyApiFetch(id)(assume this exists and may throw). - Catches any error and ensures it is converted into a proper
Errorobject. - Returns the
SafeResulttuple.
- Calls a mock async function
- Implement a
processWalletPayment(userId: string)function that usesfetchWalletSafe. It must check for the error first; if no error exists, it should log the wallet balance.
Tip: Remember to use a type guard or a check to handle the case where the legacy API throws something that isn't an instance of Error.
There are no comments for now.