In simple language, a module is a file that exports stuff so other files can import it. TypeScript uses the same ES module syntax JavaScript uses — import / export. Namespaces are an older TS-only way to organize code that we should know about but rarely use in new code.
Modules — the standard way
Any file with a top-level import or export is a module. Without one, the file is a “script” and everything declared in it goes into the global scope (which is usually bad).
// math.ts — named exports
export function add(a: number, b: number) {
return a + b;
}
export const PI = 3.14159;
// default export — one per file
export default class Calculator {
add(a: number, b: number) { return a + b; }
}
// app.ts — importing
import Calculator, { add, PI } from "./math";
import * as math from "./math"; // namespace import
import { add as plus } from "./math"; // rename
console.log(add(1, 2));
console.log(math.PI);
The only difference between named and default exports: default exports are unnamed (the importer picks the name), named exports require matching the name (unless renamed with as).
Type-only imports
TS adds one thing on top of ES modules — type-only imports. These get erased at compile time and don’t generate any runtime require/import.
import type { User } from "./types";
import { type Config, loadConfig } from "./config"; // mixed
// type-only — totally removed at runtime
function process(user: User) { /* ... */ }
Two reasons to use import type: it makes intent clear, and it avoids accidental circular runtime dependencies. With verbatimModuleSyntax: true in tsconfig, TS enforces type-only imports for type-only usage.
Re-exports
We can forward exports from one module through another. Useful for building a “barrel” file (single entry point for a folder).
// utils/index.ts
export { add, PI } from "./math";
export * from "./strings"; // re-export everything
export { default as Calc } from "./math"; // re-export default as named
Now import { add } from "./utils" works. Be careful — barrel files can hurt tree-shaking if not set up right.
Namespaces — the legacy approach
Before ES modules existed, TS had namespace (originally called “internal modules”). They group related code under a name. We mostly see them in old .d.ts files and library typings.
namespace Geometry {
export interface Point { x: number; y: number }
export function distance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
}
const p: Geometry.Point = { x: 0, y: 0 };
Geometry.distance(p, { x: 3, y: 4 }); // 5
Namespaces can span multiple files (they merge by name), which sounds neat but creates implicit dependencies and breaks tree-shaking. Don’t use them in new code. Use ES modules.
CommonJS interop
Node’s classic require / module.exports is CommonJS. When importing a CJS module into a TS ES module project, we hit a snag: default-export interop.
// works when esModuleInterop is true (default in modern setups)
import express from "express";
// without esModuleInterop, we'd need:
import * as express from "express";
esModuleInterop: true in tsconfig makes CJS modules look like they have default exports. Modern setups have this on — it’s only relevant if we’re maintaining an old codebase.
Module resolution gotcha
When TS sees import "./math", how does it find the file? That’s controlled by moduleResolution in tsconfig. For modern Node + bundlers, use "bundler" or "node16". With "node16" we need explicit .js extensions in imports (even though the file is .ts) — confusing but correct, because that’s what ES modules require at runtime.
// with moduleResolution: "node16" + ESM
import { add } from "./math.js"; // .js even though file is math.ts
Stick with "bundler" if a bundler (Vite, esbuild, webpack) handles resolution. Use "node16" for native Node ESM.