Skip to main content
TypeScript intermediate Lesson 13 of 21

Modules and Declaration Files in TypeScript

Understand TypeScript modules, namespaces, .d.ts declaration files, and ambient declarations for typing third-party JavaScript.

ES Modules

TypeScript uses standard ES module syntax, which means the same import and export keywords you write in modern JavaScript — but with types attached. A file becomes a module as soon as it contains at least one import or export, which isolates its scope from the global namespace and enables tree-shaking by bundlers. TypeScript checks imports at compile time, so a typo in a module path or a missing export surfaces as an error before you ship.

// math.ts — each export is individually typed
export function add(a: number, b: number): number {
  return a + b;
}

export const PI = 3.14159;

export interface Vector2D {
  x: number;
  y: number;
}

// Default export for the primary thing a module provides
export default class MathUtils {
  static square(n: number): number {
    return n * n;
  }
}
// main.ts — TypeScript checks that all imports actually exist in math.ts
import MathUtils, { add, PI, Vector2D } from "./math";

const sum = add(1, 2);         // number
const area = MathUtils.square(5); // number

Module Resolution

TypeScript needs to know how to find the file that corresponds to each import path. The moduleResolution setting controls this, and choosing the wrong one causes spurious “module not found” errors even when the file clearly exists. The right choice depends on your runtime and toolchain: bundler is the modern default for Vite and webpack projects, while node16/nodenext handles modern Node.js ESM correctly.

  • node — classic Node.js resolution (for CommonJS projects)
  • node16 / nodenext — modern Node.js ESM/CJS dual resolution
  • bundler — for Vite, webpack, and other bundlers (recommended for most projects today)
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

Re-exporting

As a project grows, consumers end up writing long relative import paths to reach deeply nested files. Barrel files solve this by aggregating exports from a directory into a single entry point — consumers import from one stable path, and internal file organization can change without breaking every import statement across the codebase.

// src/models/index.ts — a barrel file that re-exports everything in one place
export { User } from "./user";
export { Post } from "./post";
export type { ApiResponse } from "./api"; // type-only re-export
export * from "./shared";
// Consumers import from one place instead of navigating the directory tree
import { User, Post, ApiResponse } from "./models";

Namespaces (Legacy)

Namespaces are TypeScript’s original module system, created before ES modules became the standard. They group related code under a single global name, but they don’t integrate with the ES module system or bundlers the way import/export does. You’ll encounter them in older codebases and in declaration files, but new code should 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 validator = new Validation.LettersOnlyValidator();

Nested namespaces:

namespace App {
  export namespace Models {
    export interface User { id: number; name: string; }
  }
  export namespace Services {
    export interface UserService {
      find(id: number): Models.User | null;
    }
  }
}

Declaration Files (.d.ts)

Declaration files let you add TypeScript types to JavaScript libraries that weren’t written in TypeScript. They contain only type information — no runtime code — so they have zero impact on the compiled output. When TypeScript sees an import of a module that has a corresponding .d.ts file, it uses that file to type-check every call into that module, giving you autocomplete and error detection even for plain JavaScript.

// types/my-js-lib.d.ts — describes the shape of an untyped JS module
declare module "my-js-lib" {
  export function process(input: string): string;
  export function validate(data: unknown): boolean;

  export interface Options {
    strict?: boolean;
    timeout?: number;
  }

  export default class Client {
    constructor(options?: Options);
    connect(): Promise<void>;
    disconnect(): void;
    send(data: string): Promise<void>;
  }
}

After creating this file, TypeScript will type-check code that imports my-js-lib.

DefinitelyTyped and @types

Most popular JavaScript libraries have community-maintained declaration files on DefinitelyTyped, published as @types/* packages. This means you get full type coverage for libraries like Express, Lodash, and Jest without those libraries shipping TypeScript themselves — install the types package and TypeScript picks it up automatically.

npm install -D @types/node
npm install -D @types/lodash
npm install -D @types/express

TypeScript automatically includes @types/* packages from node_modules. You can restrict which ones are included:

{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

Ambient Declarations

Ambient declarations tell TypeScript about values that exist in the JavaScript environment but weren’t imported — things injected by a bundler, defined globally by a framework, or present in the runtime but not in any module. Without ambient declarations, TypeScript would report these as undefined variables; with them, you get full type checking on globals.

// Declare a global variable injected by webpack's DefinePlugin
declare const __ENV__: "development" | "production" | "test";
declare const __VERSION__: string;

// Declare a global function available in the runtime environment
declare function require(module: string): any;

// Declare a global class provided by the environment
declare class EventEmitter {
  on(event: string, listener: Function): this;
  emit(event: string, ...args: any[]): boolean;
}

Augmenting Global Types

Sometimes a library or your own code adds properties to globally-available objects like Window or Array. TypeScript doesn’t know about these additions until you tell it. Declaration merging with declare global extends existing types safely — you keep all the original type information and add only what you need, without forking someone else’s type definitions.

// src/types/globals.d.ts
export {}; // make this a module so declare global works correctly

declare global {
  interface Window {
    // Analytics script injected at runtime — TypeScript now knows about it
    analytics: {
      track(event: string, properties?: Record<string, unknown>): void;
    };
  }

  interface Array<T> {
    // Custom method monkey-patched onto Array.prototype
    groupBy<K extends string>(fn: (item: T) => K): Record<K, T[]>;
  }
}

Module Augmentation

Module augmentation adds new types to an existing module’s declarations without editing its source or forking its @types package. This is how you add custom properties to Express’s Request type — a very common pattern for attaching authenticated user data or request IDs in middleware.

// Augmenting Express to add a user property set by auth middleware
import "express";

declare module "express" {
  interface Request {
    user?: {
      id: string;
      roles: string[];
    };
    requestId: string; // set by request-id middleware
  }
}
// Augmenting a third-party library to add your plugin's options
import "some-lib";

declare module "some-lib" {
  interface PluginOptions {
    myCustomOption?: boolean;
  }
}

Path Mapping

Relative import paths like ../../../components/Button are fragile — move a file and every relative import to it breaks. Path aliases solve this by mapping a short prefix like @components/* to a directory, so imports are stable regardless of where the importing file lives. This is a compile-time-only feature in TypeScript; your bundler or a runtime loader also needs to know about the same aliases.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}
// Instead of:
import { Button } from "../../../components/Button";

// Write:
import { Button } from "@components/Button";

Note: path mapping in TypeScript is compile-time only. For Node.js, you also need a runtime loader like tsconfig-paths or configure the same aliases in your bundler.

Project References

In a monorepo with multiple packages, running tsc on the entire codebase means recompiling everything every time. Project References split the codebase into independently compilable sub-projects that TypeScript can build in parallel and cache individually. When shared hasn’t changed, api and web skip recompiling it entirely — this can reduce build times from minutes to seconds in large repos.

// packages/utils/tsconfig.json
{
  "compilerOptions": {
    "composite": true,  // required — marks this as a referenced project
    "outDir": "./dist",
    "declaration": true // required — other projects consume the .d.ts files
  }
}
// packages/app/tsconfig.json
{
  "compilerOptions": {
    "composite": true
  },
  "references": [
    { "path": "../utils" } // TypeScript will build utils first if needed
  ]
}

Build with:

npx tsc --build

TypeScript rebuilds only packages whose source has changed, making large monorepo builds significantly faster.

Practical Example: Typing a Third-Party SDK

When a JS SDK ships without types, the temptation is to use any and move on. Resist it — writing a declaration file takes 15 minutes and pays back in autocomplete, error detection, and documentation for every developer on the team who uses that SDK from that point forward.

// types/payment-sdk.d.ts
declare module "payment-sdk" {
  export type Currency = "USD" | "EUR" | "GBP";

  export interface ChargeOptions {
    amount: number;        // in smallest currency unit (cents)
    currency: Currency;
    description?: string;
    metadata?: Record<string, string>;
  }

  export interface ChargeResult {
    id: string;
    status: "succeeded" | "failed" | "pending";
    amount: number;
    currency: Currency;
    createdAt: number;
  }

  export interface PaymentClient {
    charge(options: ChargeOptions): Promise<ChargeResult>;
    refund(chargeId: string, amount?: number): Promise<ChargeResult>;
    getCharge(id: string): Promise<ChargeResult>;
  }

  export function createClient(apiKey: string): PaymentClient;
}

Now consumers of the SDK get full type checking, autocomplete, and documentation.

Frequently Asked Questions

What is a .d.ts file?
A declaration file (.d.ts) describes the types of a JavaScript module without containing any runtime code. It tells TypeScript the shapes of values that exist in JS libraries so you get type checking and autocomplete.
When do I need to write my own declaration files?
When you use a JavaScript library that has no TypeScript types (no built-in types and no @types/* package on npm). You write a .d.ts to describe its API.
Should I use namespaces or modules?
Use ES modules (import/export). Namespaces are a legacy TypeScript feature from before ES modules were widespread. They are still used for augmenting global types and in declaration files for ambient namespaces.