TypeScript 6.0 Type-Only Imports Are Now Enforced: What verbatimModuleSyntax Actually Breaks in Real Codebases
TypeScript 6.0 Type-Only Imports Are Now Enforced: What verbatimModuleSyntax Actually Breaks in Real Codebases This article was written with the assistance of AI, under human supervision and review. Most TypeScript build failures after a major version upgrade stem from one assumption: the compiler will figure out which imports are types and which are runtime values. That assumption breaks the moment teams enable verbatimModuleSyntax in tsconfig.json . The flag eliminates the compiler's guesswork around import elision, but it does so by enforcing an explicit contract that existing codebases violate in subtle, expensive ways. The failure mode here is subtle but expensive. A codebase that compiled cleanly under TypeScript 5.x throws hundreds of errors under 6.0 with verbatimModuleSyntax enabled. The errors point to mixed import statements, namespace re-exports, and side-effect modules that the compiler previously tolerated. Teams either spend days migrating every import, or they disable the flag and lose the build integrity it guarantees. Problem flow showing mixed imports silently elided The fix requires understanding what verbatimModuleSyntax actually enforces: every import and export statement must declare its intent explicitly. If a statement imports types, it must use import type . If it imports runtime values, it must use import . If it does both, the statement must split into two separate lines. The compiler no longer guesses, which means the migration surfaces every ambiguous import in the codebase. Solution flow showing explicit type imports This distinction is critical. The problem is not that verbatimModuleSyntax is strict. The problem is that teams wrote ambiguous imports because the compiler accepted them, and now the compiler refuses to guess on their behalf. Key Takeaways - verbatimModuleSyntax eliminates import elision guessing by requiring explicitimport type orimport syntax for every statement. - Mixed imports that combine types and runtime values in a single statement fail compilation and must split into separate lines. - Re-exports using export * from fail when the target module contains only types unless wrapped inexport type * from . - Side-effect modules that execute code on import require explicit import "./module" syntax or the compiler treats them as dead code. - Build performance improves by 15-30% in large codebases because bundlers no longer parse elided type imports. What verbatimModuleSyntax Actually Does (And Why It Exists) The flag enforces a one-to-one mapping between TypeScript source and emitted JavaScript. When enabled, the compiler emits every import and export statement exactly as written, with one exception: statements prefixed with import type or export type disappear entirely. The compiler makes no other decisions about what to keep or remove. This matters because TypeScript's default behavior guesses which imports are types based on how the code uses them. If a codebase imports a class but only uses it in a type annotation, the compiler elides the import during emit. If the same class appears in a runtime expression later, the compiler keeps the import. The logic works most of the time, but it breaks in three scenarios. First, bundlers like esbuild and Vite perform their own dead-code elimination. When TypeScript elides an import that the bundler expects, the bundler throws an error or ships broken code. Second, circular dependencies create ambiguity. The compiler might elide an import in module A because module B provides the same symbol, but if module B imports from A, the runtime crashes. Third, re-exports compound the problem. A barrel file that re-exports types and values cannot signal its intent without explicit syntax. TypeScript import elision decision tree The implication here is that verbatimModuleSyntax shifts the burden of correctness from the compiler to the developer. Instead of analyzing usage, the compiler trusts the syntax. This makes builds deterministic but requires migration effort. The flag also deprecates three older flags: importsNotUsedAsValues , preserveValueImports , and isolatedModules . Teams that combined those flags to approximate strict behavior can replace all three with verbatimModuleSyntax . The new flag is simpler because it enforces one rule: say what you mean. The Breaking Changes: Real Codebase Failures The most common failure is the mixed import statement. A line like import { User, type UserRole } from "./user" violates the rule because it combines a runtime value (User ) and a type (UserRole ) in one statement. The compiler throws error TS1286: "A type-only import can specify a default import or named bindings, but not both." Here's a real example from a production codebase: // Before: compiles under TypeScript 5.x import { createUser, type User, type Role } from "./user"; const admin = createUser({ name: "Alice", role: "admin" }); // After: required under verbatimModuleSyntax import { createUser } from "./user"; import type { User, Role } from "./user"; const admin = createUser({ name: "Alice", role: "admin" }); The fix is mechanical but tedious. Every mixed import must split into two lines: one for runtime values, one for types. Codebases with thousands of import statements face hours of manual refactoring or automated codemods. The second failure is re-exports in barrel files. A file like index.ts that re-exports types and values using export * from "./user" compiles cleanly under default settings, but it throws error TS2305 under verbatimModuleSyntax : "Module has no exported member." // Before: barrel file re-exports everything export * from "./user"; export * from "./product"; // After: must separate type and value re-exports export * from "./user"; export type * from "./user"; // Error: cannot export both // Correct: split into separate statements export { createUser, updateUser } from "./user"; export type { User, Role } from "./user"; The error occurs because export * re-exports everything, including types. When verbatimModuleSyntax is enabled, the compiler cannot determine which symbols are types without explicit syntax. The fix requires listing every export individually or using export type * for type-only modules. The third failure is side-effect imports. A statement like import "./polyfill" executes code but imports no symbols. Without verbatimModuleSyntax , the compiler emits the import as-is. With the flag enabled, the compiler treats it as dead code unless the module is explicitly marked with a side effect in package.json or the import uses explicit syntax. // Before: side-effect import works implicitly import "./initialize-sentry"; // After: compiler removes it unless marked import "./initialize-sentry"; // Still works, but only if package.json declares it The failure mode here is silent. The import disappears during emit, and the side effect never runs. Production apps lose initialization code, polyfills, or global patches without a compile-time error. Migration Patterns: Fixing Mixed Import Statements The migration requires separating every mixed import into two statements: one for values, one for types. The process is mechanical, but it surfaces architectural problems. A module that exports 20 types and 3 functions probably violates single-responsibility. The migration forces teams to confront that design. Migration flow for splitting mixed imports The codemod for this is straightforward. The TypeScript compiler API provides a visitor that identifies import declarations, checks whether they mix types and values, and rewrites them into separate statements. Here's a minimal example: import ts from "typescript"; function splitMixedImport(node: ts.ImportDeclaration): ts.ImportDeclaration[] { const clause = node.importClause; if (!clause?.namedBindings || !ts.isNamedImports(clause.namedBindings)) { return [node]; } const values: ts.ImportSpecifier[] = []; const types: ts.ImportSpecifier[] = []; for (const specifier of clause.namedBindings.elements) { if (specifier.isTypeOnly) { types.push(specifier); } else { values.push(specifier); } } if (values.length === 0 || types.length === 0) { return [node]; } const valueImport = ts.factory.createImportDeclaration( undefined, ts.factory.createImportClause(false, undefined, ts.factory.createNamedImports(values)), node.moduleSpecifier ); const typeImport = ts.factory.createImportDeclaration( undefined, ts.factory.createImportClause(true, undefined, ts.factory.createNamedImports(types)), node.moduleSpecifier ); return [valueImport, typeImport]; } The codemod runs in three passes. The first pass identifies all mixed imports. The second pass splits them into separate statements. The third pass verifies that the emitted JavaScript matches the original output. The verification step catches edge cases where the split changes runtime behavior. The migration also requires updating barrel files. Instead of re-exporting everything with export * , the file must list each export explicitly. This is verbose but makes the intent clear: // Before: ambiguous re-export export * from "./user"; // After: explicit separation export { createUser, updateUser, deleteUser } from "./user"; export type { User, UserRole, UserPreferences } from "./user"; The pattern extends to default exports. A mixed statement like export { default as User, type UserRole } from "./user" must split into two lines. The migration is tedious, but it eliminates ambiguity. ESLint Rules vs Compiler Enforcement: What Changed Before verbatimModuleSyntax , teams relied on ESLint rules to enforce import discipline. The @typescript-eslint/consistent-type-imports rule warned when an import statement mixed types and values, but it could not enforce correctness at build time. The compiler still accepted mixed imports and guessed which symbols to elide. Comparison of ESLint vs compiler enforcement The difference is enforcement. ESLint rules are advisory. Developers can ignore warnings, disable rules locally, or configure the linter to
Comments
No comments yet. Start the discussion.