Skip to content
Home
TypeScript Module System: ES Modules, Namespaces, and Resolution

TypeScript Module System: ES Modules, Namespaces, and Resolution

TypeScript TypeScript 8 min read 1506 words Beginner ExcellentWiki Editorial Team

TypeScript’s module system determines how code is organized, shared, and loaded. Choice of module system affects everything from tree-shaking to compatibility with different JavaScript runtimes.

This guide covers ES modules vs CommonJS, namespaces, module resolution strategies, barrel files, and path aliases.

ES Modules vs CommonJS

TypeScript supports both module systems. ES modules (import/export) are the ECMAScript standard. CommonJS (require/module.exports) is the Node.js legacy system.

ES Modules (Recommended)

// math.ts
export function add(a: number, b: number): number {
    return a + b;
---

export const PI = 3.14159;

// app.ts
import { add, PI } from "./math.js";

ES modules are statically analyzable — the compiler understands imports at compile time, enabling tree-shaking, dead-code elimination, and optimization.

CommonJS

// math.ts
function add(a: number, b: number): number {
    return a + b;
---
module.exports = { add };

// app.ts
const math = require("./math");

CommonJS is dynamic — imports can be conditional and computed, which prevents static analysis.

Configuration

The module compiler option in tsconfig.json determines the output module system:

{
    "compilerOptions": {
        "module": "esnext",     // For modern bundlers
        "module": "commonjs",   // For Node.js without bundler
        "module": "nodenext",   // For Node.js 16+ with native ESM
        "module": "bundler"     // For modern bundlers (TS 5.0+)
    }
---

The esModuleInterop option provides compatibility helpers so ES module imports work with CommonJS modules. The allowSyntheticDefaultImports option allows default imports from modules without a default export.

Module Resolution

Module resolution is the process of finding the file corresponding to an import path. TypeScript supports several resolution strategies.

Classic vs Node Resolution

Classic resolution is the legacy strategy — it looks for files relative to the importing file and walks up the directory tree. Node resolution mimics Node.js — it looks for a file matching the name, then index.ts, then checks node_modules.

Use moduleResolution: "node" for older projects, moduleResolution: "node16" or "nodenext" for Node.js 16+ with ES modules, and moduleResolution: "bundler" for projects using a bundler (Vite, webpack, esbuild).

Extension Resolution

TypeScript resolves imports with these extensions in order: .ts, .tsx, .d.ts, .js, .jsx. In ESM mode (Node16+), you must include the .js extension in imports even for TypeScript files:

import { User } from "./models/user.js";  // Resolves to user.ts

Path Aliases

Path aliases create short, readable import paths that avoid deeply nested relative imports.

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"],
            "@components/*": ["src/components/*"],
            "@utils/*": ["src/utils/*"]
        }
    }
---

After configuration, you can import with aliases:

// Instead of ../../../components/Button
import { Button } from "@components/Button";

Remember to configure your bundler to resolve the same aliases — TypeScript only handles type checking, not module loading.

Barrel Files and Re-exports

Barrel files (index.ts) re-export multiple modules from a single entry point:

// features/index.ts
export { UserService } from "./user-service";
export { OrderService } from "./order-service";
export { AuthService } from "./auth-service";

// Consumer imports from one place
import { UserService, OrderService } from "./features";

Barrels simplify imports but can cause problems: circular dependencies, tree-shaking issues (the bundler may include entire barrel contents), and increased import resolution time. Use barrels for stable public API surfaces and avoid them for internal modules where direct imports are clearer.

Namespaces (Legacy)

Before ES modules, TypeScript had its own module system called namespaces (previously “internal modules”):

namespace App.Models {
    export interface User {
        id: number;
        name: string;
    }
---

namespace App.Utils {
    export function formatDate(date: Date): string {
        // ...
    }
---

// Usage
const user: App.Models.User = { id: 1, name: "Alice" };

Namespaces compile to immediately-invoked function expressions that attach to a global object. They are considered legacy — use ES modules instead.

TypeScript and Node.js ESM

Node.js 16+ supports native ES modules. When "type": "module" is set in package.json, Node.js treats .js files as ESM. TypeScript’s module: "nodenext" enables full ESM support:

  • Imports must use the .js extension
  • File extensions are required (no automatic resolution)
  • Top-level await is supported
  • Dynamic import() expressions work with ESM

Summary

ES modules are the standard for modern TypeScript development. Use moduleResolution: "bundler" with bundlers and moduleResolution: "nodenext" for Node.js. Set path aliases for clean imports, avoid circular dependencies in barrels, and prefer ES modules over namespaces.

FAQ

Should I use ES modules or CommonJS? Use ES modules for new projects. They are the standard, support tree-shaking, and work across browsers and Node.js. Use CommonJS only when maintaining legacy code or targeting older Node.js versions without a bundler.

Why do I need .js extensions in imports? In ESM mode (Node16+), import paths must include the file extension as it appears in the compiled output. Since TypeScript compiles .ts to .js, imports use .js extensions.

What is the difference between "module": "nodenext" and "module": "bundler"? nodenext follows Node.js ESM rules — requiring .js extensions and supporting CJS interop. bundler (TS 5.0+) is more permissive, allowing extensionless imports and treating all modules as ESM.

How do I fix circular dependency errors with barrel files? Remove the barrel file and import directly from the source modules. Circular dependencies between modules usually indicate a design problem — consider extracting the shared code into a separate module.

Module Resolution Strategies

TypeScript supports two module resolution strategies that mirror Node.js module resolution:

Classic (Legacy)

The classic strategy is used when module is set to amd, system, umd, or es2015 with older compiler settings. It resolves relative imports by walking up directories looking for .ts and .d.ts files. For non-relative imports, it searches in the source directory. This strategy is rarely used in modern projects and is maintained for backward compatibility.

Node (Recommended)

The Node strategy mirrors how Node.js resolves modules. For relative imports (./foo), it checks:

  1. ./foo.ts, ./foo.tsx, ./foo.d.ts
  2. ./foo/index.ts, ./foo/index.tsx, ./foo/index.d.ts
  3. ./foo/package.json types or typings field

For non-relative imports (lodash), it searches:

  1. node_modules/lodash (with the same file resolution as above)
  2. node_modules/**/lodash (walking up the directory tree)
  3. Parent node_modules directories until root

Path Aliases

Path aliases provide clean import paths without deep relative references:

{
    "compilerOptions": {
        "paths": {
            "@components/*": ["./src/components/*"],
            "@utils/*": ["./src/utils/*"],
            "@shared/*": ["./src/shared/*"]
        }
    }
---

This allows imports like import { Button } from "@components/Button" instead of import { Button } from "../../../components/Button". Note that runtime bundlers (Webpack, Vite, esbuild) also need to be configured with matching aliases.

Type Roots

The typeRoots compiler option specifies directories where TypeScript looks for type declarations. By default it searches node_modules/@types. Override this to restrict type resolution:

{
    "compilerOptions": {
        "typeRoots": ["./node_modules/@types", "./typings"]
    }
---

When typeRoots is specified, only the listed directories are searched — the default @types lookup is replaced.

Dynamic Imports

Dynamic imports load modules on demand, enabling code splitting and lazy loading:

async function loadChartModule() {
    const { ChartComponent } = await import("./charts/ChartComponent");
    return ChartComponent;
---

// Use with React.lazy for component-level code splitting
const Chart = React.lazy(() => import("./charts/ChartComponent"));

TypeScript preserves type safety with dynamic imports — the imported module is fully typed. Dynamic imports work with all major bundlers (Webpack, Vite, esbuild) and produce separate chunks automatically. Use dynamic imports for routes, large libraries, and features that are not needed on initial page load.

The import type Syntax

Use import type when importing only types, not values. This avoids unnecessary runtime dependencies and enables bundlers to elide the import entirely:

import type { User, Config } from "./types";
import { Database } from "./database";  // Runtime import

// Or inline type import
import { type User, Database } from "./module";

This is especially important for circular dependency resolution and for libraries where consumers only need types. TypeScript 5.0+ automatically detects type-only imports in some cases, but explicit annotations are clearer and more reliable.

Practical Type Patterns for Large Codebases

When building large TypeScript applications, several patterns keep type complexity manageable:

Discriminated unions for API responses — Model every API endpoint as a union of success, error, and loading states:

type AsyncState<T, E = Error> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T }
    | { status: "error"; error: E };

This pattern eliminates impossible states — you cannot accidentally access data when status is "loading". The compiler enforces exhaustive handling of all states, preventing common bugs.

Zod for runtime validationTypeScript types are compile-time only. Use Zod to validate external data at runtime and infer the type:

import { z } from "zod";

const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    role: z.enum(["admin", "user"]),
---);
type User = z.infer<typeof UserSchema>;

// Validate API response at runtime
const user = UserSchema.parse(apiResponse);
// user is fully typed as User

This dual approach provides runtime safety for data entering your system (API responses, file parsing, user input) and compile-time safety everywhere else.

Avoiding Type Complexity Traps

Complex types (nested conditionals, deep mapped types, recursive type aliases) can make code harder to understand. Apply these guidelines:

  • Keep conditional types shallow — avoid nesting beyond 2-3 levels
  • Name intermediate types instead of inlining complex expressions
  • Prefer interfaces over type aliases for object shapes (interfaces have better error messages and are faster to check)
  • Use type for unions, intersections, and mapped types
  • Extract reusable type utilities into a shared types module

Related: TypeScript Config Guide | Declaration Files

Section: TypeScript 1506 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top