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.