TypeScript Tooling: tsconfig, Project References & TS 7

You add TypeScript to a project, run tsc, and suddenly the dev server that started in 200ms takes four seconds. Then someone tells you to also add esbuild. Now you have two tools that both seem to “compile TypeScript,” and it isn’t obvious why you’d want both.

The confusion comes from treating TypeScript as one thing. It’s really two jobs wearing the same name: checking your types and removing them. Modern toolchains split those jobs across different tools, and once you see the split, every piece of the config stops feeling arbitrary. This article is about the tooling — the config, the build orchestration, the runtimes. For the language itself (types, generics, narrowing), start with TypeScript for JavaScript Developers and come back here.

The one split that explains everything

TypeScript does two fundamentally different things with your code, and they have nothing to do with each other.

Type-checking reads your whole program, builds a model of every type, and tells you where you’ve made a mistake. It’s slow because it has to understand your code — resolve every import, infer every generic, follow every union. It produces no output. Its only product is a list of errors (or silence).

Emit — also called transpiling — throws the types away and hands back plain JavaScript. const x: number = 2 becomes const x = 2. That’s mechanical: no cross-file understanding required, just strip the annotations and down-level any syntax. It’s fast, and it’s exactly the kind of per-file transform that esbuild and SWC do in a millisecond.

app.tsconst n: number = 2tsc –noEmitesbuild / SWCreads the whole graphinfers + verifies typesoutput: errors, no filesreads one file at a timestrips types, down-levelsoutput: app.js, no checksthe correctness gatethe thing that ships
Same source, two independent jobs. tsc type-checks the whole graph and emits nothing; a fast transpiler like esbuild or SWC ignores types entirely and just produces JS. Neither depends on the other.

Here’s the part that surprises people: the fast transpilers can’t type-check at all. esbuild deletes the annotations without ever looking at whether your types make sense. It will happily emit JavaScript from a file riddled with type errors. That’s not a bug — it’s the deal. You trade checking for speed on the emit path, and you get your checking back from a separate tsc run.

So the modern arrangement is:

  • Your bundler (Vite, esbuild, SWC-based) does the emit — fast, on every save, no type-checking.
  • tsc --noEmit is the type-check gate — slower, run in the editor and in a dedicated CI job.

This split has a practical consequence for CI that trips teams up. Because your bundler ignores type errors, a build can succeed and deploy while your types are broken. The type-check has to be its own required job — a step that runs tsc --noEmit and fails the pipeline on a type error. Skip it and TypeScript stops being a safety net; you’ve kept the annotations and thrown away the checking.

# a dedicated CI job — the only place type errors block a merge
typecheck:
  runs-on: ubuntu-latest
  steps:
    - run: npm ci
    - run: tsc --noEmit   # bundler build is separate and does NOT check

tsconfig.json, field by field

tsconfig.json is where you tell the compiler what environment your code targets and how strict to be. Most of the file is noise once you understand a handful of fields. Here’s a config for an app whose JavaScript is produced by a bundler:

{
  "compilerOptions": {
    "target": "es2023",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2023", "dom", "dom.iterable"],
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}

target and lib

target sets the JavaScript syntax level the compiler assumes it can use — but since a bundler handles the real down-leveling, target here mostly controls what tsc allows and how it type-checks newer features. lib is different: it’s the set of built-in APIs TypeScript knows about. ["es2023", "dom"] means “assume Array.prototype.findLast exists and assume document exists.” Drop dom for a pure server project and TypeScript will correctly refuse to let you touch window.

module and moduleResolution

These two are the field people get wrong most, because the right answer depends entirely on who resolves your imports at runtime. There are two worlds.

If a bundler owns your imports — Vite, Next.js, anything with a build step producing the final bundle — use "moduleResolution": "bundler". This mode matches how bundlers actually behave: it reads package.json exports/imports, and it lets you write extensionless relative imports (import { x } from "./util") because the bundler fills in the rest.

If Node runs your files directly — a backend, a CLI, a published library — use "moduleResolution": "nodenext" with "module": "nodenext". This mirrors Node’s own resolver: it respects the type field in package.json, distinguishes import from require, and requires the file extension on relative imports (./util.js, even from a .ts file — you write the output extension).

who resolves your imports?import “./util”a bundlerVite, Next, Rollup, esbuildmoduleResolution: bundlerno extension neededreads exports mapsNode itselfbackend, CLI, librarymodule: nodenextwrite ./util.jsrespects type field
Pick moduleResolution by who resolves imports at runtime. A bundler is forgiving and reads exports maps; Node is strict and wants real file extensions.

strict

Turn it on. strict: true is a bundle of a dozen smaller flags — noImplicitAny, strictNullChecks, and friends — and it’s the entire reason to use TypeScript. Without strictNullChecks, null and undefined are assignable to everything and the type system quietly lies to you. Starting a project without strict and trying to add it later is a genuinely painful migration; start strict and stay there.

paths and skipLibCheck

paths sets up import aliases (@/components/... instead of ../../../components/...). Note that tsc only uses paths to resolve types — your bundler or runtime needs the same aliases configured too, or the code that type-checks fine will fail at runtime. skipLibCheck: true skips type-checking inside .d.ts files from your dependencies; it’s near-universal because you can’t fix bugs in someone else’s type definitions anyway, and it saves real time.

verbatimModuleSyntax and erasable syntax

Two newer flags shape how cleanly your types can be removed, which matters more than ever now that runtimes strip types themselves (more on that below).

verbatimModuleSyntax makes import/export predictable. When you import something used only as a type, you must mark it import type — and the compiler then erases those imports exactly, with no guessing about whether an import should survive into the output. It kills a whole category of “why is this module being evaluated at runtime?” bugs.

import type { User } from "./types.js";   // erased entirely
import { save } from "./db.js";           // kept — it's a real value

erasableSyntaxOnly goes further: it bans TypeScript features that aren’t pure annotations — enum, namespace with runtime code, and constructor parameter properties. These are the features that can’t be removed by simply deleting characters; they generate real JavaScript. Turning this flag on guarantees your .ts files can be run by anything that only knows how to strip types.

Declaration files (.d.ts)

A .d.ts file is types with no implementation — the shape of a module without its body. It’s how a compiled JavaScript library still gives you autocomplete and checking.

// math.d.ts — describes math.js without containing its code
export declare function add(a: number, b: number): number;
export declare const PI: number;

When you publish a library, you ship .js (what runs) plus .d.ts (what the type-checker reads). You generate the .d.ts files with tsc’s declaration option — one of the few times you do want emit, even in a bundler world, often via a dedicated tsc --emitDeclarationOnly step.

For libraries written in plain JavaScript, the types live in a separate package under the @types scope — @types/node, @types/lodash — maintained by the community. When you npm install @types/node, you’re pulling in hand-written .d.ts files. A TypeScript library bundles its own .d.ts and needs no @types package.

The way a package advertises its own types is the types field in its package.json, sitting right alongside the main/exports that point at the runtime code:

{
  "name": "my-lib",
  "exports": { ".": "./dist/index.js" },
  "types": "./dist/index.d.ts"
}

When you import my-lib, the type-checker reads dist/index.d.ts and the runtime reads dist/index.js — two files describing the same module for two different audiences. Modern packages often put the types entry inside the exports map so it can vary per entry point; the publishing workflow covers that layout in detail.

Project references: making big type-checks fast

Here’s the scaling problem. tsc type-checks by building a model of your entire program at once. On a small app that’s fine. On a large codebase or a monorepo with dozens of packages, a full check can take minutes — and it redoes all that work even when you changed one file.

Project references break the program into separate compilation units that depend on each other, each with its own tsconfig.json. TypeScript builds a dependency graph across them, and crucially, it caches the result of each unit in a .tsbuildinfo file. On the next build it compares timestamps and skips any project whose inputs haven’t changed.

// tsconfig.json at the root — orchestrates the graph
{
  "references": [
    { "path": "./packages/utils" },
    { "path": "./packages/core" },
    { "path": "./packages/web" }
  ],
  "files": []
}

Each referenced project sets "composite": true, which is what enables the caching and forces it to emit .d.ts so downstream projects can consume its types without re-checking its source. You build the whole graph with tsc --build (or tsc -b), and add --incremental behavior comes for free with composite.

first build — everything checkedutilschecked 4.2scorechecked 6.1swebchecked 5.4seach writes a .tsbuildinfo cache ↓edit web, rebuildutilsskippedcoreskippedwebre-checked 5.4s15.7s → 5.4s — only the changed project runs
Project references with the incremental cache. On the second build, tsc reads each project's .tsbuildinfo, sees that utils and core are unchanged, and skips straight to the one project that was edited.

The payoff is real: on large repos this turns a two-minute cold check into a few-second warm one, because the pipeline only pays for what actually changed. Delete the .tsbuildinfo files and you’re back to a full cold build, which is exactly what your CI does on a fresh checkout unless you cache them between runs.

incremental alone vs. project references

There’s a lighter option. Set just "incremental": true on a single project and tsc writes one .tsbuildinfo for the whole program — the second check reuses work from the first without splitting anything up. That’s the right call for a single app: less config, real speedup on repeat runs.

Project references are what you graduate to when one program is too big or when packages genuinely need separate boundaries. They add the dependency graph, the per-project caching, and the emitted .d.ts handoff between packages. The rule of thumb: one package, use incremental; many packages that depend on each other, use references.

Both share a base config with extends, so strictness and target don’t drift between packages:

// tsconfig.base.json — one source of truth
{ "compilerOptions": { "strict": true, "target": "es2023", "moduleResolution": "bundler" } }
// packages/core/tsconfig.json
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true } }

Running .ts files without a build

For a long time, running a TypeScript file meant compiling it first or reaching for ts-node. Two things changed that.

tsx is a small runner that transpiles on the fly with esbuild under the hood. npx tsx server.ts just works — it strips types and runs, no config, no output files. It’s the fast, zero-setup way to run scripts and dev servers. Remember the split: tsx does not type-check, it only strips and runs.

Node’s native type stripping brought this into the runtime itself. As of Node 24 (the current LTS in 2026), running node server.ts works out of the box — Node strips the type annotations and executes the result, no flag required. Under the hood it replaces each type annotation with whitespace and runs the JavaScript that’s left. Like tsx, it performs zero type-checking.

The pattern that falls out of all this: use tsx or node to run your TypeScript instantly during development, and keep tsc --noEmit as the separate check that runs in your editor and in CI. Nobody’s runtime is checking types anymore, and that’s fine — checking was always a different job.

TypeScript 7: the Go-native compiler

The tsc you’ve used for a decade is itself written in TypeScript, running on Node. That’s elegant but slow — type-checking a huge codebase means running a lot of interpreted JavaScript. Microsoft is fixing that by porting the compiler to Go.

The result is TypeScript 7, and it reached Release Candidate in June 2026 (GA is expected within roughly a month of the RC, per Microsoft, though the exact date isn’t committed). During the preview period the native binary shipped under the name tsgo; the RC ships it as the standard tsc. The headline number: Microsoft’s own benchmark loads the VS Code codebase (~1.5M lines) in about 7.5 seconds instead of ~78 — roughly 10x — and reports similar speedups across other large projects. Part of that win comes from Go’s speed, and part from doing parsing, checking, and emit in parallel across cores.

VS Code (~1.5M lines) — full type-checktsc 6 (JS)~78stsc 7 (Go)~7.5ssame errors, same semantics — it is a port, not a redesign
Microsoft's published benchmark: type-checking the VS Code codebase. The Go-native compiler in TypeScript 7 lands the same result about ten times faster.

What matters for you day to day:

  • What changes: cold type-checks and editor responsiveness get dramatically faster. The multi-minute CI check and the laggy “loading types…” pause in your editor largely go away.
  • What doesn’t: the type system is unchanged. TS 7 is a port that aims for identical type-checking behavior — the same programs pass, the same errors appear. Your tsconfig.json carries over. It’s a speed upgrade, not a language change.

Putting the pieces together

A typical 2026 setup uses each tool for the one job it’s best at:

# dev: run instantly, no type-check, no build
npx tsx watch src/server.ts

# emit for production: bundler strips types, fast
vite build            # or esbuild / swc

# the correctness gate: check types, emit nothing
tsc --noEmit          # tsc -b for a project-referenced monorepo

# publish a library: the one time you want tsc to emit
tsc --emitDeclarationOnly   # ship .js from the bundler + .d.ts from tsc

None of these steps overlap. The bundler never checks types; tsc never ships your runtime JavaScript; tsx never blocks on a check. Once you internalize that type-checking and emit are separate jobs, the whole toolchain reads as a set of specialists instead of a pile of redundant compilers.

your .ts sourcetsx / noderun in devstrips, no checkbundleremit prod JSstrips, no checktsc –noEmitthe gatechecks typestsc -demit.d.tsthree tools move or strip code; only one asks if the code is correctthat separation is the whole design
One source of TypeScript, four specialists — each doing exactly one job. Only the tsc gate looks at whether your types are correct; everything else just moves or strips code.

Summary

  • TypeScript is two jobs: type-checking (slow, whole-program, produces only errors) and emit (fast, per-file, strips types to JS). Modern toolchains run them with different tools.
  • Fast transpilers (esbuild, SWC) do the emit and cannot type-check; tsc --noEmit is the separate check that runs in your editor and CI.
  • In tsconfig.json: strict: true always; pick moduleResolution: "bundler" when a bundler resolves imports and nodenext when Node does; lib declares available APIs but is not a polyfill.
  • verbatimModuleSyntax makes imports erase predictably; erasableSyntaxOnly bans non-erasable features (enum, parameter properties) so any type-stripper can run your files.
  • .d.ts files are types without implementation; ship them with a published library, or install community ones from @types for plain-JS packages.
  • Project references + .tsbuildinfo turn a whole-repo check into an incremental one, skipping unchanged projects and cutting minutes to seconds.
  • tsx and Node 24’s native type stripping run .ts files directly with no build — neither checks types.
  • TypeScript 7 (RC June 2026, GA close behind) is a Go-native port of the compiler, ~10x faster type-checking with identical semantics — a speed upgrade, not a language change.