Concept
The beginner framing: TypeScript sits on top of JavaScript's two module systems, CommonJS (require/module.exports) and ES Modules (import/export), and adds its own type-specific concerns on top: importing types that need to disappear entirely from the compiled output, and configuring exactly how module resolution should behave for a given build target.
import type: type-only imports, fully erased
// types.ts
export interface Config { timeout: number }
// app.ts
import type { Config } from "./types";
export function setup(config: Config) { /* ... */ }Confirmed by compiling this and inspecting the emitted JavaScript directly: the import type line produces zero trace in the compiled output, no require("./types"), nothing. Since Config is only ever used as a type annotation (never as a runtime value), TypeScript can safely erase the entire import. Writing import type explicitly (rather than a regular import that happens to only be used for types) documents this intent and, combined with verbatimModuleSyntax below, becomes an enforced requirement rather than just a convention.
verbatimModuleSyntax: the modern rule, replacing esModuleInterop folklore
// mixed.ts
export interface Foo { x: number }
export const bar = 42;
// usage.ts
import { Foo, bar } from "./mixed"; // Foo is a TYPE, bar is a VALUE, mixed importerror TS1484: 'Foo' is a type and must be imported using a type-only
import when 'verbatimModuleSyntax' is enabled.Confirmed by compiling exactly this: with verbatimModuleSyntax enabled, TypeScript requires every type-only import to be explicitly marked, either as a dedicated import type { Foo } from "./mixed" or inline within a mixed import, import { type Foo, bar } from "./mixed". This flag exists specifically to eliminate a whole class of confusing interop questions that used to revolve around esModuleInterop and related settings, rather than TypeScript guessing at your intent based on usage analysis, verbatimModuleSyntax makes the source code itself the single source of truth: what's written is exactly what gets emitted, type-only imports are marked explicitly, and nothing is silently elided or transformed based on inference. This is worth flagging as a genuine version-currency shift, a lot of older troubleshooting advice about module interop no longer applies the same way once this flag is in play.
Module resolution modes: bundler vs. node16/nodenext
// helper.ts
export const x = 1;
// main.ts, with moduleResolution: "bundler"
import { x } from "./helper"; // ✅ extensionless, fine
// main.ts, with moduleResolution: "nodenext" (and package.json "type": "module")
import { x } from "./helper"; // ❌ error TS2835
import { x } from "./helper.js"; // ✅ required, even though the source file is .ts!Confirmed by compiling both configurations: moduleResolution: "bundler" (the modern default for projects built with Vite, webpack, esbuild, and similar tools) allows extensionless relative imports, matching what those bundlers themselves generally support. moduleResolution: "nodenext" (matching Node's own actual ESM resolution rules) is stricter, relative imports in an ESM context require an explicit file extension, and confirmed by the compiler's own suggested fix, that extension is .js, even though the actual source file is .ts, this reflects what the compiled output will be named, not the source file you're currently editing, which is a genuinely common point of confusion the first time it's encountered.
Namespaces: legacy, predating ES modules
namespace Shapes {
export interface Circle { radius: number }
export function area(c: Circle) { return Math.PI * c.radius ** 2; }
}
const c: Shapes.Circle = { radius: 5 };
console.log(Shapes.area(c));namespace (and its older spelling, module, now essentially unused) predates ES modules as TypeScript's original way to organize and namespace code, back before JavaScript itself had any standard module system. It still compiles and works today, and, as covered in infer, Variance & Declaration Merging, can even merge with same-named functions or classes. But for new code, ES modules (import/export) are the standard, and namespaces are now specifically a legacy pattern, mainly encountered maintaining older codebases or certain global-script (non-module) type declaration scenarios, not something to reach for in new TypeScript projects.
Try It
Predict the outcome before checking the solution.
// with verbatimModuleSyntax enabled
export interface Options { debug: boolean }
export function configure(opts: Options) { /* ... */ }import { Options, configure } from "./config";Solution
This fails to compile with the same TS1484 error covered above, Options is a type, imported without the required type modifier, and verbatimModuleSyntax enforces that explicitly. The fix: import { type Options, configure } from "./config"; (or splitting into a separate import type { Options } from "./config"; line), marking exactly which imported names are types versus runtime values.
Implement It Yourself
Set up a small module boundary demonstrating import type combined with a value import from the same file, correctly under verbatimModuleSyntax:
// api.ts
export interface User { id: number; name: string }
export async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// consumer.ts
import { type User, fetchUser } from "./api";
async function greet(id: number) {
const user:
This is the idiomatic modern pattern: a single import statement cleanly distinguishing the type-only name (User, erased entirely at compile time) from the runtime value (fetchUser, an actual function call that must remain in the compiled output), exactly the distinction verbatimModuleSyntax makes explicit and enforced rather than inferred.
Under the Hood
import type's complete erasure is a direct application of the type-erasure model established in TypeScript Basics, types contribute nothing to runtime behavior, and a type-only import is simply erasure applied at the statement level rather than the annotation level. And CommonJS-vs-ESM's practical implications for how Node itself resolves modules were already covered concretely in the Node.js domain's Modules & File System topic, this topic's moduleResolution settings are TypeScript's way of matching its own compile-time understanding to whichever actual runtime resolution behavior (bundler-based, or Node's own) the compiled output will eventually run under.
Common Mistakes
1. Assuming esModuleInterop-related advice from older sources still fully applies
// "just set esModuleInterop and allowSyntheticDefaultImports and hope for the best"With verbatimModuleSyntax enabled, module import/export behavior is far more direct and predictable, much of the older troubleshooting folklore around interop settings is addressing problems this newer flag sidesteps by design, by making imports explicit rather than inferred.
2. Using a .ts extension in a nodenext-resolved relative import
import { x } from "./helper.ts"; // ❌ should reference the eventual COMPILED extension, .jsThe required extension under nodenext resolution reflects what the file will be named after compilation, not the source file's actual current extension, .js, not .ts, even while actively editing the .ts source.
3. Reaching for namespace in new code for simple code organization
namespace Utils { export function helper() {} } // ❌ legacy, a plain module/file does this better nowFor new code, a separate file with regular exports achieves the same organizational goal using the standard, modern module system, namespace specifically is legacy, not a stylistic alternative with equal footing.
Best Practices
- Enable
verbatimModuleSyntaxfor new projects, it eliminates an entire category of confusing interop questions by making import/export behavior fully explicit and predictable from the source alone. - Match
moduleResolutionto your actual build target,bundlerfor projects built with Vite/webpack/esbuild/similar,nodenextfor code that runs directly under Node without a bundling step. - Remember the
.jsextension convention undernodenext, it's referencing the compiled output's name, not a mistake or a typo. - Avoid
namespacein new code, reach for standard ES modules (import/ across separate files) instead.
Performance Tips
import typeerasure has a real, if usually small, practical benefit beyond just cleanliness: it can avoid pulling in an entire module at runtime purely for a type that's never actually used as a value, which matters more in contexts (like bundling for the browser) where every additional module evaluated has some cost.- Module resolution settings (
bundlervsnodenext) are purely a compile-time configuration concern, neither has any runtime performance implication once code is actually compiled and running; the choice is entirely about matching TypeScript's compile-time behavior to your actual runtime/bundler environment correctly.
