Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/content/threads/upgrading-to-typescript-7.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
title: "Upgrading to TypeScript 7: what actually breaks"
description: "A field guide to the TypeScript 7 migration: the compiler options that are now hard errors, the missing programmatic API, and why a Next.js + MDX site like this one still needs TypeScript 6 in the editor."
date: 2026-07-12
tags: ["typescript", "tooling", "migration"]
---

[TypeScript 7 is 8-12x faster](/threads/why-typescript-7-is-written-in-go), which makes the
upgrade tempting to do on a Friday afternoon. Don't. The speed is real, but 7.0 is the
release where the team cashed in every deprecation they've been sitting on *and* shipped
without a programmatic API. Whether you can move depends less on your code than on your
toolchain.

Here's the honest checklist, in the order the breakage actually hits you.

## 1. Options that are now hard errors

TypeScript 6 made these warnings. TypeScript 7 makes them errors, so `tsc` refuses to start
rather than silently doing something else. Removed outright:

| Removed | What to do instead |
| --- | --- |
| `target: es5` | Target a modern runtime; downlevel in your bundler if you truly must |
| `downlevelIteration` | Gone with ES5 |
| `moduleResolution: node` / `node10` | `nodenext` (Node) or `bundler` (Vite, webpack, Next) |
| `module: amd` / `umd` / `systemjs` / `none` | `esnext` |
| `baseUrl` | `paths`, resolved relative to the project root |
| `esModuleInterop: false`, `allowSyntheticDefaultImports: false` | Cannot be disabled; delete them |

If you're on a modern setup, most of this is a no-op. If you're carrying a `tsconfig.json`
that has been copy-pasted since 2019, this is where your afternoon goes.

## 2. Defaults that changed under you

More dangerous than the removals, because these don't error, they just change what your
build means.

- **`strict: true` is the default.** If you were implicitly relying on `strict` being off,
you now have every error `strict` catches, all at once. Set `"strict": false` explicitly
if you're not ready — but note you're now opting *out*, in writing, which is the point.
- **`module: esnext` is the default.**
- **`noUncheckedSideEffectImports: true`** — a bare `import "./thing"` that resolves to
nothing is now an error. This catches real bugs and a few false ones (side-effect imports
of CSS or assets need the right ambient declarations).
- **`stableTypeOrdering: true`, and it cannot be disabled.** The checker is parallel now, so
deterministic type ordering has to be enforced rather than assumed.
- **`rootDir` now defaults to `./`.** If your `tsconfig.json` lives above your sources, your
output layout just changed. Say what you mean:

```json
{
"compilerOptions": {
"rootDir": "./src"
}
}
```

- **`types` defaults to `[]`** instead of auto-discovering everything in
`node_modules/@types`. This is a genuinely good change (auto-discovery was a common source
of "why is `describe` a global in my production build") and a genuinely annoying one,
because you must now list what you use:

```json
{
"compilerOptions": {
"types": ["node", "vitest/globals"]
}
}
```

Missing globals after upgrading — `process`, `describe`, `expect` — is almost always this.

## 3. Checked JavaScript got stricter

If you run `checkJs` over a JS codebase with JSDoc types, several patterns are gone: values
can no longer stand in for types (use `typeof x`), `@enum` is no longer recognized (use
`@typedef`), the postfix `!` non-null operator is out, and Closure-style function syntax
isn't supported. Small surface, but if you're a big JSDoc shop it's the whole migration.

## 4. The one that actually decides your timeline: no API

TypeScript 7.0 ships with **no programmatic API**. Anything that imports `typescript` and
walks the AST or asks the checker questions does not work on 7 yet. That includes:

- typescript-eslint (so: most people's lint setup)
- Vue, Svelte, Astro, **MDX** — anything that embeds or templates TypeScript
- Angular's template type-checking
- ts-morph, most codemods, most custom transformers

The supported path is to run both compilers side by side: TypeScript 7 for the fast CLI
builds, TypeScript 6 via `@typescript/typescript6` (an npm alias) for the tools that still
need the API. A real API is expected in 7.1.

So the practical question is not "does my code compile under 7" but "which of my tools reach
into the compiler". Most teams will find the answer is *at least one*.

## What this means for a site like this one

This site is Next.js App Router plus MDX (that's how these threads are written). MDX needs
the TypeScript API to type-check inside `.mdx`, so per the release notes, MDX projects stay
on TypeScript 6 for now. The realistic setup is split:

- **CLI type-check and CI:** TypeScript 7. `npm run typecheck` is the single slowest thing
in our pipeline, and it's the thing every push waits on.
- **Editor and lint:** TypeScript 6 until 7.1 lands the API, because typescript-eslint and
the MDX toolchain need it.

That's not a clean win, and it's worth being clear-eyed about it rather than performing an
upgrade for the changelog entry. A codebase this size type-checks in a couple of seconds
either way. The 8-12x is transformative for a 130-second build and a rounding error for a
2-second one, so we'll take the CI half now and move the editor when the API is real.

## A migration order that works

1. Upgrade to the **latest TypeScript 6** first, and fix every deprecation warning. This is
the actual work, and you can do it incrementally while your tooling still functions.
2. Modernize `tsconfig.json`: `moduleResolution` to `bundler` or `nodenext`, drop `baseUrl`,
drop `esModuleInterop: false`, make `rootDir` and `types` explicit.
3. Turn `strict` on, or explicitly turn it off, so nothing changes silently under you.
4. Inventory what touches the compiler API: lint, test transforms, codemods, framework
plugins. That list is your gate.
5. Run TypeScript 7 for CLI builds and CI, keep TypeScript 6 wherever the API is required.
6. Revisit when 7.1 ships an API and the ecosystem catches up.

Steps 1-3 are worth doing even if you never adopt 7. That's usually the sign of a
well-designed breaking release: the migration path improves your codebase whether or not you
arrive at the destination.
156 changes: 156 additions & 0 deletions src/content/threads/why-typescript-7-is-written-in-go.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
title: "Why TypeScript 7 is written in Go"
description: "TypeScript 7 shipped on July 8, 2026 with an 8-12x faster compiler. The speedup isn't a clever optimization, it's the consequence of leaving a runtime that couldn't express the thing the checker wanted to do all along."
date: 2026-07-12
tags: ["typescript", "compilers", "go", "concurrency"]
---

TypeScript 7.0 [shipped on July 8, 2026](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/).
The headline is the number: vscode's build went from 125.7s to 10.6s, sentry from 139.8s
to 15.7s, playwright from 12.8s to 1.47s. Roughly 8-12x, plus 6-26% less memory. Editors
got the bigger win in practice — opening a file with errors in VS Code fell from 17.5
seconds to 1.3.

The interesting part isn't the number. It's that the number came from a *port*, not a
redesign. Same architecture, same algorithms, same type system, largely the same code
structure, moved from TypeScript to Go. When a straight port makes something an order of
magnitude faster, the old implementation wasn't slow because it was badly written. It was
slow because the runtime under it couldn't express what the program actually wanted to do.

## Where the time was going

`tsc` was a TypeScript program compiled to JavaScript, running on Node. That gives you
three costs that no amount of micro-optimization removes.

**You pay for the JIT, every time.** A compiler run is short and cold. V8 has to parse,
interpret, profile and eventually optimize hot functions — but batch `tsc` often exits
before the JIT has finished paying itself back. The checker is a mess of megamorphic call
sites (a `Type` can be one of dozens of shapes), which is exactly the pattern inline caches
handle worst.

**Every node is a heap object.** ASTs, symbols, types: millions of small objects, each a
V8 object with a hidden class and a pointer chase to reach any field. Type checking is
pointer-chasing all the way down, so cache behavior *is* the performance story, and
JavaScript gives you no control over layout.

**And the hard one: you cannot share memory.** Node's concurrency story is worker threads
with structured-cloned messages or `SharedArrayBuffer` (raw bytes, no objects). The type
checker's core structure is a giant cyclic graph — symbol tables pointing at declarations,
declarations at AST nodes, AST nodes back at symbols, types recursing into themselves. To
check two files in parallel, both workers need to *read that same graph*. Serializing it
per worker costs more than the checking does. So `tsc` stayed single-threaded, on machines
where 8 or 16 idle cores were watching it work.

That last constraint is the whole story. The checker is embarrassingly parallel by nature —
files are mostly independent, and the shared graph is read-mostly once it's built. The
algorithm wanted threads. The runtime had none to give.

## Why Go, and not Rust

This is where most of the noise was, and the reasoning is more interesting than the tribal
answer.

Go's memory model is the one the compiler already assumed: a garbage-collected heap where
any goroutine can hold a pointer to any object, and goroutines share one address space. You
can spawn workers that all read the same symbol tables with no copying, no `Arc`, no
serialization. That is precisely the capability JavaScript couldn't offer, and it's where
the 10x lives.

Rust would have fought the data model. The compiler's graph is *deliberately* cyclic: a
node points at its parent and its children; a symbol points at the declarations that create
it, which point back at the symbol; recursive types loop by construction. That's ownership's
worst case. You'd end up with `Rc<RefCell<...>>` or arena indices everywhere — either
paying the cost you were trying to avoid, or rewriting the type checker's data flow from
scratch. And a rewrite is a different, much riskier project than a port: you have to
re-derive twenty years of accumulated behavior that only exists as code.

The port mattered for a second reason. The existing compiler is written in a function-heavy,
closure-and-data style rather than a deeply object-oriented one, which maps onto Go almost
one-to-one. That kept the port mostly mechanical, and mechanical means the type system's
thousand undocumented edge cases survive the move.

Go isn't "faster than Rust" here. It's the language whose defaults match the shape of this
specific program, at a level of effort that made the project finishable.

## What native + threads actually buys

Three separate wins, worth keeping apart:

1. **Native code.** No JIT warmup, no deopt cliffs. Structs are laid out flat, so walking
an AST touches fewer cache lines. Call this a solid constant factor.
2. **Shared-memory parallelism.** Parsing, checking and emitting run concurrently across
goroutines over one graph. This is the multiplier, and it's the one that was previously
impossible rather than merely slow.
3. **Control over allocation.** A compiler allocates in bulk and frees in bulk. In Go you
can shape that; in JavaScript you get whatever V8 decides.

The parallelism is exposed, not hidden. TypeScript 7 adds `--checkers` (default 4) for
type-checking workers, `--builders` for parallelizing project references, and
`--singleThreaded` when you need deterministic behavior for debugging:

```bash
# More checkers: faster on big projects, more memory.
tsc --checkers 8

# Reproducible ordering while chasing a compiler bug.
tsc --singleThreaded
```

The team reports up to 16.7x with `--checkers 8` on large projects, trading memory for it.
That knob existing at all is the tell: concurrency is now a first-class part of the
compiler's design, not an afterthought bolted onto a single-threaded core.

There's a subtle consequence of going parallel, and TypeScript 7 handles it rather than
ignoring it. If workers check files in nondeterministic order, the order that types get
*created* in can vary, and anything that prints types — declaration emit, error messages,
union ordering — could shift between runs. So `stableTypeOrdering` is on and cannot be
turned off. Determinism is now a property the compiler has to maintain deliberately, because
the machine underneath it no longer provides it for free.

## The editor was always the real workload

Batch builds are the number people quote, but the language service is where developers
actually feel a compiler. It's a long-lived process holding the same graph, answering
latency-sensitive questions: hover, completions, go-to-definition, find-all-references.

TypeScript 7 rebuilt this on the Language Server Protocol with multithreading, and the
numbers that matter aren't speed at all: an 80% reduction in failing language server
commands and 60% fewer server crashes. A single-threaded server that blocks for 17 seconds
on a big file isn't slow, it's *broken* — it drops requests, times out, and gets killed. A
lot of what everyone experienced as TypeScript being flaky in large repos was a
concurrency-starved process failing under load.

They also rewrote the file watcher, porting Parcel's C++ watcher to Go. Watching is one of
those problems that looks trivial and is not: it's where "my editor didn't notice the file
changed" bugs are born.

## What it cost

TypeScript 7.0 ships **without a programmatic API**. Not a smaller one — none. Everything
that imports `typescript` and pokes at the AST (typescript-eslint, ts-morph, Vue, Svelte,
Astro, MDX, Angular's template checker, most codemods) cannot run on TypeScript 7 today. The
compatibility answer is to keep TypeScript 6 side by side via `@typescript/typescript6`,
with an API promised in 7.1.

That's a big bill, and it's honest about the tradeoff being made. A JavaScript API handing
out object references into a graph is a very different thing to design when that graph is
being read by a pool of goroutines and owned by a Go garbage collector. They shipped the
compiler and deferred the boundary rather than shipping a bad boundary and living with it
for a decade. Worth respecting, and worth knowing before you plan an upgrade — we wrote up
[what the migration actually involves](/threads/upgrading-to-typescript-7) separately.

## The part worth stealing

Nobody found a magic algorithm. They took a program whose *natural* structure was a
shared, read-mostly graph traversed in parallel, and moved it to a runtime that can
represent that structure. The 10x wasn't created; it was already there, sitting behind an
abstraction that couldn't reach it.

Which is the useful question to carry back to your own systems: not "how do I make this
loop faster", but "what is this program obviously trying to do, and what is stopping it?"
Sometimes the answer is a better data structure. Sometimes the honest answer is that the
platform you chose cannot express your problem, and every optimization from here is
interest payments on that decision.

The TypeScript team spent a year answering that question with a full port. The rest of us
usually get to answer it for the price of a weekend spike.
4 changes: 4 additions & 0 deletions src/lib/thread-covers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import notesWindow from "@/images/notes-window.jpg";
import wireframes from "@/images/wireframes.jpg";
import tornPaper from "@/images/torn-paper.jpg";
import forceNote from "@/images/the-force-note.jpg";
import colorStacks from "@/images/color-stacks.jpg";
import planningNotes from "@/images/planning-notes.jpg";

/**
* Maps a thread slug to its cover photo. Threads without an entry simply render
Expand All @@ -14,4 +16,6 @@ export const threadCovers: Record<string, StaticImageData> = {
"how-semantic-search-works": wireframes,
"an-mcp-server-for-your-notes": tornPaper,
"what-local-first-buys-you": forceNote,
"why-typescript-7-is-written-in-go": colorStacks,
"upgrading-to-typescript-7": planningNotes,
};