Skip to content
TypeScript Releases

TypeScript 6.0 upgrade guide: breaking changes and CI guardrails

Upgrading TypeScript in production isn’t about new syntax. It’s about avoiding surprise emit changes, blocking accidental public API drift, and keeping editor/project references working across a monorepo. This TypeScript 6.0 upgrade guide is a production-first playbook: a breaking-change triage matrix (symptom → cause → fix), tsconfig diffs you can actually apply, and CI guardrails that […]

Jack Pauley May 5, 2026 6 min read
TypeScript 6.0 upgrade guide infographic

Upgrading TypeScript in production isn’t about new syntax. It’s about avoiding surprise emit changes, blocking accidental public API drift, and keeping editor/project references working across a monorepo.

This TypeScript 6.0 upgrade guide is a production-first playbook: a breaking-change triage matrix (symptom → cause → fix), tsconfig diffs you can actually apply, and CI guardrails that catch risky changes before they hit main.

Contents

Pre-upgrade checklist (what to lock down before bumping)

Do these before changing the TypeScript version. They reduce noise and make failures actionable.

  1. Freeze your build inputs: pin Node, package manager, and lockfile in CI. TypeScript upgrades amplify drift from toolchain changes.
  2. Decide what “correct” output means:
    • If you publish packages: treat .d.ts as your contract and gate it.
    • If you deploy apps: treat emitted JS + sourcemaps as the contract and at least gate the JS entrypoints.
  3. Split typecheck from build: you want a fast tsc --noEmit job that fails early, plus an “emit diff” job that catches subtle output changes.
  4. Baseline your current outputs: generate a snapshot of emitted declarations (and optionally JS) from your current compiler version so your CI can diff after the bump.
  5. Know your module/runtime targets: Node ESM vs CJS and bundler vs runtime compilation will determine which tsconfig changes are safe.

If you’re operating a monorepo, also standardize how packages reference each other and keep project references sane. ReleaseRun has a few adjacent operator-focused guides worth skimming for process shape:

Breaking-change matrix: symptom → cause → fix

This matrix is designed for incident response: you upgraded, CI lit up, and you need a deterministic path to green.

Category Symptom (what you see) Likely cause in TS upgrade Fix (what to change)
Module typing / exports TS1484/TS1479 style errors around type-only imports/exports, or runtime imports appear/disappear Stricter enforcement around type-only positions combined with verbatimModuleSyntax and modern ESM settings Convert to import type/export type. If you rely on elided imports, review verbatimModuleSyntax behavior and bundler expectations.
Node ESM resolution Build passes but runtime fails with ERR_MODULE_NOT_FOUND or missing extension complaints Moving to NodeNext/Bundler resolution exposes extension/exports-map requirements; older “classic” resolution let invalid paths slide For Node ESM: use moduleResolution: "nodenext" and fix relative imports to include correct extensions at runtime (or ensure your bundler rewrites). Respect package.json exports.
Lib/DOM typing drift New type errors in React/DOM code (event types, fetch, URL, etc.) Updated lib definitions bundled with TS 6.0 tighten or shift types Pin lib explicitly (don’t rely on implicit defaults), and upgrade dependent @types packages in lockstep.
Decorators/metadata Decorator code breaks or emits different JS Decorator emit + lib changes over time; TS 6.0 may surface mismatches earlier Be explicit with experimentalDecorators/emitDecoratorMetadata. Gate emitted JS if you ship runtime decorators. Prefer Babel/tslib patterns consistently.
Project references / build mode VSCode shows types, but tsc -b fails (or vice versa); incremental caches thrash Subtle changes in build graph/incremental behavior + misaligned tsconfigs between referenced projects Normalize shared base tsconfig, ensure every referenced project has composite: true, and don’t mix incompatible module/moduleResolution settings across the graph.
JS emit drift No type errors, but bundle size or runtime behavior changes Emit-affecting flags or inference changes; also changes from importsNotUsedAsValues / verbatimModuleSyntax interactions Run an emit diff in CI (see recipes below). If you need strict stability, gate JS outputs (or at least public entrypoints) and fix root causes by pinning relevant compiler options.

Symptom → cause → fix examples (copy/paste)

1) Runtime import disappears after upgrade

// before
import { Foo } from "./foo";

export function useFoo(x: Foo) {
  return x;
}

Symptom: runtime code no longer imports ./foo (or the opposite: extra import appears), changing side effects or tree-shaking.

Cause: the compiler decides the import is type-only and elides it, or verbatimModuleSyntax changes elision rules relative to older defaults.

Fix: make intent explicit:

import type { Foo } from "./foo";

export function useFoo(x: Foo) {
  return x;
}

If ./foo must execute for side effects, separate it:

import "./foo";
import type { Foo } from "./foo";

2) Node ESM can’t find module at runtime

// compiles, then runtime blows up
import { thing } from "./thing";

Symptom: Error [ERR_MODULE_NOT_FOUND]: Cannot find module .../thing

Cause: Node ESM requires fully specified paths (often ./thing.js) depending on how you run emitted code. Older setups compiled fine but produced output Node won’t resolve.

Fix: align module/moduleResolution with your runtime, and make import specifiers runtime-correct. For pure Node ESM running emitted JS directly:

import { thing } from "./thing.js";

3) VSCode is green, CI is red with project references

Symptom: editor uses inferred settings, but CI tsc -b uses referenced project tsconfigs and fails.

Cause: referenced projects aren’t composite, or configs diverged between packages after the TS bump.

Fix: enforce a base config and require composite for referenced packages.

tsconfig diffs for Node + React (and VSCode project references)

Don’t “accept whatever the new defaults are”. In production you want explicit, reviewable compiler behavior. The diffs below are common stabilization moves when moving to a new major.

Baseline: shared base config (monorepo-friendly)

// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": false,

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    "noEmitOnError": true,
    "forceConsistentCasingInFileNames": true,

    "incremental": true,
    "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo"
  }
}

Node (library) diff: CJS vs ESM done intentionally

If you publish for Node, decide whether you are CJS, ESM, or dual. The worst outcome is “compiles in CI, breaks in Node”.

Goal Recommended tsconfig knobs Impact
Node CJS library "module": "commonjs", "moduleResolution": "node" Stable for legacy Node. Don’t pretend it’s ESM; avoid interop surprises.
Node ESM library "module": "nodenext", "moduleResolution": "nodenext", "verbatimModuleSyntax": true Forces you to write imports/exports that match Node’s rules and your package.json exports.
Bundler-targeted package "moduleResolution": "bundler", often paired with modern module Optimizes for bundlers that understand TS/exports conditions; not the same as “run in Node”.

Example diff: Node ESM package

 {
   "extends": "./tsconfig.base.json",
   "compilerOptions": {
-    "module": "esnext",
-    "moduleResolution": "node",
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "verbatimModuleSyntax": true,
     "outDir": "dist",
     "rootDir": "src",
     "target": "ES2022",
     "lib": ["ES2022"],
+    "types": ["node"]
   },
   "include": ["src"]
 }

What this changes:

  • nodenext makes TypeScript respect Node’s ESM/CJS rules and package.json type/exports.
  • verbatimModuleSyntax makes import/export intent explicit. You’ll fix more code now, but you’ll stop shipping “accidentally working” builds.
  • types: ["node"] prevents implicit global types drift from other tooling.

React app diff: TS is for types, bundler is for runtime

For React (Vite/Next/Webpack), TypeScript generally should not be in charge of final runtime resolution. Keep tsconfig aligned with how your bundler resolves modules.

 {
   "extends": "./tsconfig.base.json",
   "compilerOptions": {
-    "jsx": "react",
+    "jsx": "react-jsx",
     "target": "ES2022",
     "lib": ["ES2022", "DOM", "DOM.Iterable"],
     "module": "ESNext",
-    "moduleResolution": "node",
+    "moduleResolution": "bundler",
     "noEmit": true,
+    "types": ["vite/client"]
   },
   "include": ["src", "vite-env.d.ts"]
 }

VSCode + project references: make the graph explicit

When project references are involved, VSCode and tsc -b behave best when every package has a real tsconfig.json with composite enabled.

// packages/foo/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [{ "path": "../bar" }],
  "include": ["src"]
}

If your repo uses a root solution config, keep it boring:

// tsconfig.json (solution)
{
  "files": [],
  "references": [
    { "path": "packages/bar" },
    { "path": "packages/foo" }
  ]
}

Official docs to cross-check when you hit module/resolution edge cases:

CI guardrails: “diff mode” tsc + emit change blockers (pnpm/npm/yarn)

A production upgrade needs two separate gates:

  • Type gate: tsc --noEmit blocks new type errors.
  • Emit gate: blocks changes to emitted outputs you care about (usually .d.ts for libs, sometimes JS entrypoints for apps/tools).

1) Type gate (fast, deterministic)

// package.json
{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json --noEmit"
  }
}

2) Emit gate: declarations snapshot + API diff

This pattern works well for libraries and internal packages: emit declarations into a temp directory and diff them against a committed baseline (or compare between base branch and PR).

Step A: add a “declarations-only build” config

// tsconfig.dts.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "emitDeclarationOnly": true,
    "declaration": true,
    "declarationMap": false,
    "stripInternal": false,
    "outDir": "./.dts-out"
  },
  "include": ["src"]
}

Step B: add scripts to generate and diff

// package.json
{
  "scripts": {
    "dts:gen": "rimraf .dts-out && tsc -p tsconfig.dts.json",
    "dts:diff": "node ./scripts/dts-diff.mjs"
  },
  "devDependencies": {
    "rimraf": "^6.0.0"
  }
}

Step C: implement a minimal diff script (no dependencies; uses git diff --no-index)

// scripts/dts-diff.mjs
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";

const BASELINE_DIR = ".dts-baseline";
const OUT_DIR = ".dts-out";

if (!existsSync(BASELINE_DIR)) {
  console.error(`Missing ${BASELINE_DIR}. Create it by copying a known-good .dts-out snapshot.`);
  process.exit(2);
}

try {
  execSync(`git diff --no-index -- ${BASELINE_DIR} ${OUT_DIR}`, { stdio: "inherit" });
  // exit code 0 means no diff
} catch (e) {
  // git diff exits 1 when there are differences
  process.exit(1);
}

How to create the baseline (run on main before the TS 6.0 bump):

# generate .d.ts
pnpm run dts:gen

# store the baseline
rm -rf .dts-baseline
cp -R .dts-out .dts-baseline

git add .dts-baseline
git commit -m "chore: snapshot declaration baseline"

Copy-paste CI recipes (pnpm / npm / yarn)

pnpm (GitHub Actions)

name: ci
on: [pull_request]

jobs:
  typescript:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      # Type gate
      - run: pnpm run typecheck

      # Emit gate (declarations)
      - run: pnpm run dts:gen
      - run: pnpm run dts:diff

npm (GitHub Actions)

name: ci
on: [pull_request]

jobs:
  typescript:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm run dts:gen
      - run: npm run dts:diff

yarn (GitHub Actions)

name: ci
on: [pull_request]

jobs:
  typescript:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
      - run: yarn install --immutable
      - run: yarn typecheck
      - run: yarn dts:gen
      - run: yarn dts:diff

Blocking risky JS emit changes (when declarations aren’t enough)

If you ship runtime JS from TypeScript (CLIs, server apps without a bundler, libraries consumed as JS), also gate JS entrypoints. Two pragmatic options:

  • Diff a small allowlist: only compare dist/index.js, dist/cli.js, and sourcemaps. This catches breaking output changes without diffing the world.
  • API Extractor (for libraries): generate and diff an API report rather than raw .d.ts files. This reduces churn. See API Extractor.

Migration path: staged rollout for monorepos

  1. Create a TS 6.0 branch and upgrade only the compiler first. Don’t upgrade ESLint/ts-jest/bundlers in the same PR unless you have to.
  2. Run the type gate (tsc --noEmit) and fix straightforward errors first (type-only imports, lib changes, resolution issues).
  3. Enable the emit gate and treat diffs as a contract review:
    • Expected public API changes: update baseline with an explicit PR note.
    • Unexpected diffs: find the compiler option or module mode causing it and pin behavior.
  4. Normalize tsconfigs across packages before touching code. Major upgrades punish configuration drift.
  5. Roll forward by dependency direction: leaf packages first, then shared libs, then apps. This keeps the breakage surface smaller.
  6. Cut a canary release (or deploy behind a flag) if you have runtime JS risk. Your CI gates reduce surprises; they don’t eliminate them.

Bottom Line

Use TS 6.0 in production when you can answer two questions with automation: (1) “Do we still typecheck?” and (2) “Did our emitted contract change?” The breaking-change matrix gets you to green quickly; the tsconfig diffs remove ambiguity; the CI guardrails keep the upgrade from turning into a recurring fire drill.

🛠️ Try These Free Tools

📦 Dependency EOL Scanner

Paste your dependency file to check for end-of-life packages.

🗺️ Upgrade Path Planner

Plan your upgrade path with breaking change warnings and step-by-step guidance.

🔧 GitHub Actions Version Auditor

Paste your workflow YAML to audit action versions and pinning.

See all free tools →

Stay Updated

Get the best releases delivered monthly. No spam, unsubscribe anytime.

By subscribing you agree to our Privacy Policy.