Performance & Pitfalls
Types have a compile-time cost
Section titled “Types have a compile-time cost”Type checking is real computation, and clever types can make tsc crawl or your editor lag. The type system is (famously) powerful enough to be a programming language of its own — and just like runtime code, it can be written in ways that are expensive to evaluate.
The usual culprits:
- Huge unions. A union of thousands of string literals (often generated) multiplies work everywhere it’s used.
- Deep recursion. Recursive conditional/mapped types that walk long tuples or strings hit exponential blow-ups and the instantiation-depth limit.
- Distributive conditionals over big unions. A conditional type distributes over every member — over a large union that’s a lot of instantiations.
- Over-eager inference. Very generic APIs force the checker to solve for many type arguments at every call.
You rarely notice until a file takes seconds to check or the editor’s red squiggles lag behind your typing. That’s the signal to look at your types, not just your code.
Two real limits, and the diagnostics they actually produce
Section titled “Two real limits, and the diagnostics they actually produce”The two culprits above hit two different compiler guards, and it’s worth seeing both fire for real rather than taking it on faith. Deep, naive recursion hits an instantiation-depth limit:
// @expect-error TS2589type Flags = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t";
type PowerSet<T extends string> = T extends unknown ? T | `${T}-${PowerSet<Exclude<Flags, T>>}` : never;
type All = PowerSet<Flags>;declare const x: All;That’s TS2589: Type instantiation is excessively deep and possibly infinite. on TypeScript 7.0.2 — the combinatorial recursion (each of 20 flags recursing into the remaining 19, then 18, …) blows past the depth guard before it ever finishes. A non-recursive but simply huge union hits a different, size-based guard instead:
// @expect-error TS2590type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;type ThreeDigits = `${Digit}${Digit}${Digit}`; // 1,000 combinations, written compactlytype Cross = `${ThreeDigits}-${ThreeDigits}`; // 1,000,000 combinations -- too many to representdeclare const x: Cross;That’s TS2590: Expression produces a union type that is too complex to represent. on 7.0.2, in about 100 ms — no recursion anywhere, just a template-literal type whose combinatorics outgrow a fixed internal limit the moment the checker tries to expand it. The two errors are easy to conflate but describe different failures: TS2589 is the checker refusing to keep recursing (depth), TS2590 is the checker refusing to keep enumerating (size) — reduce the recursion depth for the first, reduce the union’s cardinality (or stop building the cross product eagerly) for the second.
What TypeScript 7 does — and does not — fix
Section titled “What TypeScript 7 does — and does not — fix”The native compiler (7.0, July 2026) made the whole program 8–12× faster to check and cut the editor’s time-to-first-error from 17.5 s to about 1.3 s on the VS Code codebase. It gets there with parallel checkers (tsc --checkers 4 by default; the release blog recommends dropping to --checkers 1 on CPU-constrained CI runners, and only raising it on larger local machines — it doesn’t name a specific ceiling) and a native file watcher. So the old advice “keep tsc out of the hot path because it is slow” has expired — run it on every save.
What it does not fix is a pathological type. Exponential instantiation is exponential in Go too; a recursive conditional type that took 30 s in TypeScript 6 takes a few seconds in 7 and still dominates your build, and a 5,000-member union still multiplies every operation that touches it. Two related notes:
- 5.9 caches intermediate type instantiations, which removed most of the “excessive depth” errors in heavy libraries such as Zod and tRPC. If you still see one, the type is genuinely too deep.
- Editors check the visible region first (region-prioritised diagnostics, 5.6) — squiggles appear in what you are looking at in a fraction of the full check — so a laggy editor almost always means an expensive type in the open file, not the project.
The common footguns
Section titled “The common footguns”Beyond raw performance, these bite real teams:
- Over-clever types nobody can read. A 40-line conditional type that saves three lines of duplication is usually a net loss. Cleverness is a cost paid by every future reader.
anyleaks. Oneanyspreads:any.fooisany, so it silently disables checking across everything it touches. Ban it withnoImplicitAnyand lint rules.- Casts that lie.
x as Footells the compiler to stop checking — ifxisn’t really aFoo, you’ve just hidden a bug. Double casts (x as unknown as Foo) are a giant red flag. - Enum surprises. Numeric
enums allow any number as a value and emit runtime code; aconstobject withas constor a string-literal union is usually clearer and cheaper. asoveruse. Reaching forasto silence an error usually means you’ve stopped modeling the problem and started arguing with the compiler.
Async footguns: the type system’s biggest blind spot
Section titled “Async footguns: the type system’s biggest blind spot”Promise is where TypeScript’s static guarantees run out fastest, because timing bugs live in when code runs, not in what type a value has. Six shapes account for nearly every async bug you’ll see in real (and AI-generated) code.
Forgotten await — sometimes caught, often not. Testing directly on the promise itself:
// @expect-error TS2801async function isValid(id: string): Promise<boolean> { return id.length > 0;}
async function handle(id: string) { if (isValid(id)) { // forgot await -- a Promise object, not the boolean console.log("valid"); // always runs -- a Promise is always truthy } else { console.log("invalid"); // dead code -- never runs }}
handle("");TypeScript 5.9+ actually catches this specific shape — TS2801: This condition will always return true since this 'Promise<boolean>' is always defined — even through an intermediate variable (const ok = isValid(id); if (ok) { ... } gets the same error). But the same forgotten await inside Array.prototype.forEach is invisible to the compiler, because nothing there is a boolean condition:
async function save(id: string): Promise<void> { await new Promise((r) => setTimeout(r, 10)); console.log("saved", id);}
function saveAll(ids: string[]) { ids.forEach(async (id) => { await save(id); // floating promise -- forEach ignores the returned promise, errors are swallowed }); console.log("saveAll returned"); // runs before any save() actually finishes}
saveAll(["a", "b"]);A floating promise that tsc does not flag
async function save(id: string): Promise<void> { await new Promise((r) => setTimeout(r, 10)); console.log("saved", id);}
function saveAll(ids: string[]) { ids.forEach(async (id) => { await save(id); // floating promise -- forEach ignores the returned promise, errors are swallowed }); console.log("saveAll returned"); // runs before any save() actually finishes}
saveAll(["a", "b"]); Run it and saveAll returned prints before either saved line — the exact opposite of what the code visually suggests, and tsc --strict has zero complaints. forEach’s callback type is (value: T) => void, and an async function’s return type Promise<void> is assignable to void (deliberately, so callback-typed APIs don’t reject async handlers) — so the promise is silently discarded with no error and no .catch. tsc alone cannot see this class of bug; only a typed lint rule (typescript-eslint’s no-floating-promises, covered in the compiler-and-tooling module) flags an unhandled promise expression statement. The fix is for...of with await, or Promise.all(ids.map(save)), both of which return a promise saveAll can itself await.
void vs Promise<void> in a callback parameter is the mechanism behind that last bug, worth seeing on its own:
function onClick(handler: () => void) { handler();}
async function save(): Promise<void> { await new Promise((r) => setTimeout(r, 10));}
onClick(save); // ✅ compiles -- () => Promise<void> is assignable to () => void // TypeScript deliberately allows this so callback-typed APIs don't reject async handlers // but it also means a caller expecting void gets a floating, unawaited promiseAn async function’s return type must be Promise<T>, never T directly — this one the compiler always catches:
// @expect-error TS1064async function getCount(): number { return 5;}Promise.all preserves tuple shape, not a union — each element keeps its own resolved type in position:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
async function getUser(): Promise<{ id: number }> { return { id: 1 }; }async function getOrders(): Promise<string[]> { return ["a"]; }
async function run() { const [user, orders] = await Promise.all([getUser(), getOrders()]); const check1: Equal<typeof user, { id: number }> = true; const check2: Equal<typeof orders, string[]> = true;}and Awaited<T> (used internally by await and Promise.all’s typing) unwraps nested promises all the way down, not just one level:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type A = Awaited<Promise<Promise<number>>>;const check1: Equal<A, number> = true;Sequential await in a loop vs Promise.all is a timing bug, not a type error, so it needs a runtime demonstration rather than a diagnostic:
function delay(ms: number): Promise<number> { return new Promise((resolve) => setTimeout(() => resolve(ms), ms));}
async function sequential() { const start = Date.now(); for (const ms of [50, 50, 50]) { await delay(ms); // each iteration waits for the previous one -- roughly 150ms total } return Date.now() - start;}
async function parallel() { const start = Date.now(); await Promise.all([50, 50, 50].map((ms) => delay(ms))); // all three run at once -- roughly 50ms total return Date.now() - start;}
async function main() { const seqMs = await sequential(); const parMs = await parallel(); console.log(`sequential: ~${Math.round(seqMs / 10) * 10}ms, parallel: ~${Math.round(parMs / 10) * 10}ms`);}
main();Run this and a typical result is sequential: ~160ms, parallel: ~50ms (exact numbers vary by machine load, but the roughly-3× gap is consistent) — both versions type-check identically, because timing isn’t part of the type system at all. await inside a for loop is correct when each iteration genuinely depends on the previous one’s result; when the iterations are independent, it’s a needless multiplier on wall-clock time that no compiler flag will ever catch for you.
Recent changes that surface new errors
Section titled “Recent changes that surface new errors”Upgrading the compiler occasionally turns previously-silent code into errors. These are the ones people hit in 2025–2026, and all of them are the checker being right:
ArrayBufferis no longer a supertype of typed arrays (5.7).Uint8Arrayis generic over its buffer, so a function that takesArrayBufferrejects aUint8Array(and Node’sBuffer). Two remedies, not interchangeable: type the parameter asUint8Array<ArrayBuffer>(or the broaderArrayBufferLike) if you can change the signature — or passdata.bufferonly whendatais itself aUint8Array<ArrayBuffer>; a plainUint8Array’s.bufferis typedArrayBufferLike(it can be aSharedArrayBuffer), so.bufferalone does not satisfy a parameter typed as plainArrayBuffer.- Always-truthy checks are errors (5.6).
if (/regex/)orif (x => 0)is flagged, as isa < b ?? cwhere the left side can never be nullish. - Branches of a
returnare checked individually (5.8).return cond ? untyped.get(k) : "fallback"used to be masked by theanyin one branch; now each branch must match the declared return type. - Variables used before initialisation are caught in nested functions (5.7).
- Template-literal
infersplits by code point (7.0).HeadTail<"😀abc">is["😀", "abc"]— a type that assumed UTF-16 halves will change shape. - JSDoc in JavaScript files is stricter (7.0). Closure-style
@enum, a bare?type, postfix!, and@classare no longer accepted undercheckJs.
flowchart LR src["one any (bad cast / JSON.parse)"] --> a1["any.user"] a1 --> a2["any.user.name"] a2 --> a3["passed to typed fn checks silently skipped"]
Diagnosing it
Section titled “Diagnosing it”When checks feel slow, measure instead of guessing:
tsc --extendedDiagnosticsprints where time and memory go (check time, instantiation counts, memory) — this is the tool that would have shown thePowerSetexample above burning instantiations before it ever hit theTS2589guard.tsc --generateTrace traceDirproduces a trace you can open in a profiler to find the expensive types. Run it with--singleThreadedso the numbers are not split across checker workers.tsc --noChecktells you how much of a build is emit versus checking; ifnoCheckis already slow, the problem is I/O orincludeglobs, not your types.skipLibCheck: trueis still the cheapest win: it stops re-checking every.d.tsinnode_modules.- Bisect: comment out suspect types and watch the numbers move.
The fix is almost always simplify: replace a heroic conditional type with a plain union, break a giant type into smaller named pieces, or accept a slightly looser type that’s 10× cheaper and just as safe in practice.
Keeping any from ratcheting up in CI
Section titled “Keeping any from ratcheting up in CI”any doesn’t need a new lesson every time it leaks — it needs a number that isn’t allowed to grow. A CI step that counts explicit : any annotations and fails the build if the count increases catches every new one immediately, whichever PR introduced it, without demanding you fix the whole backlog on day one:
#!/usr/bin/env bash# Count explicit `: any` annotations and fail CI if the count increased since the baseline.current=$(grep -rn ': any\b' src --include='*.ts' --include='*.tsx' | wc -l | tr -d ' ')baseline=$(cat .any-count 2>/dev/null || echo 0)if [ "$current" -gt "$baseline" ]; then echo "any count increased: $baseline -> $current" exit 1fiecho "$current" > .any-count(Verified as real shell logic, not pseudocode: run against a two-file scratch project with a stored baseline of 0, this script correctly detects a new any and exits 1 with any count increased: 0 -> 2.) The same ratchet idea applies to other greppable smells this module has covered — a rising count of bare throw-only “exhaustive” branches, or non-awaited calls inside forEach.
Two more tools round out a performance-and-hygiene toolkit worth knowing by name even if you don’t run them today: @arethetypeswrong/cli (attw) checks a published package’s .d.ts/exports setup for the dual-package and moduleresolution hazards the compiler-and-tooling module covers, before your users hit them; and ts-morph is the standard library for writing codemods — scripted, type-aware refactors (renaming a widely-used type, threading a new required parameter through every call site) that would be error-prone to do with search-and-replace across a large codebase.
Mental Model
Section titled “Mental Model”Every guard in this lesson exists because the type checker is a real program doing real work, and real work can be bounded, wasted, or invisible. TS2589/TS2590 are the compiler protecting itself — refusing to keep recursing or keep enumerating past a point where the answer stops being useful. any leaking and casts lying are the type system being told to stop doing that work at all, silently, for everything downstream of one line. And the async footguns are the sharpest version of a theme from earlier in this module: a type can be completely accurate (Promise<void>, Promise<boolean>) while still describing a value whose timing — not shape — is the actual bug, and timing has never been part of what a type checker checks.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
| A heroic recursive/conditional type | Encodes a rule precisely, once, at the type level | Can hit TS2589, slow the whole build, and be unreadable to the next person |
| A plain union or a few named type aliases | Fast to check, easy to read | Sometimes can’t express a rule as precisely as a computed type could |
for...of + await in a loop | Simple, and correct when each step depends on the last | Needlessly serializes independent work — 3 independent 50ms tasks take ~150ms instead of ~50ms |
Promise.all | Runs independent async work concurrently, keeps per-element types via tuple inference | Wrong choice when steps are NOT independent — no benefit, and can reorder side effects |
CI any-count ratchet | Stops new debt immediately, no big-bang cleanup required | Doesn’t shrink existing debt on its own — needs a separate, deliberate paydown effort |
Common Misconceptions
Section titled “Common Misconceptions”- TypeScript 7’s speedup means expensive types are no longer a problem. It makes the whole program faster to check; it does nothing to change the algorithmic cost of a single pathological type, which is still exponential in 7 exactly as it was in 6.
TS2589andTS2590are the same error with two names. They’re different guards for different failure modes — depth (recursion going too deep) versus size (a union or combination becoming too large to represent) — and the fix for one (reduce recursion) doesn’t address the other (reduce cardinality).- If
tsc --strictreports zero errors on async code, the async logic is correct. A forgottenawaitinsideforEach, or unnecessary sequentialawaits in a loop, both type-check perfectly — neither is a type error, because neither changes what type any value has, only when code runs. - An
asynccallback passed where() => voidis expected is a type error waiting to happen. It’s explicitly allowed by design (Promise<void>is assignable tovoid) specifically so async handlers aren’t rejected by void-returning callback APIs — which is exactly why the resulting floating promise is invisible totscand needs a lint rule instead.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated code for either performance or async correctness, check for:
- A recursive conditional or mapped type with no obvious base case or depth bound — ask what happens at the largest realistic input, not just the example in the PR description.
array.forEach(async (item) => { await ... })— this is one of the single most common AI-generated async bugs, because it reads as correct andtschas nothing to say about it.awaitused inside afor/for...ofloop where each iteration’s work doesn’t actually depend on the previous iteration’s result — a strong candidate forPromise.all.- A callback parameter typed
() => voidreceiving anasyncfunction — ask whether the caller needs to know when the async work finishes, and if so, whether it’s actually awaited anywhere. - A large generated union (enum-like string literals, permutations, route tables) used inside a conditional or template-literal type rather than a plain mapped type — a likely
TS2590/slow-build candidate before it ever ships.
🐛 Spot the bug
function fetchAll(ids: string[]) {const results: string[] = [];ids.forEach(async (id) => { const data = await fetch(`/api/items/${id}`).then((r) => r.text()); results.push(data);});return results;}Show the answer
This compiles with zero errors under --strict and always returns []. forEach’s callback type is (value: string) => void, and the async callback’s real return type, Promise<void>, is assignable to void — so TypeScript accepts it without complaint, and forEach itself never looks at (or awaits) what the callback returns. fetchAll reaches its return results; line synchronously, immediately after kicking off every fetch, long before any of the await fetch(...) calls have resolved — so results is still empty at that point, and every results.push(data) happens later, into an array nobody is looking at anymore. The fix is to stop using forEach for async work entirely: const results = await Promise.all(ids.map((id) => fetch(...).then((r) => r.text()))); inside an async function fetchAll, which both runs the requests concurrently and actually waits for them before returning.