On this page

Modules and namespaces

10 min read TextCh. 5 — TypeScript in Practice

Modules in TypeScript

TypeScript's module system is built on top of the ECMAScript module (ESM) specification. Every .ts file with a top-level import or export statement is a module — it has its own scope and must explicitly export what it wants to share. A file without any import or export is a script that runs in the global scope (avoid this pattern in modern projects).

Named exports and imports

// geometry.ts
export function circleArea(radius: number): number {
  return Math.PI * radius ** 2;
}

export function rectArea(width: number, height: number): number {
  return width * height;
}

export const PI_APPROX = 3.14159;
// main.ts
import { circleArea, PI_APPROX } from "./geometry.js";

console.log(circleArea(5));  // 78.539...
console.log(PI_APPROX);      // 3.14159

Default exports

A module can have one default export. Default exports are convenient for components, classes, and configuration objects:

// logger.ts
export default class Logger {
  private prefix: string;

  constructor(prefix: string) {
    this.prefix = prefix;
  }

  info(message: string): void {
    console.log(`[${this.prefix}] INFO: ${message}`);
  }

  error(message: string): void {
    console.error(`[${this.prefix}] ERROR: ${message}`);
  }
}
// app.ts
import Logger from "./logger.js"; // any name is valid for default imports

const log = new Logger("App");
log.info("Starting...");

In practice, many style guides prefer named exports over defaults because they are easier to rename, tree-shake, and autocomplete.

Type-only imports and exports

TypeScript provides import type and export type syntax for imports that exist only at the type level. The emitted JavaScript will not contain these imports:

// models.ts
export type User = {
  id: number;
  name: string;
  role: "admin" | "user";
};

export interface Session {
  token: string;
  userId: number;
  expiresAt: number;
}
// service.ts
import type { User, Session } from "./models.js";

function getUser(session: Session): User | undefined {
  // Implementation would go here
  return undefined;
}

The import type statement guarantees that the import is erased completely during compilation. If you mix types and values in a single import, TypeScript 4.5+ lets you mark individual specifiers:

import { type User, createUser, type Session } from "./models.js";

Module resolution

TypeScript supports several module resolution strategies configured via tsconfig.json:

Strategy Use case
Node CommonJS projects (legacy)
NodeNext Node.js ESM projects
Bundler Vite, webpack, esbuild — the bundler handles resolution
Classic Legacy TypeScript (avoid)

For modern projects using a bundler, set "moduleResolution": "Bundler". For Node.js ESM, use "NodeNext".

Path aliases

Deep relative imports like ../../../services/auth are brittle. Configure path aliases in tsconfig.json to use clean absolute-style paths:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*":      ["src/app/*"],
      "@core/*":     ["src/core/*"],
      "@shared/*":   ["src/shared/*"],
      "@env":        ["src/environments/environment.ts"]
    }
  }
}

Now you can write:

import { AuthService } from "@core/services/auth.js";
import { Button }      from "@shared/components/button.js";

Important: Path aliases are a TypeScript-only feature. Your bundler or runtime also needs to resolve them. Vite uses vite.config.tsresolve.alias; Node.js needs a package exports map or a loader.

Barrel files

A barrel is an index.ts file that re-exports everything from a directory, creating a single public API entry point:

// src/validators/index.ts
export { EmailValidator }  from "./email-validator.js";
export { PasswordValidator } from "./password-validator.js";
export { PhoneValidator }  from "./phone-validator.js";
export type { ValidationResult } from "./types.js";

Consumers import from the barrel rather than hunting for individual file paths:

import { EmailValidator, PhoneValidator } from "@validators/index.js";

Declaration files (.d.ts)

A .d.ts file contains only type declarations — no runtime code. TypeScript generates them automatically when you set "declaration": true in tsconfig.json. They describe the public API of a compiled library so other TypeScript projects can consume it with full type safety.

You also write .d.ts files manually to add types to JavaScript libraries that do not ship their own:

// src/types/analytics.d.ts
declare module "my-analytics" {
  export interface TrackOptions {
    event: string;
    properties?: Record<string, unknown>;
    userId?: string;
  }

  export function track(options: TrackOptions): void;
  export function identify(userId: string, traits?: Record<string, unknown>): void;
}

DefinitelyTyped and @types

For popular JavaScript libraries, the community maintains type declarations in the DefinitelyTyped repository, published as @types/* packages:

npm install --save-dev @types/node @types/express @types/lodash

TypeScript automatically picks up these packages — no import needed. They augment the module's types globally.

Namespaces (legacy)

Namespaces were TypeScript's pre-ESM module system. They merge declarations under a single identifier and can span multiple files. In modern projects you should prefer ES modules:

// Avoid in new code — use ES modules instead
namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }

  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string): boolean {
      return /^[A-Za-z]+$/.test(s);
    }
  }
}

const v = new Validation.LettersOnlyValidator();

The only valid modern use case for namespaces is augmenting global declaration files or organizing related ambient declarations in .d.ts files.


Practice

  1. ES module structure: Create a small module string-utils.ts that exports three named functions: capitalize, slugify, and truncate. Import and use all three in a main.ts file. Use .js extensions in the imports.
  2. Type-only import: Define a UserProfile interface in types.ts. In user-service.ts, import it with import type and use it as the return type of a fetchProfile(id: number): Promise<UserProfile> function.
  3. Path aliases: Configure @utils and @models path aliases in a tsconfig.json. Write the barrel index.ts for each directory and update the imports in your service files to use the aliases.
  4. Ambient module: Write a .d.ts declaration file for a fictional "date-fns-lite" module that exports two functions: format(date: Date, pattern: string): string and addDays(date: Date, days: number): Date.

Next lesson: migrating JavaScript to TypeScript — a practical, incremental approach.

Always add .js extensions in ESM imports
When targeting ESM (`"module": "ESNext"` + `"moduleResolution": "Bundler"` or `"NodeNext"`), write `import { x } from "./file.js"` even though the source file is `file.ts`. TypeScript compiles `.ts` to `.js`, and the runtime looks for the `.js` file. Skipping the extension causes runtime errors in Node.js ESM.
Type-only imports improve build performance
`import type { Foo }` is stripped completely from the output — it generates zero JavaScript. This helps bundlers with tree-shaking and prevents accidental circular dependency issues caused by importing implementation code when only a type is needed.
Barrel files can hurt build performance at scale
Re-exporting many modules through a single `index.ts` barrel is convenient, but bundlers must import the entire barrel to resolve any single export. In large codebases this can slow down cold starts. Keep barrels scoped to logical feature boundaries, not to the entire codebase.
typescript
// --- math.ts ---
// Named exports
export function add(a: number, b: number): number {
  return a + b;
}

export function multiply(a: number, b: number): number {
  return a * b;
}

// Re-export from another module
export { PI } from "./constants.js";

// --- types.ts ---
// Type-only export — erased at compile time, never included in JS output
export type { User } from "./models.js";
export type UserId = number;

// --- main.ts ---
// Named import
import { add, multiply } from "./math.js";

// Type-only import — signals this import is for types only
import type { UserId } from "./types.js";

// Namespace import (import everything as an object)
import * as MathUtils from "./math.js";

console.log(add(2, 3));              // 5
console.log(MathUtils.multiply(4, 5)); // 20

const id: UserId = 42; // Only used as a type — no runtime import
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@models/*": ["src/models/*"],
      "@utils/*":  ["src/utils/*"],
      "@services/*": ["src/services/*"]
    }
  }
}