Modules and Namespaces

Intermediate
11 min

Modules and Namespaces

Any real program is split across files. TypeScript uses the standard ES module system (import / export) and adds a few features of its own: type-only imports, compiler options that decide how module specifiers are resolved, and the older namespace construct. After this lesson you will know how to structure a multi-file project, how to keep type imports from leaking into runtime output, and when (rarely) a namespace is still appropriate.

Files Are Modules

A file that contains at least one top-level import or export is a module: its declarations are private unless exported. A file with neither is a script whose declarations are global, which causes surprising "Duplicate identifier" errors. Add export {}; to make an otherwise empty file a module.

typescript
// math.ts export const PI = 3.14159; export function area(r: number) { return PI * r * r; } export default function describe() { return "circle helpers"; } // app.ts import describe, { PI, area as circleArea } from "./math.js"; import * as math from "./math.js"; // namespace import export { PI } from "./math.js"; // re-export export * from "./math.js"; // re-export everything named

Named exports are preferred for most code: they refactor safely, tree-shake well and autocomplete reliably. Default exports are common for a file's single main thing, such as a React component.

Type-Only Imports and Exports

Types disappear after compilation, so importing one should not produce a runtime import statement. import type and export type make this explicit, and the inline form marks individual names:

typescript
import type { User, Role } from "./models.js"; // whole statement is type-only import { createUser, type Permission } from "./auth.js"; // mixed export type { User } from "./models.js";

Two compiler options govern this:

| Option | Effect | |---|---| | isolatedModules | Requires code that single-file transpilers (esbuild, swc, Babel, Vite) can compile safely; re-exporting a type needs export type | | verbatimModuleSyntax | Emits imports exactly as written; anything not marked type is kept, so a type imported without type is an error |

Modern projects enable both. They guarantee that removing a type import never changes runtime behaviour.

Module Resolution and Specifiers

module and moduleResolution in tsconfig.json determine how "./math.js" is found and what the output looks like:

  • "module": "nodenext" (with "moduleResolution": "nodenext") follows Node.js rules: relative imports need the output extension (./math.js even though the source is math.ts), and package.json "type": "module" selects ESM vs CommonJS.
  • "module": "esnext" + "moduleResolution": "bundler" is for Vite, webpack and similar tools: extensionless imports and exports maps work, and the bundler produces the final output.
  • "module": "commonjs" emits require() calls for legacy Node.js projects; pair it with esModuleInterop: true so import fs from "fs" behaves.

TypeScript 5.7 added rewriteRelativeImportExtensions, which lets you write ./math.ts in source and have tsc rewrite it to .js in the output; Node.js 22.18+ and 24 can run such .ts files directly by stripping types.

Dynamic import() returns a Promise of the module namespace and is fully typed:

typescript
const { area } = await import("./math.js");

Namespaces

Before ES modules existed, TypeScript grouped code with namespace (originally "internal modules"). A namespace is compiled to an IIFE and an object:

typescript
namespace Validation { export const emailRe = /^[^@\s]+@[^@\s]+$/; export function isEmail(s: string) { return emailRe.test(s); } } Validation.isEmail("ada@example.com");

Namespaces can be nested, split across files and merged with a same-named function, class or enum, which some libraries use to attach helpers to a type. In application code they are considered legacy: use files and folders as your namespaces. They remain legitimate in declaration files (declare namespace Express { ... }) and for augmenting global libraries, topics covered in the declaration files lesson.

The erasableSyntaxOnly option (TypeScript 5.8) rejects namespaces that contain runtime code, because Node.js type stripping cannot execute them; it is another push toward plain ES modules.

Common mistakes

  • Forgetting the .js extension under nodenext and getting "Relative import paths need explicit file extensions".
  • Importing a type without type under verbatimModuleSyntax, which errors because the import would be retained at runtime.
  • Mixing script files and module files in one project, which pollutes the global scope.
Quick Quiz
Question 1 of 3

What makes a TypeScript file a module rather than a script?

Key Takeaways

  • A file with top-level import or export is a module with private scope; use export {} to force module scope.
  • Prefer named exports; use import type / export type for anything that is only a type.
  • Enable isolatedModules and verbatimModuleSyntax so type imports can never leak into runtime output.
  • module: nodenext needs .js extensions on relative imports; moduleResolution: bundler suits Vite and webpack.
  • Namespaces are legacy for application code but still useful in declaration files and global augmentations.

Next lesson: tsconfig.json and Compiler Options — configure targets, strictness, output and module settings for a project.

Modules and Namespaces - TypeScript | CodeYourCraft | CodeYourCraft