Actually, I spent three hours last Tuesday debugging a production crash that shouldn’t have happened. The stack trace pointed to a property access on undefined inside a function that was explicitly typed to return a User object. TypeScript said it was safe. The compiler was happy. My IDE had zero squigglies.
But the runtime reality was a disaster.
Here’s the thing about asynchronous TypeScript that nobody likes to talk about: Promise<T> is a lie. It tells you what happens if everything goes right, but it stays completely silent about the fifty different ways things can go wrong. And if you’re writing async code in 2026 the same way you did in 2020, you’re probably leaving a massive blind spot in your type safety.
The Try/Catch Black Hole
The default way we handle async errors in JavaScript—and by extension, TypeScript—is fundamentally broken for type safety. We wrap everything in a try/catch block and hope for the best. But look at what happens to your types the moment you enter that catch block.
async function getUserData(id: string): Promise<User> {
try {
const response = await api.get(/users/${id});
return response.data;
} catch (error) {
// What is error?
// TypeScript 4.0+ says 'unknown' or 'any'.
// You have ZERO type safety here.
console.error(error);
throw error; // We just re-threw an untyped grenade up the stack
}
}
I see this pattern everywhere. Developers assume that because the function signature says Promise<User>, the caller will handle the error. But the caller just sees the success type. Unless you diligently read the JSDoc (who does that?) or the implementation details, you have no idea what errors to expect.
I got sick of this ambiguity. In my recent projects running on Node 23.4, I probably stopped throwing errors entirely for expected failure states. Instead, I stole a page from Go and Rust.
The “Result” Pattern
Instead of returning a value or throwing an exception, I force the return type to acknowledge failure. It makes the “sad path” a first-class citizen in your code, not an afterthought buried in a catch block.
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function safeAwait<T, E = Error>(
promise: Promise<T>
): Promise<Result<T, E>> {
try {
const data = await promise;
return { success: true, data };
} catch (error) {
return { success: false, error: error as E };
}
}
This changes everything. When you use this, you literally cannot access the data without checking if the operation succeeded first. TypeScript’s control flow analysis won’t let you.
const result = await safeAwait(fetchUser(id));
if (!result.success) {
// TypeScript knows this is the error path
handleError(result.error);
return;
}
// TypeScript knows result.data exists here
console.log(result.data.name);
It’s verbose? Maybe a little. But it’s explicit. I’d rather type three extra lines of code than wake up at 4 AM because an unhandled promise rejection crashed the server.
You Can’t Trust the API
Another massive lie we tell ourselves is casting API responses. You know the drill:
const response = await fetch('/api/config');
const config = await response.json() as AppConfig; // DANGER
That as AppConfig is you looking TypeScript in the eye and saying, “Trust me, I know the future.” You don’t. APIs change. Backends deploy breaking changes without updating the frontend. Networks fail in weird ways returning HTML error pages instead of JSON.
If you aren’t validating your async data at runtime, your types are just documentation, not protection. And I’ve been burned by this enough times that I now refuse to merge PRs that manually cast fetch results.
I use Zod for this. It adds a tiny bit of runtime overhead (benchmarked at roughly 2ms for a 5kb object on my M2 MacBook), but the peace of mind is worth it.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
settings: z.object({
theme: z.enum(['light', 'dark'])
}).optional()
});
async function fetchUserSafe(id: string) {
const res = await fetch(/users/${id});
const raw = await res.json();
// This throws if the API returns garbage
// It ensures runtime reality matches compile-time types
return UserSchema.parse(raw);
}
Async in the DOM: The Timing Nightmare
It’s not just data fetching. Async logic in the DOM is where things get really messy, especially when you’re dealing with third-party scripts or legacy code that injects elements dynamically. You try to select an element, it’s null, your script crashes.
setTimeout is the amateur way to fix this. “I’ll just wait 500ms” is code for “I hope the user’s internet isn’t slow today.”
But a better approach I use is wrapping MutationObserver in a Promise. It pauses execution until the element actually exists in the DOM. This is incredibly useful for writing resilient UI scripts or testing harnesses.
function waitForElement<T extends Element>(
selector: string,
parent = document.body
): Promise<T> {
return new Promise((resolve) => {
if (parent.querySelector(selector)) {
return resolve(parent.querySelector(selector) as T);
}
const observer = new MutationObserver(() => {
const element = parent.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element as T);
}
});
observer.observe(parent, {
childList: true,
subtree: true
});
});
}
// Usage
async function initWidget() {
// No more "cannot read property of null"
const container = await waitForElement<HTMLDivElement>('#dynamic-widget');
container.style.display = 'block';
}
Why This Matters Now
We are seeing a shift. With tools like Deno and Bun maturing (I’ve been testing Deno 2.1 recently and it’s impressive), the boundaries of where and how we run TypeScript are expanding. But the core language features regarding async/await haven’t fundamentally changed handling error types yet.
I ran a quick test on a project with about 50 API endpoints. Converting them from standard try/catch to the Result pattern took me two days of refactoring. It was boring work. But in the three weeks since deploying that update, our error logging service has gone quiet. Not because we’re swallowing errors, but because we’re finally handling them explicitly instead of letting them bubble up to a global crash handler.
Stop trusting Promise<T>. It’s an optimist. And in software development, optimists write buggy code. Be a pessimist. assume failure, type the failure, and validate everything that comes over the wire.
