Modules and Namespaces
Module resolution strategies, import type, and legacy namespaces for reading older TypeScript code.
TypeScript modules are JavaScript modules
TypeScript doesn't invent its own module system — it uses the same ES module import/export syntax covered in this app's JavaScript track, with type information layered on top. Everything you already know about named exports, default exports, and re-exporting carries over unchanged:
// mathUtils.ts
export function add(a: number, b: number): number {
return a + b;
}
export interface Point {
x: number;
y: number;
}
// main.ts
import { add, Point } from "./mathUtils.js";
const p: Point = { x: 1, y: 2 };
console.log(add(p.x, p.y)); // 3
Note the .js extension in the import path even though the source file is mathUtils.ts — TypeScript compiles .ts to .js, and with modern "module": "ESNext"/"moduleResolution": "bundler" or "node16" settings, import specifiers should reflect the compiled output's extension, not the source file's. This trips up almost everyone coming from a setup where extensions were omitted entirely.
Module resolution: how TypeScript finds what you're importing
moduleResolution in tsconfig.json controls the algorithm TypeScript uses to turn import { add } from "./mathUtils.js" into an actual file on disk, and to resolve bare imports like import { z } from "zod" to the right package.
| Setting | Behavior |
|---|---|
node16 / nodenext |
Matches Node.js's own ESM resolution rules exactly — requires explicit file extensions in relative imports, respects "type": "module" in package.json. The most accurate choice for code that will actually run in Node. |
bundler |
Designed for projects built by Vite, esbuild, or webpack, which have more lenient resolution than Node itself (extensions can often be omitted). The common choice for frontend app code that never runs directly in Node. |
classic |
TypeScript's original, largely obsolete algorithm predating Node's own module resolution — essentially never the right choice for a new project today. |
A minimal, modern tsconfig.json for a Node-targeted project:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
import type — importing only type information
import type tells the compiler that an import is used only for types, never for a runtime value — the import is guaranteed to be completely erased from the compiled JavaScript, with zero risk of accidentally pulling in a module you only needed for its type shape:
// user.ts
export interface User {
id: number;
name: string;
}
export function createUser(name: string): User {
return { id: Date.now(), name };
}
// userView.ts
import type { User } from "./user.js"; // erased entirely at compile time — no runtime import at all
import { createUser } from "./user.js"; // a real runtime import — createUser is a function that must exist at runtime
function renderUser(user: User): string {
return `${user.name} (#${user.id})`;
}
Without import type, a plain import { User } from "./user.js" used only as a type still compiles away cleanly in most modern setups (TypeScript is smart enough to elide type-only imports automatically) — but import type makes that intent explicit rather than relying on the compiler's inference, which matters in projects using certain bundler settings (isolatedModules) where each file is transpiled independently, without the whole-project view needed to safely guess which imports are types and which are values.
A single import statement can mix both, since TypeScript 4.5+:
import { createUser, type User } from "./user.js";
Namespaces — legacy context
Before ES modules existed in JavaScript, TypeScript shipped its own module-like construct: namespaces (originally called "internal modules"), using the namespace keyword to group related code under one shared name:
namespace Geometry {
export interface Point {
x: number;
y: number;
}
export function distance(a: Point, b: Point): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
}
const p1: Geometry.Point = { x: 0, y: 0 };
const p2: Geometry.Point = { x: 3, y: 4 };
console.log(Geometry.distance(p1, p2)); // 5
Namespaces predate ES modules and solved the same basic problem — avoiding global naming collisions and grouping related code — but entirely at compile time, with no connection to any real JavaScript module system; multiple namespace Geometry { ... } blocks (even across files, with the right compiler setup) simply merge together.
You will not write new namespaces in modern TypeScript. ES modules solve the same problem with a real, standard JavaScript feature that works with any bundler, any Node version, and any tooling — there's no remaining advantage to namespaces for organizing application code. The reason to recognize the syntax at all is legacy: some older libraries (and their published .d.ts type definition files) still declare their public API using namespaces, particularly libraries older than ES modules becoming universal, so you'll occasionally see SomeLibrary.SomeType in a type definition or in an older codebase's source and need to recognize what it is.
Comparing modules and namespaces
ES modules (import/export) |
Namespaces (namespace) |
|
|---|---|---|
| Real JavaScript feature | Yes — standard ECMAScript | No — TypeScript-only compile-time construct |
| Works with bundlers/Node natively | Yes | Only with specific, older compiler settings |
| Tree-shaking / code-splitting support | Yes | No |
| Current recommendation | Always use this | Legacy only — recognize, don't write new code with it |
Common mistakes
- Omitting the file extension in a relative import under
"moduleResolution": "node16"/"nodenext"— Node's own ESM resolution requires it, and omitting it produces a resolution error that can be confusing if you're used to a bundler's more lenient rules. - Writing new code using
namespacebecause an old tutorial or legacy codebase uses it — for anything new, ES modules are the correct, standard choice; namespaces exist today only to read old code and old library type definitions. - Assuming
import typeis required for every type-only import — modern TypeScript usually erases these automatically;import typeis about making that intent explicit and guaranteed, most relevant underisolatedModules, not a requirement for correctness in every project.
Interview questions
Q: What does import type actually guarantee that a regular import used only for types doesn't?
import type guarantees, unconditionally, that the import is erased from the compiled JavaScript and never treated as a runtime dependency — the compiler doesn't need to infer that intent from how the imported name is used elsewhere in the file. This matters most under isolatedModules (where each file is compiled independently, without whole-project context), since a plain import used only for a type might otherwise be ambiguous to a single-file transpiler in a way it isn't to the full TypeScript compiler.
Q: Why would you see a namespace in a real codebase today, and would you write a new one? Namespaces predate ES modules as TypeScript's original way to group related code and avoid global name collisions, entirely at compile time with no connection to a real JavaScript module system. You'd still encounter one reading an older codebase or an older library's bundled type definitions, but you wouldn't write a new one — ES modules solve the same organizational problem using a real, standard JavaScript feature that also supports tree-shaking and works uniformly across every modern bundler and Node version.