Exploring the TypeScript Compiler Part 6: What Go Unlocks
This is Part 6, the final part of an expanded and revised version of the tskaigi 2026 Day 2 talk, “A History of TypeScript Compiler Design Through Constraints and Historical Context”. It includes explanations and short stories that were cut from the talk because of time.
- Part 0: Overview
- Part 1: Why Go?
- Part 2: Inside the TypeScript Compiler
- Part 3: Why TypeScript?
- Part 4: Roslyn and the Red-Green Tree
- Part 5: JavaScript Madness🫠
- Part 6: What Go Unlocks
On March 11, Microsoft announced that it was porting the TypeScript compiler to Go. The reported numbers were close to a tenfold speed increase, even for large repositories such as VS Code and Playwright.
It is easy to say that it became faster because it was written in Go. This part looks more closely at what changed and how those changes improved speed.
In the interview below, Anders Hejlsberg says native code brings a 3.5-fold gain and parallel work brings another 3.5-fold gain, for roughly a tenfold gain overall.
Here, “native code” includes both compiling to machine code and improving memory layout with Go structs.
Machine Code
The earlier TypeScript compiler was a JavaScript program run by Node.js. Before it could start its main work, it went through these steps:
- Start Node.js.
- Load
tsc, written in JavaScript, and have V8 turn it into bytecode. - Run the bytecode and compile parts of it with a JIT compiler when needed.
Go code is compiled to machine code ahead of time. At runtime, it does not have to load JavaScript, turn it into bytecode, or wait for JIT optimization.
Value Types
Go's struct is a value type. When one struct is stored directly in a field of another, its contents can be placed inside the outer struct's memory area.
Here is the definition of an AST node in typescript-go:
type Node struct {
Kind Kind // int16
Flags NodeFlags // uint32
Loc core.TextRange // Another struct
id atomic.Uint64 // Another struct
Parent *Node // Reference to the parent node
data nodeData // interface
}
This gives us the following picture of how the data is placed in memory:
Primitive values such as int16 and uint32 are placed next to each other. The same applies to the embedded structs TextRange and atomic.Uint64. Parent stores a pointer rather than the whole parent node because the nodes refer to each other in a cycle.
In JavaScript, the layout is different:
// Shortened example
interface Node extends ReadonlyTextRange {
kind: SyntaxKind; // Numeric enum
flags: NodeFlags; // Numeric enum
pos: number; // Go version: Loc.Pos()
end: number; // Go version: Loc.End()
id?: NodeId; // number
parent: Node; // Reference to the parent node
}
Its layout in memory looks like this:
In JavaScript, primitive values can be stored next to each other. But when one object contains another object, it holds a reference to that object rather than the object's data itself.
This matters when the program reads the data. If values are close together, one memory fetch can bring nearby values into the cache. If objects are spread across the heap, the program has to follow references to reach them.
Shared Memory and Multiple Threads
Parallel work brings the other 3.5-fold gain. The JavaScript version did not run this work in parallel. It used one thread, which limited its speed.
We have said several times that the TypeScript compiler's AST is mutable. After binding, however, the AST is no longer changed, so it can be read safely in parallel.
References
A History of TypeScript Compiler Design Through Constraints and Historical Context (Japanese)