A source-guided look at checker pools, import-graph partitioning, and the trade-off between build speed and duplicated type state
TypeScript 7 is often introduced as the Go-based, much faster successor to the JavaScript compiler. That description is true, but it misses one of the more interesting engineering decisions in the new compiler: type checking is not simply dispatched to an arbitrary worker pool. The compiler builds a fixed set of checkers, assigns source files to them deliberately, and keeps that assignment stable for a given program and checker count.
This matters because a type checker is not a stateless function that can examine any file in isolation. A file may rely on declarations from its imports, ambient declarations, generic instantiations, and the global scope. A design that maximized CPU use without considering those relationships would recreate the same semantic work repeatedly, make memory use unpredictable, and risk output whose ordering changes from build to build.
The TypeScript team describes TypeScript 7 as a native Go port which performs parsing, type checking, and emitting in parallel. Its public controls include --checkers, --builders, and --singleThreaded. This article focuses on the less obvious one: the type-checker pool used within a project.
I am not part of the TypeScript team. This is an independent technical reading of the publicly available source code and documentation, not an official explanation of the implementation or its design decisions.
Source and license note
This article discusses the publicly available microsoft/TypeScript repository. TypeScript 7’s Go compiler lives under tsc/; the earlier typescript-go staging repository was archived after the release. The source is licensed under the Apache License, Version 2.0.
The short blocks labelled as excerpts are from that repository. Diagrams, explanations, and the simplified code are original. The full sources discussed here are checkerpool.go and program.go. The repository’s NOTICE file remains available with its attribution notices.
The problem: type checking has shared semantic work
Parsing is comparatively easy to parallelize. Given a file’s text and compiler options, a parser can normally construct that file’s syntax tree without waiting for the rest of the program. Emitting JavaScript can often follow the same pattern.
Type checking is different. Consider a small project:
src/api.ts ───────┐
├── src/service.ts ─── src/app.ts
src/models.ts ────┘
Consider the following code. ApiResponse<T> is generic: when the compiler checks ApiResponse<User>, it has to work with a concrete form in which T is replaced by User. This is called generic instantiation.
// src/models.ts
export type User = {
id: string;
name: string;
};
// src/api.ts
export type ApiResponse<T> = { data: T };
export async function request<T>(path: string): Promise<ApiResponse<T>> {
// The real HTTP work is omitted to keep this example focused.
throw new Error(`not implemented: ${path}`);
}
// src/service.ts
import { request } from "./api";
import type { User } from "./models";
export async function loadDisplayName(id: string): Promise<string> {
const response = await request<User>(`/users/${id}`);
return response.data.name.toUpperCase();
}
// src/app.ts
import { loadDisplayName } from "./service";
void loadDisplayName("123").then(console.log);
To check service.ts, a checker first resolves the names request and User to the modules that export them. To check request<User>(...), it handles the return type Promise<ApiResponse<User>>, in which T has been replaced with User. To confirm that response.data.name is valid, it must also follow User far enough to find name: string. app.ts imports loadDisplayName, so it also needs the exported function type.
In this article, semantic state means the checker’s internal working information: resolved names, compared types, and concrete types created from generics. It is neither emitted JavaScript nor a user-visible value. It is work the compiler can retain so that it does not have to answer the same type question repeatedly.
That gives file assignment a concrete trade-off.
One new checker per file
checker A: api.ts → builds state for ApiResponse<T> and request<T>
checker B: models.ts → builds state for User
checker C: service.ts → may build its own state while following the imports
checker D: app.ts → may separately follow the exported loadDisplayName type
Benefit: file-level work is easy to isolate
Cost: semantic state about shared imports can be duplicated
One checker for every file
checker 0: api.ts, models.ts, service.ts, app.ts
Benefit: symbols and types from imports are easier to reuse within one checker
Cost: one checker cannot safely serve multiple file checks at once, so file-oriented checking becomes effectively serial
The useful design space lies between those extremes: keep related files near the same checker where possible, while ensuring that no one checker receives most of the work.
Several checkers
checker 0: api.ts, models.ts, service.ts
checker 1: app.ts
service.ts stays close to the api.ts and models.ts files it imports, which aims to reuse checker 0's state.
app.ts is related to service.ts too, but the whole project must also avoid concentrating too much work in one checker.
The real compiler is not deciding only among these four files. It uses the complete import graph and an estimated weight for each file. It therefore does not follow a simplistic “always place an import beside its importer” rule; it looks for an assignment that preserves both useful locality and practical parallelism.
A checker is a stateful worker, not just a goroutine
In TypeScript 7, a checker pool owns a fixed number of *checker.Checker values, one mutex per checker, and a mapping from each source file to its assigned checker.
// Excerpt. Fields unrelated to this article are omitted; inline comments are explanatory.
type checkerPool struct {
program *Program // The Program for the project currently being compiled.
checkers []*checker.Checker // An ordered list of created checkers.
locks []*sync.Mutex // A mutex for the checker at the same index.
fileAssociations map[*ast.SourceFile]*checker.Checker // A lookup from file to its assigned checker.
}
First, a quick guide to the Go notation: *T is a pointer to T. Here it refers to one existing Program or Checker instance without copying it. []T is an ordered list, and map[K]V is a lookup table from keys of type K to values of type V. Therefore, []*checker.Checker is an ordered list of pointers to checkers, while map[*ast.SourceFile]*checker.Checker looks up a pointer to a checker from a pointer to a source file.
Each field has a distinct role.
Here, a mutex is short for mutual exclusion. It is a lock that prevents one shared resource from being changed simultaneously, or read while another operation is midway through changing it. When a goroutine calls mutex.Lock(), only one goroutine can hold that mutex. Another goroutine that calls Lock() on the same mutex waits until the first calls Unlock(). A mutex does not copy or store data; it decides who is currently allowed to use one checker.
-
programrepresents the one project currently being compiled.Programholds project-wide information such as source files, compiler options, and import relationships. The checker pool is not a global pool shared by every compilation; it is created to type-check thisProgram. -
checkersis the list of objects that actually perform type checking. With two checkers,checkers[0]andcheckers[1]hold differentCheckerinstances. Each instance has its own caches for symbols, types, and generic instantiations. -
locksis the list of mutexes that prevents two goroutines from using the same checker at once. The index relationship matters:locks[0]protects onlycheckers[0], andlocks[1]protects onlycheckers[1]. This is not one global lock, so work using a different checker does not have to wait. -
fileAssociationsrecords the owner of every source file. Its key is*ast.SourceFileand its value is*checker.Checker. Afterservice.tsis assigned to checker 0,fileAssociations[serviceFile]returns the same checker ascheckers[0].
For the earlier project with two checkers, the relationship looks like this in principle:
checkers[0] = checker 0 locks[0] = mutex for checker 0
checkers[1] = checker 1 locks[1] = mutex for checker 1
fileAssociations[service.ts] ──────────> checker 0
fileAssociations[api.ts] ──────────> checker 0
fileAssociations[models.ts] ──────────> checker 0
fileAssociations[app.ts] ──────────> checker 1
The important point is that locks belong to checkers, not to files. service.ts, api.ts, and models.ts are different files, but all are associated with checker 0. Any work using checker 0's state needs the same locks[0]. app.ts, associated with checker 1, uses locks[1] instead.
Within one Program, one file cannot use both locks[0] and locks[1]. fileAssociations[serviceFile] returns one checker, and that checker has one position in checkers and therefore one corresponding mutex. The mutex protects the checker's caches and in-progress state, which several files can share; it does not protect a file itself.
The following simplified Go code shows only how the mutex works. Treat work as type-checking one file with one checker. This is explanatory code, not the TypeScript implementation.
// withExclusiveChecker makes one checker exclusive for the duration of work.
func withExclusiveChecker(lock *sync.Mutex, work func()) {
lock.Lock() // Another goroutine using this lock waits until Unlock.
defer lock.Unlock() // Release the checker even if work returns early.
work() // Safely use the checker's caches and semantic state.
}
defer is Go syntax for running a call when the function exits. Here, it makes sure that Unlock() is not forgotten whether work completes normally or returns early. In the following examples, the go prefix starts a function call in a new goroutine.
This is permitted parallel work. The two jobs use different checkers and therefore different mutexes, so neither waits for the other.
// Explanatory pseudocode: service.ts uses checker 0; app.ts uses checker 1.
go withExclusiveChecker(locks[0], func() {
typeCheck(checkers[0], serviceFile) // Uses only checker 0's state.
})
go withExclusiveChecker(locks[1], func() {
typeCheck(checkers[1], appFile) // Uses only checker 1's state.
})
goroutine A: Lock locks[0] → check service.ts with checker 0 ───→ Unlock
goroutine B: Lock locks[1] → check app.ts with checker 1 ───────→ Unlock
The locks differ, so A and B can make progress at the same time.
This is work that waits. service.ts and api.ts are different files, but both are assigned to checker 0 and therefore both need locks[0].
// Explanatory pseudocode: both jobs need the same checker 0.
go withExclusiveChecker(locks[0], func() {
typeCheck(checkers[0], serviceFile)
})
go withExclusiveChecker(locks[0], func() {
typeCheck(checkers[0], apiFile) // Cannot progress until the earlier work unlocks checker 0.
})
goroutine A: Lock locks[0] → check service.ts with checker 0 ───→ Unlock
goroutine B: tries Lock locks[0] → waits ───────────────────────→ check api.ts → Unlock
B waits so that both jobs never touch one checker's state at once.
If two goroutines tried to check the same service.ts file, the second would also wait on locks[0]. However, the central purpose is not to protect one file; it is to prevent multiple jobs that share one checker from corrupting its caches and state at the same time.
The earlier examples simplified mutex use by passing a lock directly. The real getCheckerForFileExclusive instead receives a file and finds the required mutex itself. It has four steps:
- Look up the file’s assigned checker with
fileAssociations[file]. - Find that checker’s position in
checkers. - Lock the mutex at the same position,
locks[idx]. - Return the checker and a release function for the caller to invoke when its work is done.
The following excerpt implements exactly that correspondence.
// Excerpt: the pool selects the file's checker, then protects that checker.
func (p *checkerPool) getCheckerForFileExclusive(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
p.createCheckers()
c := p.fileAssociations[file] // The association is created once for this program.
idx := slices.Index(p.checkers, c)
p.locks[idx].Lock() // Do not let two callers mutate or read this checker unsafely.
return c, sync.OnceFunc(func() {
p.locks[idx].Unlock() // The caller releases the checker when its operation ends.
})
}
c is the checker from step 1, and idx is the checker’s position found in step 2. p.locks[idx] is the mutex from step 3. The second returned value is the release function, which the caller uses when it is done with the checker. sync.OnceFunc is a Go helper that makes the inner Unlock() run only once, even if the release function is accidentally called twice.
The reused unit here is a real checker with caches and mutable semantic state, not merely an execution slot. The source comment describes each partition as a checker with its own symbol, type, and instantiation caches. Multiple checkers may work at the same time, but one checker is never treated as a thread-safe shared bag of type state.
Why file assignment is a graph-partitioning problem
The implementation models the program’s in-project resolved imports as an undirected graph.
source file = a vertex
resolved import between in-project files = an edge
checker = a partition of vertices
An import is directional in source code: app.ts imports service.ts. For cache locality, however, the relationship is useful in both directions. If two files share a checker, either one may benefit from type information that the other caused that checker to construct. The implementation therefore uses an undirected adjacency graph for this decision.
The source comments describe the objective in a compact form:
affinity(partition) - alpha × incrementalLoadPenalty(partition)
“Affinity” means that a file gets a better score for joining a checker that already owns adjacent imported files. “Load penalty” means that a checker becomes less attractive as its estimated workload grows. Neither term should win unconditionally: putting every connected file together harms parallelism; spreading every file evenly can duplicate semantic cache construction.
The production code uses a weighted adaptation of the FENNEL streaming graph-partitioning objective. You do not need to know FENNEL to understand the practical rule: score every checker by nearby import relationships, subtract a penalty for additional assigned work, and choose the best stable result.
Here is a deliberately simplified version of that decision. It is explanatory code, not a copy of the TypeScript implementation.
// chooseChecker assigns one file to the most suitable checker.
func chooseChecker(file int, neighbors [][]int, assignment []int, loads []int, weight int) int {
bestChecker := 0
bestScore := math.Inf(-1)
for checker := range loads {
sharedNeighbors := 0
for _, adjacentFile := range neighbors[file] {
if assignment[adjacentFile] == checker {
sharedNeighbors++ // Related files on one checker can reuse its semantic caches.
}
}
nextLoad := loads[checker] + weight
loadPenalty := float64(nextLoad) // The real compiler uses a calibrated convex penalty.
score := float64(sharedNeighbors) - loadPenalty
if score > bestScore {
bestChecker, bestScore = checker, score
}
}
return bestChecker
}
The real function is more careful. It estimates each file’s weight from syntax-node count, source-text length, and import count; it establishes a preferred maximum load; and it uses deterministic tie-breaking. Those details prevent a highly connected or expensive file from causing one checker to become the bottleneck.
Determinism is part of the design, not a by-product
Parallel systems often become non-deterministic by accident. If a scheduler assigns the next completed task to the next available worker, two runs can choose different work sequences. That is acceptable for many batch jobs, but it is a poor default for a compiler: developers expect the same input and configuration to produce the same diagnostics and output.
TypeScript 7 therefore computes associations before it begins file-oriented type-checking. The association code has explicit stable rules: it can preserve program order, or sort source files before declaration files and use their estimated weight; ties fall back to stable file indexes. Once constructed, fileAssociations maps every source file to its selected checker.
The subsequent diagnostics collection preserves the source-file index as well. The following excerpt shows the essential idea.
// Excerpt, shortened: each file's diagnostics go to its own stable slot.
func (p *Program) collectCheckerDiagnosticsFromFiles(ctx context.Context, sourceFiles []*ast.SourceFile, collect func(context.Context, *checker.Checker, *ast.SourceFile) []*ast.Diagnostic) [][]*ast.Diagnostic {
diagnostics := make([][]*ast.Diagnostic, len(sourceFiles))
p.compilerCheckerPool.forEachCheckerGroupDo(ctx, sourceFiles, p.SingleThreaded(), func(c *checker.Checker, fileIndex int, file *ast.SourceFile) {
diagnostics[fileIndex] = collect(ctx, c, file)
})
return diagnostics
}
Workers may complete at different times, but completion order does not determine where results are stored. Later processing can concatenate, sort, and deduplicate diagnostics. This is a broadly useful pattern whenever parallel work must retain a caller-visible order: assign stable input indexes before concurrency begins, store each result by index, and make ordering explicit at the merge boundary.
There is an important limit. The TypeScript 7 release notes say that changing the number of checkers can expose rare order-dependent results. A fixed --checkers value gives a stable partition for a stable input, but changing that value deliberately changes the partitioning configuration. Teams that need identical behavior across environments can therefore pin the checker count.
The worker count is a performance and memory dial
The pool defaults to four checkers. --checkers can choose another count, while --singleThreaded sets the count to one and also disables parsing and emitting parallelism. The implementation caps the count to the number of program files and to 256, so a configuration cannot create more checkers than there are files to assign.
// Excerpt: the default and the two configuration paths.
checkerCount := 4
if program.SingleThreaded() {
checkerCount = 1
} else if c := program.Options().Checkers; c != nil {
checkerCount = *c
}
checkerCount = max(min(checkerCount, len(program.files), 256), 1)
More checkers can reduce wall-clock time when a project has enough independent work and CPU capacity. They also increase the chance that separate checker caches recreate similar type information. The release notes make this trade-off explicit: increasing --checkers can speed up larger builds, but usually costs more memory. --checkers 1 removes inter-checker duplication at the cost of type-checking parallelism.
This is why “use every CPU core” is not automatically the right compiler setting. The appropriate value depends on project shape, available memory, CI runner capacity, and whether type-checking or another phase is actually the bottleneck. A monorepo has a second control, --builders, for project-reference builds; its concurrency combines multiplicatively with checker concurrency, so --checkers 4 --builders 4 may allow up to sixteen checkers at once.
What application developers can borrow from this design
Most applications do not need graph partitioning. The transferable lessons are smaller and more practical.
If a worker has expensive reusable state, treat locality as a first-class concern. A database connection with prepared statements, a compiler cache, a GPU context, or a model session may benefit from assigning related work consistently rather than sending every task to whichever worker happens to be idle.
If the work has a visible order, preserve that order separately from execution. Parallelism should change how long a job takes, not randomly change the sequence of diagnostics, records, or user-facing messages.
Finally, expose concurrency as a control rather than an opaque promise. TypeScript 7 gives users a default, a way to tune it for larger machines, and a fully single-threaded mode for constrained environments or debugging. That makes the performance trade-off observable and reversible.
Closing thought
TypeScript 7’s speedup is not only a consequence of moving from JavaScript to Go. The native implementation also makes it practical to use shared-memory parallelism, and the checker pool shows the care required to use that parallelism well.
The key idea is not “run type checking on four workers.” It is “partition a program so that related work can reuse state, unbalanced work does not dominate the build, and the same configuration produces the same observable result.” That is a useful design lens for compilers, build systems, and any service that needs both concurrency and trustworthy output.
Related project
I develop Kinmokusei, a programming language with TypeScript-inspired syntax that compiles to readable Go. The project is relevant here because it works with the normal Go toolchain and ecosystem while exploring a different source-language design.
Top comments (0)