skip to content

Build Topology & Toolchain

You will learn how TypeScript scales past a single tsconfig: multi-project builds, incremental caching, and pipelines where a fast transpiler emits JavaScript while tsc only checks types. Interviewers ask because a slow or wrongly wired build is the first pain a growing codebase feels.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

10

In a tsconfig.json, what does setting `"composite": true` turn on, and what does the compiler then require of that project?

level: middleimportance: must knowfreq 58%

answer

  1. a project that can be built on its own
  2. consumers read output, not sources
  3. declaration emit is not optional here
  4. the file list must be complete
  5. referenced projects inherit the same requirement

basics

~20 s

composite: true marks a tsconfig.json as a buildable unit that other projects can reference. It forces declaration on, enables incremental build info, defaults rootDir to the config's directory, and requires every input file to be matched by files or include.

solid answer

~40 s

Setting `"composite": true` declares the project a self-contained build unit in a project-references graph. Three things follow. First, `declaration` is forced on — consumers must be able to type-check against emitted `.d.ts` files rather than the project's sources, so a composite project may not turn declaration emit off. Second, `incremental` behaviour comes with it, so the project writes a `.tsbuildinfo` file that build mode uses for up-to-date checks. Third, the compiler needs a complete, static input list: `rootDir` defaults to the directory containing the tsconfig.json, and every implementation file must be matched by `files` or `include`, otherwise you get "File … is not listed within the file list of project …". Any project named in another project's `references` array must itself be composite; that is what makes the reference legal.

code

json · 10 lines
json
{
  "compilerOptions": {
    "composite": true,
    "declarationMap": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src"],
  "references": [{ "path": "../core" }]
}

go deeper

for a junior

Recall that composite: true marks a project as one that other projects can reference, and that it forces declaration output on. Knowing it exists and pairs with a references array is enough at this level.

for a middle

Explain the three consequences — mandatory .d.ts emit, build-info for incremental checks, and a complete input list with rootDir defaulted — and say why each follows from the project being an independent build unit.

for a senior

Demonstrate diagnosis: interpret the not-listed-in-file-list error as an import crossing a package boundary without a reference, and describe the cost of consumers checking against generated declarations instead of source.

for a principal

Own the decision of where the project boundaries fall — how granular the graph should be, what each boundary buys in enforced isolation versus what it costs in build-info churn and cross-package change friction.

## The problem composite solves Without project references, a large TypeScript codebase is one compilation: `tsc` loads every source file reachable from the entry points and checks the lot. Every edit re-checks everything, and nothing enforces that the payments code may not reach into the internals of the auth code. Project references split that into several independently buildable compilations that know about each other. `"composite": true` is the flag that makes one tsconfig.json eligible to be such a unit — it is the producer side of the feature, while the `references` array is the consumer side. ```json { "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "rootDir": "./src", "outDir": "./dist" }, "include": ["src"] } ``` ## What the flag turns on **Declaration emit is mandatory.** A composite project must emit `.d.ts` files, and the compiler rejects `"declaration": false` alongside `composite`. The reason is structural: when project B references project A, B does not type-check A's sources — it consumes A's emitted declarations. Those declarations are A's public contract, and without them the reference cannot be resolved. This is also why a composite project's public API must be expressible in a `.d.ts` file; a type the compiler cannot name in output produces a declaration-emit error that a non-composite project would never have hit. **Build info is produced.** Composite projects participate in incremental building: the compiler writes a `.tsbuildinfo` file recording what it compiled and with which options, which build mode later reads to decide whether the project needs rebuilding at all. `tsBuildInfoFile` moves that file if the default location is inconvenient. **The input set must be complete and static.** `rootDir` defaults to the directory containing the tsconfig.json rather than being inferred from the common ancestor of the input files, so the output layout under `outDir` is predictable. More importantly, every implementation file the compilation ends up including must be matched by `files` or `include`. If a source file is pulled in only because something imports it, but no glob covers it, you get: ``` error TS6059: File '/repo/packages/util/src/x.ts' is not listed within the file list of project '/repo/packages/api/tsconfig.json'. Projects must list all files or use an 'include' pattern. ``` That error is almost always the real diagnosis behind "composite broke my build": a project is reaching directly into another package's sources instead of going through a reference. The fix is to add a reference and import from the package, not to widen the glob until the error stops. ## The consumer side A project declares its dependencies with `references`, each entry pointing at a directory containing a tsconfig.json or at the config file itself: ```json { "references": [{ "path": "../core" }, { "path": "../logging" }] } ``` Every referenced project must be composite; otherwise the compiler reports that the referenced project must have the `composite` setting enabled. Two useful consequences follow from the reference being explicit. Imports are now checked against a declared graph, so an unreferenced package's types are simply not visible — the module boundary is enforced by the compiler rather than by convention. And the build order is derivable: build mode topologically sorts the graph rather than asking you to script it. ## What composite does not do It does not bundle, it does not link packages, and it does not change emitted JavaScript semantics — types are still erased, and the output of a composite project is the same JavaScript you would have got otherwise, plus declarations and a build-info file. It also does not resolve package names for you: the reference tells the compiler about build ordering and about which declarations back an import, while resolving `@acme/core` to a location is still ordinary module resolution — via the workspace's node_modules layout or explicit path mapping. It is also not free. Every consumer is now type-checked against generated `.d.ts` output rather than the original source, so a stale build of an upstream project produces confusing downstream errors, and a change to an upstream public type is only visible once that project has been rebuilt. That tradeoff is the reason `declarationMap` is near-mandatory alongside `composite` in a repo people edit daily. ## Reading the flag in an interview The compact way to answer: `composite` promises the compiler that this project can be built on its own and consumed as declarations. The requirements — mandatory declaration emit, a fully enumerated input set, a defaulted `rootDir`, and a build-info file — are all consequences of that one promise.

  • Why does the compiler refuse `"declaration": false` in a project that sets `"composite": true`?
    Because a composite project is consumed through its emitted `.d.ts` files. A project that references it never type-checks its sources; it reads the declarations as the public contract. With declaration emit off there would be nothing for the consumer to check against, so the combination is rejected outright rather than failing later.
  • A package errors with "File … is not listed within the file list of project …". What is usually the underlying cause?
    Something in the project imports a source file that no `files` or `include` pattern covers — typically a deep relative import reaching into a neighbouring package's `src` instead of importing the package and declaring a reference to it. Add the reference and import through the package boundary rather than widening the glob.
  • What changes about `rootDir` when a project becomes composite?
    Without `composite`, `rootDir` is inferred from the longest common directory of the input files, so adding a file outside that ancestor silently reshuffles the output layout. With `composite`, `rootDir` defaults to the directory containing the tsconfig.json, making the mapping from inputs to `outDir` stable and predictable.

saying these in an interview costs you the question

  • Thinks composite alone builds dependencies without references
  • Believes referenced projects are type-checked from their sources
  • Says composite changes the emitted JavaScript
  • Assumes a referenced project need not be composite itself
  • Widens include globs to silence the file-list error

context

open as a page

What does running `tsc --build` (`tsc -b`) do that plain `tsc -p tsconfig.json` does not?

level: middleimportance: must knowfreq 52%

basics

~20 s

Build mode walks the references graph: it builds each referenced project first, in dependency order, and skips any project whose outputs are already newer than its inputs. Plain tsc -p compiles only the one project and assumes its dependencies are already built.

open as a page

A project bundles its TypeScript with esbuild, and the build succeeds and ships even though the source contains type errors. Why does esbuild not fail on them, and what command do teams run to catch them instead?

level: middleimportance: must knowfreq 70%

basics

~20 s

esbuild only strips type annotations file by file; it never runs TypeScript's type checker, so type errors cannot fail it. Teams add a separate step running tsc --noEmit, which type-checks the whole program and writes no output.

open as a page

In a TypeScript project, what does the `extends` field in tsconfig.json do, and relative to which directory are relative paths written inside the inherited base config resolved?

level: juniorimportance: should knowfreq 55%

basics

~20 s

The extends field makes a tsconfig.json inherit another config file's settings, which the local file can then override key by key. Relative paths written in the base file resolve against the base file's own directory, not the inheriting project's.

open as a page

Running a script with tsx succeeds, but running the same file with ts-node fails with a type error before anything executes. What is different about how the two tools run a TypeScript file?

level: juniorimportance: should knowfreq 52%

basics

~20 s

ts-node type-checks with the real TypeScript compiler before running, so a type error stops it. tsx uses esbuild to strip types with no checking, so it runs whatever parses. ts-node's --transpileOnly flag makes it behave like tsx.

open as a page

In a TypeScript repo using project references, go-to-definition on a symbol from another project lands in a generated `.d.ts` file instead of the original `.ts` source. Which compiler option addresses this, and how?

level: middleimportance: should knowfreq 42%

basics

~10 s

Enable declarationMap in the referenced project. It emits a .d.ts.map alongside each declaration file, mapping every declaration back to the source that produced it, so go-to-definition and rename follow through to the original .ts.

open as a page

Node can run a .ts file by stripping its type syntax rather than compiling it. Which TypeScript constructs cannot be handled by stripping alone, and which tsconfig flag makes the compiler report them?

level: middleimportance: should knowfreq 40%

basics

~10 s

Constructs that emit runtime code cannot simply be erased: enum, namespaces containing runtime members, constructor parameter properties, and import x = require(...). The tsconfig flag erasableSyntaxOnly makes the TypeScript compiler report exactly those.

open as a page

A TypeScript monorepo builds with `tsc -b`, yet every CI run recompiles all projects from scratch even though only one package changed. How do you diagnose and fix that?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Run tsc -b --verbose to see why each project is considered out of date. Typically CI restores neither the .tsbuildinfo files nor the emitted outputs, or a fresh checkout resets file timestamps, or a changed compiler version or config invalidates the recorded build state.

open as a page

Your CI step that runs tsc --noEmit has grown to several minutes on a mid-sized TypeScript repo. Which compiler flags and measurements would you use to find where the time is actually going?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Start with tsc --extendedDiagnostics for a breakdown of parse, bind, check and emit time plus file, type and instantiation counts. Then use --generateTrace with @typescript/analyze-trace to find the specific files and types that dominate checking.

open as a page

You lead a team whose build strips TypeScript types without checking them. How would you decide where the type-check gate runs — the editor, a pre-commit hook, or CI — and what does each placement cost?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Treat the editor as feedback, a pre-commit hook as convenience, and a blocking CI job as the only real enforcement. Put the guarantee in CI, keep the other two as fast loops, and run the identical tsc --noEmit command everywhere.

open as a page