TypeScript

Modules & Namespaces

30 min Lesson 15 of 40

Organizing Code with Modules and Namespaces

As TypeScript applications grow, organizing code becomes crucial. TypeScript provides two primary mechanisms for code organization: ES modules (the modern standard) and namespaces (legacy but still useful). Understanding both is essential for working with TypeScript codebases.

ES Modules in TypeScript

ES modules are the standard way to organize JavaScript/TypeScript code. They provide a clean, explicit way to share code between files:

// math.ts - Module file export function add(a: number, b: number): number { return a + b; } export function subtract(a: number, b: number): number { return a - b; } export const PI = 3.14159; // Can also export after declaration function multiply(a: number, b: number): number { return a * b; } export { multiply }; // main.ts - Importing module import { add, subtract, PI } from "./math"; console.log(add(5, 3)); // 8 console.log(subtract(10, 4)); // 6 console.log(PI); // 3.14159 // Import with alias import { multiply as mult } from "./math"; console.log(mult(4, 5)); // 20 // Import all exports as namespace import * as Math from "./math"; console.log(Math.add(1, 2)); // 3 console.log(Math.PI); // 3.14159
Note: ES modules are statically analyzed, meaning imports and exports are resolved at compile time. This enables tree-shaking (removing unused code) and better IDE support with IntelliSense.

Default Exports

Modules can have one default export, which is imported without curly braces:

// user.ts - Default export export default class User { constructor( public name: string, public email: string ) {} greet(): string { return `Hello, I'm ${this.name}`; } } // Can also use separate export default statement class Product { constructor(public name: string, public price: number) {} } export default Product; // main.ts - Importing default export import User from "./user"; import Product from "./product"; const user = new User("Alice", "alice@example.com"); console.log(user.greet()); // "Hello, I'm Alice" const product = new Product("Laptop", 999); console.log(product.price); // 999 // Can name default import anything import MyUser from "./user"; const user2 = new MyUser("Bob", "bob@example.com"); // Combine default and named exports // utils.ts export default function log(message: string): void { console.log(`[LOG] ${message}`); } export function error(message: string): void { console.error(`[ERROR] ${message}`); } export function warn(message: string): void { console.warn(`[WARN] ${message}`); } // main.ts import log, { error, warn } from "./utils"; log("Application started"); error("Something went wrong"); warn("Deprecated feature used");

Re-exporting

Modules can re-export items from other modules, useful for creating barrel files:

// models/user.ts export interface User { id: number; name: string; email: string; } // models/product.ts export interface Product { id: number; name: string; price: number; } // models/order.ts export interface Order { id: number; userId: number; products: number[]; total: number; } // models/index.ts - Barrel file export { User } from "./user"; export { Product } from "./product"; export { Order } from "./order"; // Or use export * syntax export * from "./user"; export * from "./product"; export * from "./order"; // main.ts - Import from barrel import { User, Product, Order } from "./models"; const user: User = { id: 1, name: "Alice", email: "alice@example.com" }; const product: Product = { id: 1, name: "Laptop", price: 999 }; const order: Order = { id: 1, userId: 1, products: [1], total: 999 };
Tip: Barrel files (index.ts) simplify imports by providing a single entry point for a directory. However, be cautious with barrel files in large projects as they can impact tree-shaking and bundle size.

Type-Only Imports and Exports

TypeScript allows importing/exporting types separately from values:

// types.ts export interface User { id: number; name: string; } export type UserId = number; export class UserService { getUser(id: UserId): User | null { return null; } } // main.ts - Type-only imports import type { User, UserId } from "./types"; import { UserService } from "./types"; // User and UserId are types only, erased at runtime const userId: UserId = 1; const user: User = { id: 1, name: "Alice" }; // UserService is a value, included in compiled JS const service = new UserService(); // Type-only exports // utils.ts interface Config { apiKey: string; endpoint: string; } export type { Config }; // Mixed type and value exports export { type Config, UserService } from "./types"; // Re-export as type only export type { User as UserType } from "./types";
Note: Type-only imports are especially useful when you want to ensure a module is only used for type information and should be completely removed from the compiled JavaScript.

Module Resolution

TypeScript needs to understand how to find modules. Configure module resolution in tsconfig.json:

// tsconfig.json { "compilerOptions": { // Module system: "commonjs", "amd", "es2015", "es2020", "esnext", "node16" "module": "esnext", // Module resolution strategy: "node" or "classic" "moduleResolution": "node", // Base directory for resolving non-relative module names "baseUrl": "./src", // Path mapping for module aliases "paths": { "@models/*": ["models/*"], "@utils/*": ["utils/*"], "@services/*": ["services/*"] }, // Include type definitions "types": ["node", "jest"], // Allow importing .json files "resolveJsonModule": true, // Allow default imports from modules with no default export "allowSyntheticDefaultImports": true, // Emit interoperability between CommonJS and ES Modules "esModuleInterop": true } } // With path mapping, you can use: import { User } from "@models/user"; import { logger } from "@utils/logger"; // Instead of: import { User } from "../../models/user"; import { logger } from "../../utils/logger";

Namespaces

Namespaces (formerly called "internal modules") are TypeScript's legacy way of organizing code. They're still useful for organizing global scripts:

// Namespace declaration namespace Validation { export interface StringValidator { isValid(s: string): boolean; } export class EmailValidator implements StringValidator { isValid(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); } } export class UrlValidator implements StringValidator { isValid(s: string): boolean { try { new URL(s); return true; } catch { return false; } } } // Private helper (not exported) function log(message: string): void { console.log(`[Validation] ${message}`); } } // Usage const emailValidator = new Validation.EmailValidator(); console.log(emailValidator.isValid("test@example.com")); // true console.log(emailValidator.isValid("invalid")); // false const urlValidator = new Validation.UrlValidator(); console.log(urlValidator.isValid("https://example.com")); // true console.log(urlValidator.isValid("not a url")); // false // Nested namespaces namespace Company { export namespace Employees { export class Manager { constructor(public name: string) {} } export class Developer { constructor(public name: string) {} } } export namespace Products { export interface Product { id: number; name: string; } } } const manager = new Company.Employees.Manager("Alice"); const developer = new Company.Employees.Developer("Bob");
Warning: Namespaces are considered legacy. For new code, prefer ES modules. Namespaces are still useful for: organizing ambient declarations, working with global scripts, and maintaining legacy codebases.

Namespace Aliases

Create shorter aliases for deeply nested namespaces:

namespace Shapes { export namespace Polygons { export class Triangle { constructor(public sides: number = 3) {} } export class Square { constructor(public sides: number = 4) {} } } } // Alias for shorter access import Polygons = Shapes.Polygons; const triangle = new Polygons.Triangle(); const square = new Polygons.Square(); console.log(triangle.sides); // 3 console.log(square.sides); // 4 // Can also alias specific classes import Triangle = Shapes.Polygons.Triangle; const tri = new Triangle();

Namespace Merging

Namespaces with the same name in the same scope are automatically merged:

// First declaration namespace Animals { export class Dog { bark(): void { console.log("Woof!"); } } } // Second declaration - merged with first namespace Animals { export class Cat { meow(): void { console.log("Meow!"); } } } // Both classes available const dog = new Animals.Dog(); const cat = new Animals.Cat(); dog.bark(); // "Woof!" cat.meow(); // "Meow!" // Useful for extending functionality namespace MathUtils { export function add(a: number, b: number): number { return a + b; } } namespace MathUtils { export function multiply(a: number, b: number): number { return a * b; } } console.log(MathUtils.add(2, 3)); // 5 console.log(MathUtils.multiply(2, 3)); // 6

Declaration Merging

TypeScript allows merging interfaces, namespaces, and even interfaces with namespaces:

// Interface merging interface User { name: string; } interface User { email: string; } // Both declarations merged const user: User = { name: "Alice", email: "alice@example.com" }; // Interface + Namespace merging interface Album { label: string; } namespace Album { export class AlbumLabel { constructor(public name: string) {} } } // Both interface and namespace available const album: Album = { label: "Rock" }; const albumLabel = new Album.AlbumLabel("Jazz Records"); // Function + Namespace merging function buildLabel(name: string): string { return name; } namespace buildLabel { export function fromObject(obj: { name: string }): string { return obj.name; } } console.log(buildLabel("Direct")); // "Direct" console.log(buildLabel.fromObject({ name: "From Object" })); // "From Object" // Enum + Namespace merging enum Color { Red, Green, Blue } namespace Color { export function mix(c1: Color, c2: Color): string { return `Mixed ${Color[c1]} and ${Color[c2]}`; } } console.log(Color.Red); // 0 console.log(Color.mix(Color.Red, Color.Blue)); // "Mixed Red and Blue"
Note: Declaration merging is a powerful feature that allows you to augment existing types. This is commonly used when extending third-party library types or adding utility functions to enums.

Ambient Modules

Declare types for modules that don't have TypeScript definitions:

// types.d.ts - Ambient module declaration declare module "legacy-library" { export function oldFunction(param: string): number; export interface OldInterface { prop: string; } export class OldClass { constructor(value: string); method(): void; } } // main.ts - Use the library with types import { oldFunction, OldInterface, OldClass } from "legacy-library"; const result = oldFunction("test"); // Type: number const obj: OldInterface = { prop: "value" }; const instance = new OldClass("value"); // Wildcard module declarations declare module "*.json" { const value: any; export default value; } declare module "*.css" { const classes: { [key: string]: string }; export default classes; } // Usage import config from "./config.json"; // Works with type any import styles from "./styles.css"; // Works with string dictionary

Module Augmentation

Extend existing modules with additional declarations:

// Augment Array interface declare global { interface Array<T> { first(): T | undefined; last(): T | undefined; } } // Implementation Array.prototype.first = function() { return this[0]; }; Array.prototype.last = function() { return this[this.length - 1]; }; // Usage const numbers = [1, 2, 3, 4, 5]; console.log(numbers.first()); // 1 console.log(numbers.last()); // 5 // Augment external module // express.d.ts import "express"; declare module "express" { interface Request { user?: { id: number; name: string; }; } } // main.ts import express from "express"; const app = express(); app.get("/user", (req, res) => { // req.user is now typed if (req.user) { res.json({ id: req.user.id, name: req.user.name }); } else { res.status(401).send("Unauthorized"); } });
Exercise: Create a module system for a simple e-commerce application. Build separate modules for products, cart, and checkout. Each module should export interfaces, classes, and utility functions. Create a barrel file (index.ts) that re-exports everything. Use path mapping in tsconfig.json for clean imports. Add type-only exports where appropriate. Finally, augment the Array interface with a sum() method for number arrays.

Choosing Between Modules and Namespaces

Here's when to use each approach:

  • Use ES Modules when:
    • Building modern applications (recommended default)
    • Working with module bundlers (Webpack, Rollup, Vite)
    • You need tree-shaking and code splitting
    • Working with npm packages
    • Building libraries for distribution
  • Use Namespaces when:
    • Working with legacy codebases
    • Writing global scripts without a module system
    • Organizing ambient type declarations
    • Creating internal organization in declaration files

Module Best Practices

  • Keep modules focused: Each module should have a single responsibility
  • Avoid circular dependencies: A imports B, B imports A creates problems
  • Use barrel files sparingly: They can impact bundle size and build performance
  • Prefer named exports: Default exports can lead to inconsistent naming
  • Use path mapping: Avoid long relative import paths like ../../../utils
  • Export interfaces from implementation: Keep types close to their implementations
  • Document public APIs: Add JSDoc comments to exported members

Summary

TypeScript provides powerful code organization tools through ES modules and namespaces. ES modules are the modern standard, offering static analysis, tree-shaking, and excellent tooling support. Namespaces remain useful for legacy code and ambient declarations. Understanding module resolution, declaration merging, and module augmentation enables you to work effectively with any TypeScript codebase and create well-organized, maintainable applications.