Why Microsoft Chose Go to Rewrite TypeScript — A 10x Performance Story

Lessons from porting 150,000 lines of compiler code, and what it means for the Go ecosystem

分享
Why Microsoft Chose Go to Rewrite TypeScript — A 10x Performance Story
https://www.youtube.com/watch?v=PZm_YbE3fcA

A few months ago, the TypeScript team dropped a bombshell: the TypeScript compiler is being rewritten in Go and is 10x faster. There was a lot of buzz about it, and at GopherCon 2025, Jake Bailey shared more details about the project and what it has achieved.

As a Go developer, this announcement felt like validation. But beyond the initial excitement, there’s a fascinating engineering story here — one filled with pragmatic decisions, clever optimizations, and hard-won lessons about what makes Go special.

Let me break down what happened, why Go won, and what every Go developer can learn from this massive porting effort.

The Problem: JavaScript Hit a Wall

TypeScript is everywhere. It’s the third most popular language on GitHub, and if you’ve ever used VS Code’s autocomplete or hover documentation — even in plain JavaScript files — you’ve used TypeScript’s toolchain.

But here’s the irony: TypeScript’s compiler was written in JavaScript.

For years, this made sense. The team got immediate feedback on their own code, and the community could contribute easily. But JavaScript wasn’t designed for writing compilers, and as codebases grew larger, the cracks started to show:

  • No shared memory between threads — serializing data for parallelism often costs more than the parallelism saved
  • 4GB memory limit in Electron — VS Code users with massive codebases hit walls
  • Single-threaded by nature — all that CPU power, wasted

The TypeScript team squeezed every drop of performance out of JavaScript through profiling, caching, and JIT-friendly code patterns. But fundamentally, they were fighting the language itself.

“We couldn’t do any better than this in JavaScript because there’s just no way for us to share objects between threads.”

Why Go? The Checklist That Pointed One Direction

When the team finally decided to explore other languages, they had a strict checklist:

That last point is crucial. TypeScript has no formal specification. The “spec” is whatever the 150,000 lines of compiler code do. Users depend on obscure, undocumented behaviors. A rewrite would break things in unpredictable ways.

They needed a language where the ported code would look almost identical to the original.

Go checked every box.

The TypeScript codebase was already “class-free” — just functions, interfaces, and data. Sound familiar? That’s basically Go’s philosophy. The team doesn’t use inheritance or polymorphism. It’s just structs and methods.

“All signs are pointing one language. You knew that already — we’re at GopherCon, right?”

The Port: From 80 Seconds to 7 Seconds

The team started at the bottom — the scanner and parser. Within weeks, they had proof: parsing was 5x faster out of the gate, with code that looked nearly identical to the original TypeScript.

Jake Bailey built a tool called ts-to-go that automatically transforms TypeScript code into Go using the TypeScript API. It's not perfect — JavaScript's || operator needs manual conversion to if statements, and ternary operators become function calls — but it got them 80% of the way there.

The results spoke for themselves:

And here’s the kicker: about half the speedup comes from Go’s concurrency model.

Concurrency: The Game Changer

In the old JavaScript compiler, everything was sequential:

  1. Parse files one by one
  2. Bind files one by one
  3. Type-check files one by one
  4. Emit files one by one

In Go, the team parallelized everything they could:

  • Parsing: Files are independent — parse them all in parallel
  • Binding: Self-contained — parallelize it
  • Emit: Embarrassingly parallel

But type-checking was tricky. Types from one file can affect any other file. There’s recursion, global state, and order dependencies.

Their clever solution? Spawn multiple checkers.

Instead of having a single checker process all files, they create multiple independent checkers, each handling a subset. Yes, there’s some duplicated work. But the net effect is a massive win — finally using all those CPU cores.

“This is Go, so adding concurrency was what, like five lines of code?”

The team — most of whom had never written Go professionally — picked it up quickly. The main advice Jake gave them: “Please write simpler, straight-line code. Don’t go crazy with channels.”

Go-Specific Optimizations: Lessons for Every Gopher

The port wasn’t just a straight translation. The team identified several Go-specific performance patterns worth studying.

1. Arena Allocation for AST Nodes

Parsing VS Code generates 9 million AST nodes. In Go, every allocation has overhead — you’re calling into the runtime.

Their solution: arena-style allocation. A pool pre-allocates large slices and hands out pointers to elements. When the AST is done, the whole arena is garbage-collected at once.

Result: 20% faster parsing, 96% fewer allocations.

// Conceptually: 
type NodePool struct { 
    nodes []Node 
    index int 
} 
func (p *NodePool) Alloc() *Node { 
    if p.index >= len(p.nodes) { 
        p.nodes = make([]Node, 1024) // Allocate in bulk 
        p.index = 0 
    } 
    node := &p.nodes[p.index] 
    p.index++ 
    return node 
}

2. Avoiding Interface Boxing for nil

The team initially used interfaces for AST nodes — the “obvious” Go pattern. But they kept hitting the classic bug: assigning nil to an interface doesn’t give you a nil interface.

Their radical solution: everything is a struct. They embed a base Node struct in every specific node type and only pass around *Node pointers. Yes, they lost some type safety. But a whole class of porting bugs disappeared.

3. Pre-binding Method Values

This one’s subtle. Consider code like:

func (b *Binder) bindChildren(node *Node) { 
    forEachChild(node, b.visit) // b.visit escapes! 
}

Every call allocates a new method value because the compiler can’t prove forEachChild won't store it.

Fix: Store the bound method once at construction time:

type Binder struct { 
    visitFunc func(*Node) // Pre-bound 
} 
func NewBinder() *Binder { 
    b := &Binder{} 
    b.visitFunc = b.visit 
    return b 
}

Result: 17% faster binding, 90% fewer allocations in that phase.

4. String Concatenation Traps

In JavaScript, string concatenation with + is optimized via "cons strings" — the runtime builds a tree and only flattens it when needed.

In Go, every + allocates a new string. The team had to hunt down these patterns and replace them with strings.Builder.

“In Go this is a big no-no. Thankfully, pprof will point this out for you.”

The Race Detector Saved Them

When you port JavaScript code to Go and add concurrency, you inherit assumptions that don’t hold anymore.

For example, the old codebase sometimes mutated AST nodes after creation — setting a flag to indicate that a cached computation had been performed. Fine in single-threaded JavaScript. A data race in concurrent Go.

The race detector caught these immediately.

“The race detector did a great job. We had more race conditions, and Go’s race detector pointed those out for us.”

They also hit “logical races” — code that wasn’t technically racing but behaved strangely under concurrency. The fix was adopting an immutable snapshot model for the language server, a pattern used by gopls and rust-analyzer.

What Go Gained: A New Compiler Benchmark

The announcement didn’t just excite TypeScript developers. Go contributors immediately started testing the new compiler.

On the same day as the announcement, someone filed a bug: “The Go compiler is slow compiling the TypeScript compiler.”

Two days later, TypeScript became a Go compiler benchmark. A pair of CLs landed in Go 1.25, speeding up compilation of the checker package by 5x.

This kind of cross-pollination is exactly what open source is about.

The Road Ahead

The team is targeting TypeScript 7.0 for the Go-powered release. TypeScript 6.0 will include deprecations to smooth the transition.

What’s still coming:

  • Full LSP (Language Server Protocol) support with async capabilities
  • Auto-imports, rename, and other editor features
  • WASM support for browser-based tooling
  • Potentially a public Go API (though the Go 1 compatibility promise makes this tricky)

Key Takeaways for Go Developers

  1. Go’s simplicity is a feature, not a limitation. A team of 10 TypeScript developers learned Go quickly and became productive. “Go is a simple language” isn’t a criticism — it’s why massive ports like this are feasible.
  2. Concurrency doesn’t have to be complex. The TypeScript team’s concurrency model is straightforward: spawn independent workers, let them share immutable data. No complex channel choreography required.
  3. Profile everything. Arena allocation, pre-bound methods, string builders — none of these optimizations are obvious. pprof And the race detector is an essential tool.
  4. Sometimes “obvious” patterns aren’t best. Using interfaces for AST nodes is the textbook Go approach. But for this specific use case, struct embedding and raw pointers worked better. Know the rules, but know when to break them.
  5. Go is production-ready for massive, complex systems. If it can handle a 150,000-line compiler port with 100,000 tests, it can handle your project.

Final Thoughts

The TypeScript-to-Go port is more than a performance story. It’s a validation of Go’s design philosophy: simplicity, readability, and powerful concurrency primitives that don’t require a PhD to use correctly.

When Microsoft — a company with heavy investments in C#, Rust expertise, and every language option on the table — chose Go for one of their most critical developer tools, it says something.

The future of TypeScript is written in Go. And honestly? That’s pretty cool.

Based on Jake Bailey’s talk at GopherCon 2025: “Porting the TypeScript Compiler to Go for a 10x Speedup”

Watch the full talk: YouTube