Typing async code in TypeScript doesn't have to be a battle. Learn how to handle promises, API responses, and error states without fighting the compiler.

We’ve all been there: staring at a red squiggly line in VS Code because the compiler can’t figure out what your fetch call is returning. You’re just trying to map over some data, but TypeScript is convinced your response is unknown, and suddenly your morning is gone.
I remember refactoring a legacy dashboard last year where I spent about four hours just wrestling with Promise<any> definitions. It was a mess. Once I learned how to properly define return types and leverage the compiler instead of fighting it, the code didn't just get safer—it got significantly easier to read.
The primary reason we struggle is that JavaScript’s async/await syntax makes everything look synchronous, but the underlying types are deeply nested. When you write const data = await fetchData(), the compiler has to track the Promise wrapper, the generic type inside it, and the potential for a rejected state.
We often reach for any as a quick escape hatch. Don’t do it. When you use any, you lose the benefits of TypeScript narrowing: How to make the compiler trust your code, which is essentially the superpower that makes TS worth using in the first place.
Early on, I used to do this:
TYPESCRIPTasync function getUser(id: string): Promise<any> { const response = await fetch(CE9178">`/api/users/${id}`); return response.json(); }
The problem here is that getUser promises something, but the rest of my application has no idea what that something is. I’m essentially lying to the compiler. When I try to access user.email, I’m flying blind.
Instead of hiding the shape of your data, bring it into the light. If you’re working with an API, define an interface that matches the expected response.
TYPESCRIPTinterface User { id: string; email: string; role: CE9178">'admin' | CE9178">'user'; } async function getUser(id: string): Promise<User> { const response = await fetch(CE9178">`/api/users/${id}`); if (!response.ok) { throw new Error(CE9178">'Failed to fetch user'); } return response.json() as Promise<User>; }
By explicitly typing the return, you’ve given the compiler a map. Now, when you call getUser, the compiler knows exactly what properties are available. If you try to access user.username, you’ll get a helpful error message instead of a runtime undefined.

One thing that still catches me off guard is how to type errors. In TypeScript 4.4 and later, catch blocks default to unknown. This forces you to handle the error type safely.
TYPESCRIPTtry { const user = await getUser(CE9178">'123'); } catch (err) { if (err instanceof Error) { console.error(err.message); } else { console.error(CE9178">'An unexpected error occurred'); } }
This pattern is non-negotiable. If you try to access err.message without that instanceof check, the compiler will stop you. It’s annoying in the moment, but it’s saved me from countless production crashes where a non-Error object was thrown.
Sometimes, your async function might return different states—like a success object or an error state. Instead of just throwing, you can return a result object. This is where Discriminated unions in TypeScript: Modeling state without bugs really shines.
TYPESCRIPTtype Result<T> = | { success: true; data: T } | { success: false; error: string }; async function safeFetch<T>(url: string): Promise<Result<T>> { try { const res = await fetch(url); const data = await res.json(); return { success: true, data }; } catch (e) { return { success: false, error: CE9178">'Request failed' }; } }
Now, the consumer of safeFetch is forced to check the success property. The compiler will literally refuse to let you touch the data unless you’ve verified that success is true.
Q: Should I use async/await or .then()?
A: Use async/await whenever possible. It keeps your code flat and makes error handling via try/catch much more intuitive.
Q: What if the API response is unpredictable? A: Use a validation library like Zod. You can parse the data at the edge of your application to ensure it matches your interface before it propagates through your code.
Q: Does typing everything slow down development? A: In the short term, yes. You’ll spend an extra 2-3 minutes defining types. In the long term, it saves you hours of debugging "cannot read property of undefined" errors.

Typing async code in TypeScript is about setting boundaries. You’re telling the compiler, "This is what I expect, and this is how I handle it when things go wrong."
I still occasionally get frustrated when a complex API response takes longer to type than to write, but I've realized that the time spent typing is just the cost of doing business. If I had to do it all over again, I’d stop trying to be clever with any much sooner. Just define the interface, handle the errors, and let the compiler work for you.
Generics in TypeScript can feel like an academic hurdle, but they pay off when you use them to enforce type safety in API calls and reusable components.
Read more