Why I Love TypeScript: Async as an Honesty Test
I love TypeScript for an unromantic reason: it forces me to admit what my code actually returns. And nowhere does that hurt more than in async code.
Promise<T> is the most honest type in the language
Promise<User> doesn’t say “a user”. It says “a user, eventually, if nothing blows up”. That word —eventually— is the one most code ignores until a flaky test brings it up.
// What people think they wrote
const user = getUser(id);
// What they actually wrote
const user: Promise<User> = getUser(id);
TypeScript doesn’t save you from concurrency, but it stops you from lying to yourself about it. Without types, user.name is undefined at runtime. With types, it’s a red error before the commit. The difference between the two is about six hours of your life on a Thursday.
async/await isn’t parallelism, it’s sequential sugar
The most expensive mistake I see in reviews:
const profile = await getProfile(id); // 300ms
const permissions = await getPermissions(id); // 300ms
const feature = await getFlags(id); // 300ms
// 900ms, and none of them depends on the others
Versus:
const [profile, permissions, flags] = await Promise.all([
getProfile(id),
getPermissions(id),
getFlags(id),
]);
// 300ms, and the tuple type is inferred on its own
Promise.all types the tuple position by position. Change the order and TypeScript complains. It’s the kind of boring detail that prevents a production bug no retro would have explained well.
And when one of the three can fail without dragging the rest down, there’s Promise.allSettled. It returns { status: 'fulfilled' | 'rejected' } and forces you to decide what you do with partial failure instead of letting one giant catch swallow it all.
The hole TypeScript doesn’t cover: catch (e: unknown)
This is where the language is honest to the point of discomfort. Anyone can throw "something", so the type of the error is unknown and there’s nothing TypeScript can do about it.
try {
await pay(order);
} catch (e: unknown) {
if (e instanceof PaymentDeclined) return retry(order);
throw e; // I don't know what this is, I'm not going to invent it
}
That last line is the lesson: propagating what you don’t understand is better than logging it and carrying on as if nothing happened. The catch that swallows everything is the code equivalent of nodding along in a meeting you lost track of ten minutes ago.
Cancellation: what almost nobody types
AbortController has been around for years and is still the least used pattern in the ecosystem. A user typing fast in a search box fires eight requests; without cancellation, the response that paints the screen is the one that arrived last, not the one that matches what’s written. That’s a race condition, not “weird browser behavior”.
function search(q: string, signal: AbortSignal): Promise<Result[]> {
return fetch(`/api?q=${q}`, { signal }).then((r) => r.json());
}
Putting signal in the signature turns an optional convention into a requirement of the type. Whoever calls that function has to think about cancelling. That’s design, not bureaucracy.
Why this matters more than elegant generics
TypeScript is sold on conditional types and type-level magic, and that part is fun for conference talks. The real value is more pedestrian: it forces you to name time. What’s ready now, what will be ready later, what can fail in between.
Async code without types isn’t faster to write. It just postpones the conversation with reality until reality has users.