· 16 min read
Keep the types, cut the wait
Expressive types need a cost model: local inference, shared representations, stable interfaces, and fewer repeated obligations instead of weaker guarantees.

I want the compiler to reject a mixed-up identifier, remember which protocol state I am in, and infer the boring local types. I also want the next edit to finish checking before I lose the thread. Giving up either side is a bad default.
It is tempting to frame this as a permanent exchange rate: more expressive types buy more correctness with more waiting. I do not buy that as a design rule. Before removing a guarantee, I want to know whether the compiler is discovering it, checking it, copying its representation, or checking the same thing for the thousandth time.
Those are different bills. A language can give us useful static facts while being much less generous about how much searching, expansion, and repeated work is allowed to obtain them. That is the direction I want programming languages to take. Compilation time has to be a feature constraint, not the bill nobody owns.
Keep the guarantee, price the work#
The July 8, 2026 TypeScript 7 release is a useful reality check. Its Go-based native port deliberately preserves much of the old compiler's structure and checking logic. Microsoft's published VS Code build comparison goes from 125.7 seconds with TypeScript 6 to 10.6 seconds with TypeScript 7 using four checker workers. Those are the team's measurements, not mine, and not a promised multiplier for your project.
The release combines native execution, parallelism, and implementation improvements. The workers can duplicate common checking work, and increasing their count can cost memory. Even so, this is a large improvement without first turning everyone's domain model into untyped dictionaries. The 7.0 release also came without a programmatic compiler API, so adoption was a tooling-compatibility question as well as a speed question.
A faster implementation is worth having. It does not tell us whether a particular expression asks for an unreasonable amount of work. I would split the investigation into these buckets before deciding that a feature is intrinsically slow.
| Work | The question to ask | A possible lever |
|---|---|---|
| Inference and search | How many alternatives must be explored? | Expected types, explicit boundaries, pruning |
| Type representation | How much distinct structure is materialized? | Sharing, compact summaries, lazy expansion |
| Repeated checking | Which obligations have already been answered? | Context-correct memoization |
| Incremental invalidation | What did this edit actually change for clients? | Stable interfaces, tracked dependencies |
| Code generation | How many bodies must the backend optimize? | Sharing and selective specialization |
That last qualification matters. Adding five profiler totals can double-count nested work; dividing CPU time by thread count does not give interactive latency. The question is which dependency chain the person editing the program is waiting for.
Give inference somewhere to stop#
I like inference most inside a function whose boundaries are already clear. The less interesting annotations disappear, while the public contract remains readable. The trouble begins when a missing fact causes the checker to keep several interpretations alive across a large expression.
Bidirectional typing, surveyed by Jana Dunfield and Neel Krishnaswami, makes the direction of information explicit. Some expressions synthesize a type from their contents; others are checked against an expected type supplied by their context. This supports a useful compromise: retain rich types, but require annotations at the places where inference would otherwise become difficult. It is a way to organize checking, not a universal speed theorem.
Here is a small, runnable TypeScript example of the boundary I mean. The function parameter itself is polymorphic: it must work for each type chosen by its caller. The outer function can use that one parameter at two different types.
type PolyId = <A>(value: A) => A;function applyTwice(f: PolyId): readonly [number, boolean] { return [f(1), f(true)];}const identity: PolyId = value => value;applyTwice(identity);// @ts-expect-error A number-only function is not polymorphic.applyTwice((value: number) => value);The generic annotation on PolyId carries real information. A function that only handles numbers cannot satisfy the contract. Inside identity, the parameter gets its type from the expected function type. Inside applyTwice, checking each call does not require rediscovering what the supplied function promises.
This demonstrates an expressive contract and contextual information flow; it does not establish that adding this annotation makes TypeScript faster. The general design preference is mine: make difficult interfaces explicit, and preserve local inference where it is useful. A language with higher-rank functions does not need to promise annotation-free inference for every higher-rank program.
There is also implementation work to do after the boundaries are chosen. In his June 2 account of Swift 6.4 development, Slava Pestov describes eliminating impossible overload candidates rather than merely trying favored ones first. A sole surviving candidate can propagate immediately; no surviving candidates means the branch can fail immediately. Binding inference also combines constraints more precisely. These were described as development-snapshot improvements, not a claim here about a particular installed Swift release.
Imagine a solver with three decisions, each offering ten candidates. An eager combination search might explore a thousand combinations. If an expected type rules out nine candidates at the first decision, that information belongs in the search before the combinations are constructed. This is an illustrative search tree, not a Swift benchmark. Pruning helps only where constraints actually distinguish candidates; it does not make every difficult expression cheap.
Count the repeated question#
Now suppose the compiler already knows the types. It still has to compare them. This is a good place to find surprising amounts of work without changing the type system at all.
Build a type from an atom and repeated pairs. At depth one it is a pair of integers. At depth two it is a pair of those pairs. Each new node points to the same child twice, so the representation contains only one new node per level. Two calls build separately allocated but structurally equal graphs.
type Ty = | Readonly<{ kind: "atom"; name: string }> | Readonly<{ kind: "pair"; left: Ty; right: Ty }>;function atom(name: string): Ty { return Object.freeze({ kind: "atom", name });}function pair(left: Ty, right: Ty): Ty { return Object.freeze({ kind: "pair", left, right });}function tower(depth: number, name = "int"): Ty { if (!Number.isSafeInteger(depth) || depth < 0 || depth > 20) { throw new RangeError("depth must be an integer from 0 to 20"); } let current = atom(name); for (let i = 0; i < depth; i++) current = pair(current, current); return current;}Written out as a fully expanded tree, the type gets large very quickly. Stored as this acyclic graph, it stays small. Sharing storage is only half the job, though. A recursive comparison can still revisit the same two children every time an incoming edge reaches them.
The following comparator has a switch for caching already-computed pairs of nodes. The relation is deliberately tiny: atoms match by name, and pairs match component by component. There are no inference variables, aliases, recursive cycles, or subtyping rules hiding behind the example.
function compare(a: Ty, b: Ty, memoize: boolean) { const cache = new Map<Ty, Map<Ty, boolean>>(); let calls = 0; let computed = 0; function equal(x: Ty, y: Ty): boolean { calls++; const saved = memoize ? cache.get(x)?.get(y) : undefined; if (saved !== undefined) return saved; computed++; const result = x.kind === "atom" ? y.kind === "atom" && x.name === y.name : y.kind === "pair" && equal(x.left, y.left) && equal(x.right, y.right); if (memoize) { let row = cache.get(x); if (!row) { row = new Map<Ty, boolean>(); cache.set(x, row); } row.set(y, result); } return result; } const same = equal(a, b); return { same, calls, computed };}For equal towers at depth n, the uncached comparator follows both edges all the way down. Its call count is 2^(n + 1) - 1. The cached version computes n + 1 distinct obligations and makes 2n + 1 calls, counting cache hits. The second edge still makes a call; it simply does not repeat the subtree.
| Depth | Uncached calls | Cached calls | Cached obligations computed |
|---|---|---|---|
| 8 | 511 | 17 | 9 |
| 12 | 8,191 | 25 | 13 |
| 16 | 131,071 | 33 | 17 |
The accompanying experiment and tests check those formulas through depth sixteen, reject differently named leaves and differently shaped towers, and compare both algorithms against an independent expanded-description oracle on 1,444 pairs of small trees. That gives the optimization a concrete correctness check rather than just a nicer number.
The mechanism has limits. If every comparison really is different, the cache cannot manufacture reuse. It also consumes memory. Our immutable, acyclic inputs let the key be just the ordered pair of nodes. A real solver must account for the assumptions under which an answer was obtained, mutable inference state, and the relation being checked. Caching a speculative answer under the wrong environment is a correctness bug.
Rust's internal type representation already interns types, allowing identical representations to share storage and supporting cheap identity comparisons. The same documentation explicitly warns that representation identity does not settle semantic equality: inference variables and associated-type normalization can still require work. “Intern the nodes” is good engineering; “pointer comparison replaces the solver” is not.
One more trap follows from the experiment. Printing the fully expanded type at depth sixteen would still require printing its expanded contents. A checker that stores a compact graph and then expands it into a gigantic diagnostic has moved the problem to the error message. I would preserve names and shared structure through diagnostics too, with deliberate expansion when the reader asks for it.
Do not make a schema carry its entire history#
The library author has a version of this problem. A fluent API can accumulate type-level bookkeeping for every operation, even when its user only needs the current result. The runtime object might be small while the generic arguments describe its ancestry in painful detail.
Zod 4's release notes give a concrete example. For the published five-field object-extension case, the maintainers report more than 25,000 TypeScript instantiations with Zod 3 and roughly 175 with Zod 4. They attribute the reduction to a substantial simplification of object-schema generics. This is their compiler-instantiation measurement, not a reproduction here, and not evidence of an equivalent multiplier in whole-project build time.
The design question I take from that result is simple: does the public type need the current shape, or the recipe that produced it? A schema containing a name and an age need not expose a nested record of every intermediate extension unless some supported operation observes that history.
That qualification prevents a cheap but false victory. Some libraries genuinely track input versus output, optionality, transformations, or state transitions. Removing those distinctions may alter the contract. The task is to represent the required facts compactly, not to erase inconvenient facts and announce a performance improvement.
When designing a fluent library, I would keep its stress fixture beside its functional tests: repeated composition, a wide schema, a deep schema, an invalid field, and an exported result used from another package. A tiny call-site demo is not enough to reveal whether the tenth operation has made every previous one more expensive.
Some speedups change the contract#
TypeScript gives us a very small counterexample to indiscriminate simplification. Its conditional-type rules distribute a conditional over a union when the checked side is a naked type parameter. Wrapping that parameter in a tuple prevents the distribution. Those two forms do not mean the same thing.
type Each<T> = T extends unknown ? readonly T[] : never;type Together<T> = [T] extends [unknown] ? readonly T[] : never;// Each<string | number>:// readonly string[] | readonly number[]// Together<string | number>:// readonly (string | number)[]const mixed: Together<string | number> = ["a", 1];// @ts-expect-error Each requires one element kind for the array.const rejected: Each<string | number> = ["a", 1];The first form describes an array containing strings or an array containing numbers. The second also permits one array containing both. It might be exactly the contract the library wants. It might quietly delete an invariant. Deciding that comes before timing either spelling.
I checked this distinction, along with the polymorphic-function example, using strict TypeScript compilation and @ts-expect-error assertions for rejected programs. The compile-only contract tests also assert the resulting type shapes. They establish the examples' behavior, not a performance claim about either conditional.
Likewise, replacing a type with any, asserting an unchecked result, or deleting a validation phase may make a build faster by asking it to establish less. Sometimes a team deliberately chooses less checking. That decision should appear as a changed guarantee, not disappear into an optimization report.
TypeScript's performance guide recommends techniques such as named complex types, explicit return types, and interfaces where appropriate. I would apply them to a measured hotspot, with those contract tests still running. An alias is not automatically an opaque boundary, and an annotation on every local variable is not automatically a speedup.
An edit should have a small dependency graph#
Even an excellent checker feels slow if a private edit causes the whole project to be checked again. This is where language design, module interfaces, and the build system have to agree about what another module is allowed to observe.
Suppose a parser exports a named result containing an expression and diagnostics. I change how it buffers input, but keep the exported result and operations identical. Clients that only type-check against that interface should not need to rediscover its implementation. If the interface instead exposes a huge inferred implementation type, I have made the boundary harder to hold stable.
Rust's red-green incremental-query design gives a concrete implementation principle. After an input changes, a query may need recomputing; if its result stays the same, that result need not invalidate its dependents. Dependencies must be tracked, and persisted identities must remain meaningful across compiler sessions. Reusing an old answer merely because its function has the same name would not be enough.
There is a cost to recording dependencies, hashing results, and loading caches. Cold builds still matter. My target would be a small, checked public summary whose stability stops unnecessary client work, rather than a cache that serializes an enormous amount of accidental detail.
TypeScript's isolated declarations show one practical role for explicit exports: enough annotation lets declaration emit operate without full cross-file inference. That enables a different build arrangement; it does not make syntax-only declaration generation a substitute for semantic type checking.
Nor does an unchanged signature promise that every downstream artifact stays valid. Inlined bodies, compile-time constants, macros, and visible layouts can be dependencies of later compilation phases. Keep the type-checking interface separate from the optimization interface, and invalidate whichever one the client actually used. Otherwise a “fast incremental compiler” is just a stale compiler.
Even a proof needs a stopping rule#
What about the more ambitious end: effects, refinements, dependent types, and proofs that a program obeys a protocol? I would ask the same question there. Which fact is being represented, and how much automatic work is required to establish or compare it?
For an API that promises two dimensions agree, carrying a checked equality witness can be more predictable than asking every caller to rediscover the equality through unrestricted computation. That is a design direction, not a claim that witnesses are free. Their types still need checking, and the representation can itself become large.
Lean separates elaboration from kernel checking and compilation. Elaborating convenient source into an explicit core term and checking that resulting term are distinct jobs. That distinction is useful even outside theorem provers: a language can offer ambitious automation without making that automation an invisible prerequisite for every basic check.
I would give expensive automation an explicit budget and a useful failure mode: this obligation remains unresolved; here is where an annotation or lemma is needed. Exhausting the budget must not silently establish the proposition or turn the result into an unchecked escape hatch. The trade is honest: some true programs need more guidance, but accepted programs do not get a weaker contract just because the solver ran out of patience.
There is no blanket promise of cheap checking in that arrangement. Comparing types can itself require normalization, and an explicit proof can still be expensive. The practical goal is to make the expensive boundary visible, reusable, and measurable instead of distributing it through every ordinary edit.
There is still a backend bill#
Finally, “my types are slow” can be a diagnosis of the wrong compiler phase. A generic function can be checked against its bounds once and still lead to many machine-code bodies. Rust's monomorphization collector describes the process of finding those concrete items for code generation. Faster inference will not remove the backend work of optimizing all of them.
The earlier A generic should know less explored sharing code according to what a generic actually observes. Here I would keep the criterion practical: specialize where measurements justify it, and consider shared bodies or dictionary-passed operations where they do not. Less specialization can save compilation and binary size while giving up inlining or introducing indirection. That is a runtime tradeoff to measure, not a universal recommendation.
This separation is useful politically as well as technically. It prevents a type-system feature from being blamed for expensive optimization choices that happen after its checks have finished.
Make latency part of the feature#
I want a language feature proposal to show its expensive program alongside its beautiful program. Test it against deeply composed APIs, wide unions, mistaken overloads, and one-character edits inside popular modules. The negative case matters especially: editing produces incomplete programs, so fast success with glacial failure still makes a bad editor.
For the examples in this article, the reproduction commands are deliberately separate from the blog's build. The first executes the operation-count experiment. The second runs only the compile-time contract fixture, using a small configuration that excludes ambient package types.
pnpm exec tsx examples/type-checker-budget.tspnpm exec tsc -p examples/tsconfig.type-budget.jsonThe local checks here used Node.js 22.16.0 and TypeScript 5.8.3. They do not reproduce Microsoft's TypeScript 7 measurements or Zod's published benchmark. The fixtures require no new library dependency, and the article does not upgrade the blog's compiler.
For a real optimization, I would pin compiler, configuration, dependency lockfile, hardware, and worker counts; compare accepted and rejected programs; then measure a cold build and repeated edits separately. Record wall time, CPU time, peak memory, and useful work counters. Include distributions across repeated runs and slow editor responses, rather than reporting only the best invocation. Run tracing separately when its overhead would distort the timing.
My preferred default language would therefore have expressive explicit interfaces, local inference, compact shared type representations, reusable context-correct obligations, and incremental checking that stops at stable results. More ambitious automation would remain available behind visible boundaries. None of that requires promising to infer every annotation or expand every type-level computation for free.
The test for progress is straightforward: keep the mistakes we currently reject, keep the information people rely on, and make the wait smaller. Start by removing repeated questions and accidental structure. I want expressive types. I also want the compiler to respect the time between two edits.