A library builds cleanly with tsc, then somebody switches the build to esbuild for speed and a consumer reports a runtime failure:

SyntaxError: The requested module './types.js' does not provide an export named 'ClientOptions'

Nothing about the source changed. What changed is that the new build compiles one file at a time and cannot know that ClientOptions was a type. Two compiler flags make that class of failure impossible to introduce.


Root Cause

tsc compiles a program: it has type information for every file at once, so it can tell that a re-exported name is a type and elide it from the output. Single-file transpilers — esbuild, SWC, Babel — compile each file in isolation with no cross-file type knowledge, so they must guess, and they guess by preserving what they see.

isolatedModules restricts your source to constructs whose emit does not depend on that missing information. verbatimModuleSyntax goes further: it makes tsc itself emit import and export statements verbatim, removing only those explicitly marked type, so the output is identical regardless of which tool produced it.


Minimal Reproduction

// src/types.ts
export interface ClientOptions { baseUrl: string; retries?: number; }

// src/index.ts — re-export without the type keyword
export { ClientOptions } from "./types.js";
export { createClient } from "./client.js";
npx tsc && grep "ClientOptions" dist/index.js || echo "tsc elided the type re-export"
npx esbuild src/index.ts --format=esm --outfile=/tmp/esbuild-out.mjs
grep "ClientOptions" /tmp/esbuild-out.mjs && echo "esbuild preserved it — runtime error follows"
tsc elided the type re-export
export { ClientOptions } from "./types.js";
esbuild preserved it — runtime error follows

Two tools, one source file, two different outputs. Only one of them runs.


What Each Flag Enforces

Two flags, two jobs isolatedModules rejects source constructs that cannot be compiled one file at a time. verbatimModuleSyntax emits imports and exports exactly as written, removing only those marked with the type keyword. One constrains input, the other fixes output isolatedModules rejects: re-export of a type without the type keyword rejects: const enum across files rejects: ambient namespace merging emits nothing differently verbatimModuleSyntax emits: imports exactly as written removes: only type-marked ones no more silent elision side-effect imports always survive changes the published output

Step-by-Step Fix

1. Enable isolatedModules and fix what it reports

   "compilerOptions": {
+    "isolatedModules": true,
     "declaration": true
   }
npx tsc --noEmit
src/index.ts:4:10 - error TS1205: Re-exporting a type when 'isolatedModules' is enabled
requires using 'export type'.

Every diagnostic has exactly one correct fix, which makes the migration mechanical:

- export { ClientOptions } from "./types.js";
+ export type { ClientOptions } from "./types.js";
  export { createClient } from "./client.js";

2. Replace const enums and namespace merging

isolatedModules also rejects const enum used across files, because inlining its values requires whole-program knowledge:

- export const enum LogLevel { Debug, Info, Warn }
+ export const LogLevel = { Debug: 0, Info: 1, Warn: 2 } as const;
+ export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];

The replacement is a real runtime object plus a type of the same name, which behaves identically for consumers and compiles under any tool.

3. Enable verbatimModuleSyntax and mark type-only imports

   "compilerOptions": {
     "isolatedModules": true,
+    "verbatimModuleSyntax": true,
- import { ClientOptions, createClient } from "./client.js";
+ import type { ClientOptions } from "./client.js";
+ import { createClient } from "./client.js";

Expected result: the emitted JavaScript contains the value import and not the type import — and now the same is true whichever tool compiled it.

What survives into the emitted module graph An import without the type keyword becomes a real runtime import that pulls its module into the graph. A type-marked import is removed entirely, so the module is never evaluated. The keyword decides whether a module is loaded at all import { Options } from "./x.js" emitted as a real import ./x.js is evaluated at load its side effects run and it resists elimination import type { Options } from "./x.js" removed from the output ./x.js is never loaded no side effects, no cost the correct form for types

HAZARD PREVENTION

Symptom: After enabling verbatimModuleSyntax, a package that previously worked now fails at runtime with ERR_REQUIRE_ESM in a CommonJS consumer.

Root cause: An import of a type from an ESM-only module was previously elided by the compiler and is now preserved, so the CommonJS build attempts to load a module it never used to touch.

Fix: Mark the import import type so it is removed again. This is the flag doing its job — the previous behaviour depended on an elision that a different compiler would not have performed.

4. Add an automated check that both stay on

{
  "scripts": {
    "check:flags": "node -e \"const c=require('./tsconfig.build.json').compilerOptions; if(!c.isolatedModules||!c.verbatimModuleSyntax) { console.error('both flags must remain enabled'); process.exit(1); } console.log('flags ok')\""
  }
}

Verification

# the same source produces equivalent output under both toolchains
npx tsc --outDir /tmp/tsc-out
npx esbuild src/index.ts --format=esm --outfile=/tmp/esbuild-out.mjs
diff <(grep -oE 'from "[^"]+"' /tmp/tsc-out/index.js | sort -u) \
     <(grep -oE 'from "[^"]+"' /tmp/esbuild-out.mjs | sort -u) \
  && echo "module graphs agree"

# no type-only import survives as a runtime import
grep -n "import {" dist/index.mjs

Expected: module graphs agree, and an import list containing only modules with runtime values.


Edge Cases / Gotchas

  • export * from a module with only types becomes an error. Under isolatedModules it cannot be resolved per-file; replace it with explicit export type { … }.
  • Decorator metadata needs whole-program information. emitDecoratorMetadata is incompatible with single-file transpilation, so a library using it cannot fully benefit from these flags.
  • A dependency’s const enum is still inlined by tsc. Your own code is constrained, but a dependency shipping one can still produce tool-dependent output; preserveConstEnums mitigates it partially.
  • import type on a side-effect import removes the side effect. If a module was imported for its registration behaviour rather than its types, marking it type silently deletes that behaviour.
  • Editors may auto-import without the keyword. Configure the editor’s TypeScript preference to prefer type-only imports, or the migration regresses one auto-import at a time.

Both flags are worth enabling even on a project that will never switch away from tsc, because the constraint they impose is a useful one on its own: source in which every import states whether it exists at runtime is easier to reason about, easier to tree-shake, and immune to a whole category of surprise when the build tooling eventually changes. The migration cost is a few hours of mechanical edits, paid once.


Frequently Asked Questions

What problem does isolatedModules actually solve?

It restricts your source to constructs compilable one file at a time, so single-file transpilers that never see the whole program cannot produce broken output.

How is verbatimModuleSyntax different from isolatedModules?

isolatedModules restricts what you may write; verbatimModuleSyntax changes what is emitted, removing only type-marked imports and exports so the module graph is predictable.

Do these flags change my published JavaScript?

verbatimModuleSyntax does — previously elided imports may now be preserved. isolatedModules changes nothing on its own.

Is the type keyword required on every type-only import?

Under verbatimModuleSyntax, yes for anything that should not exist at runtime; without it the import is emitted and the module is loaded.

Can these flags be adopted incrementally?

Yes — enable isolatedModules first and fix its mechanical diagnostics, then verbatimModuleSyntax, whose behaviour assumes those type keywords exist.



↑ Back to Optimizing tsconfig.json for Library Distribution