TypeScript 3.9: Performance and the move to ESNext
TypeScript 3.9 RC dropped on April 28, and I’ve been running it against our codebase at TaskRabbit for the past week. The headline feature is performance — and the numbers are real.
Compile-Time Improvements #
The TypeScript team has been systematically attacking compilation performance for the last few releases. In 3.9, they targeted the pathological cases: large unions, deeply nested intersections, conditional types, and mapped types. Each individual PR contributes something like 5-10% improvement in specific scenarios; the compound effect is what moves the needle.
The benchmark they highlight is material-ui-styles, where compile time dropped roughly 25%. That’s not synthetic — material-ui is one of the most widely used React component libraries, and its type definitions are notoriously complex. If you’ve ever waited 30 seconds for your editor to resolve types after importing from @material-ui/core, this is the fix you didn’t know was coming.
I ran the RC against our TypeScript project (about 180k lines across a monorepo) and saw compile times drop from ~42 seconds to ~37 seconds. Not the 25% headline, but our type complexity doesn’t match material-ui’s. Still, five seconds off a build that runs hundreds of times a day adds up.
The TypeScript 3.9 iteration plan on GitHub lists the specific PRs if you want the details. Most of the gains come from smarter caching in type relationship checks and reducing redundant work in constraint resolution — the kind of stuff that’s boring to explain but satisfying when it quietly makes your day faster.
Promise.all and Promise.race Fixes #
TypeScript 3.7 introduced improvements to Promise.all type inference but also introduced regressions that made certain patterns fail. If you’ve written code like this and gotten unexpected type errors since 3.7, you’re not alone:
async function fetchUserData(userId: string) {
const [user, preferences, history] = await Promise.all([
fetchUser(userId),
fetchPreferences(userId),
fetchHistory(userId)
]);
// In TS 3.7-3.8, the types here could resolve incorrectly
// when mixing Promise<T> with non-Promise values
}The issue showed up when you mixed Promise<T> values with direct values in the array, or when the array had more than a few elements. The type declarations for Promise.all and Promise.race have been rewritten in 3.9 RC to handle these edge cases correctly.
I had two places in our codebase where I’d added as casts to work around this. Removed both, no type errors. Good.
@ts-expect-error #
This one is small but I think it’ll see wide adoption. @ts-expect-error is like @ts-ignore, but it errors if the next line doesn’t have a type error. That makes it genuinely useful for tests where you’re intentionally passing bad types:
// @ts-expect-error - testing that invalid input throws
const result = processOrder({ amount: "not a number" });Here’s why it matters: if someone later fixes the types so that "not a number" becomes valid (maybe the function starts accepting string amounts), the @ts-expect-error directive flags it. With @ts-ignore, you’d silently suppress a warning that no longer exists — and you’d never know the test’s assumptions changed underneath you.
At TaskRabbit we have a handful of test files that use @ts-ignore for exactly this purpose. I think we’ll migrate those once we upgrade. It’s a small quality-of-life win, but those are the ones that compound over time.
Uncalled Function Checks in Conditionals #
TypeScript 3.7 added warnings when you reference a function without calling it in if statements:
function hasPermission() { return true; }
// TS 3.7+: error - did you mean to call this?
if (hasPermission) { ... }In 3.9, this extends to ternary expressions and other conditional contexts:
// TS 3.9: now also catches this
const label = hasPermission ? "Admin" : "User";Seems minor, but I’ve caught exactly this bug in code review twice in the last year. It’s the kind of thing a tired reviewer misses on a Friday afternoon; having the compiler flag it is better than relying on human attention.
Editor Improvements #
Two things worth mentioning for day-to-day development.
CommonJS auto-imports now work in JavaScript files — not just TypeScript. If you’re in a mixed codebase (we have a few legacy JS modules, more than I’d like), the editor can suggest imports from CommonJS modules without manual require() statements. Small thing; saves real keystrokes.
Second, solution-style tsconfig.json files get better support. If you’re using project references (which I think more monorepos should), the editor now handles multi-root configurations more reliably. We’ve had intermittent issues with Go to Definition jumping to .d.ts files instead of source files in our monorepo; the 3.9 RC seems to handle this better. I haven’t stress-tested it, but even the improvement in the common path is noticeable.
Should You Upgrade? #
The RC is available now (npm install typescript@rc). If you’re on 3.8, the upgrade path is smooth — I didn’t hit any breaking changes in our codebase. The performance improvements alone make it worth testing against your own project; five seconds might not sound like much, but it’s five seconds you’re not staring at a terminal.
The stable release is expected soon. My recommendation: run the RC against your test suite now so you’re not scrambling when the stable version ships.