Chapter 9: Modules, Namespaces, and Declaration Files

JavaScript’s module story is messy. CommonJS for Node. ES Modules for browsers. AMD for RequireJS. UMD for “universal” compatibility. SystemJS. The list goes on.

TypeScript supports them all. It also adds its own features (namespaces) and a critical tool for library authors: declaration files (.d.ts).

This chapter covers how TypeScript organizes code across files and how to consume (or create) typed libraries.

ES Modules (The Modern Standard)

If you’re starting fresh, use ES modules:

// math.ts
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;
// main.ts
import { add, subtract, PI } from './math';

console.log(add(2, 3));    // 5
console.log(subtract(5, 2)); // 3
console.log(PI);            // 3.14159

Default Exports

// user.ts
export default class User {
  constructor(public name: string) {}
}
// main.ts
import User from './user';

const user = new User('Alice');

Prefer named exports. Default exports are harder to refactor and autocomplete.

Re-exports

// shapes/circle.ts
export class Circle {}

// shapes/rectangle.ts
export class Rectangle {}

// shapes/index.ts
export { Circle } from './circle';
export { Rectangle } from './rectangle';

// Or shorthand
export * from './circle';
export * from './rectangle';
// main.ts
import { Circle, Rectangle } from './shapes';

Barrel exports (index files) organize APIs.

Import Types

Sometimes you only need a type, not the value:

// user.ts
export class User {
  name: string;
}

// main.ts
import type { User } from './user';

const user: User = { name: 'Alice' }; // OK, only using the type

const instance = new User(); // Error: 'User' cannot be used as a value because it was imported using 'import type'

import type imports only the type. The value is not available. This helps with tree-shaking—unused imports are stripped.

Inline Type Imports

import { type User, fetchUser } from './user';

// User is a type, fetchUser is a value

CommonJS (Node.js Traditional)

TypeScript compiles to CommonJS when "module": "commonjs" in tsconfig.json.

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

Compiles to:

// math.js
exports.add = function(a, b) {
  return a + b;
};

Importing:

// main.ts
import { add } from './math';

Compiles to:

// main.js
const { add } = require('./math');

TypeScript handles the conversion seamlessly.

Default Export in CommonJS

// user.ts
export default class User {
  constructor(public name: string) {}
}

Compiles to:

// user.js
class User {
  constructor(name) {
    this.name = name;
  }
}
module.exports = User;

With esModuleInterop: true, this works:

import User from './user';

Without it, you need:

import * as User from './user';

Always enable esModuleInterop.

Module Resolution

TypeScript has multiple strategies for finding modules:

Classic (Legacy, Avoid)

Rare. Don’t use.

Node (Standard for Node.js)

Mimics Node’s resolution:

  1. Relative imports (./, ../) resolve from the current file
  2. Non-relative imports (lodash, react) resolve from node_modules
import { debounce } from 'lodash'; // Looks in node_modules/lodash
import { User } from './models/user'; // Relative path

TypeScript looks for:

Node16/NodeNext (Modern Node.js)

For Node.js with native ESM support:

{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16"
  }
}

Requires explicit file extensions:

import { add } from './math.js'; // Must include .js extension

Even though the source is .ts, imports reference .js (what will exist after compilation).

Bundler (Modern Bundlers)

For Webpack, Vite, etc.:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler"
  }
}

Bundlers handle resolution. TypeScript just type-checks.

Path Aliases

Clean up import paths:

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

// You can write
import { Button } from '@components/Button';

Caveat: TypeScript resolves these at compile time, but runtimes (Node.js, browsers) don’t understand them. You may need extra tooling:

Namespaces (Legacy)

Before ES modules, TypeScript had namespaces (originally called “internal modules”):

namespace MathUtils {
  export function add(a: number, b: number): number {
    return a + b;
  }

  export function subtract(a: number, b: number): number {
    return a - b;
  }
}

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

Namespaces compile to IIFEs (Immediately Invoked Function Expressions):

var MathUtils;
(function (MathUtils) {
  function add(a, b) {
    return a + b;
  }
  MathUtils.add = add;
})(MathUtils || (MathUtils = {}));

Don’t use namespaces for new code. Use ES modules. Namespaces exist for legacy compatibility (older libraries, ambient declarations).

When You Might See Namespaces

Type definitions for global libraries:

// @types/jquery/index.d.ts
declare namespace $ {
  function ajax(settings: any): any;
}

Augmenting global scope:

declare global {
  interface Window {
    myGlobal: string;
  }
}

window.myGlobal = 'value';

Declaration Files (.d.ts)

Declaration files describe types without implementation. They’re like header files in C/C++.

Why They Exist

JavaScript libraries have no types. Declaration files add them:

// lodash.js (JavaScript)
export function debounce(fn, delay) {
  // Implementation
}
// lodash.d.ts (TypeScript declaration)
export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void;

Now TypeScript knows debounce’s signature.

Creating Declaration Files

For your own libraries:

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

With "declaration": true in tsconfig.json, TypeScript generates:

// math.d.ts
export declare function add(a: number, b: number): number;

The .d.ts file has types but no implementation. Consumers get types; the .js file has runtime code.

Ambient Declarations

Describe global variables or modules without implementation:

// global.d.ts
declare const VERSION: string;
declare function log(message: string): void;

Now you can use VERSION and log without errors:

console.log(VERSION); // OK
log('Hello');         // OK

This is how you type global scripts or external libraries loaded via <script> tags.

Module Augmentation

Extend existing module types:

// express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    user?: { id: string; name: string };
  }
}

Now req.user is typed in Express middleware:

app.get('/profile', (req, res) => {
  if (req.user) {
    res.send(`Hello, ${req.user.name}`);
  }
});

Global Augmentation

// globals.d.ts
export {}; // Make this a module

declare global {
  interface Window {
    analytics: {
      track(event: string): void;
    };
  }
}

Now window.analytics is typed:

window.analytics.track('page_view');

DefinitelyTyped (@types/*)

The community-maintained repository for type definitions:

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

Over 8,000 packages have types on DefinitelyTyped. If a library lacks built-in types, check for @types/[package-name].

How It Works

When you install @types/lodash, TypeScript automatically finds it:

import { debounce } from 'lodash';
// TypeScript looks for:
// 1. node_modules/lodash/index.d.ts
// 2. node_modules/@types/lodash/index.d.ts

Publishing Your Own Types

If you maintain a library:

Option 1: Bundle types with your package

// package.json
{
  "name": "my-library",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts"
}

Set "declaration": true in tsconfig.json. TypeScript generates .d.ts files alongside .js files.

Option 2: Publish to DefinitelyTyped

If you don’t control the library, contribute types to DefinitelyTyped:

git clone https://github.com/DefinitelyTyped/DefinitelyTyped
cd DefinitelyTyped
mkdir types/my-library
cd types/my-library
# Write index.d.ts
# Submit PR

Triple-Slash Directives

Special comments for compiler instructions:

/// <reference path="./global.d.ts" />
/// <reference types="node" />

These tell TypeScript to include files or types.

Modern usage is rare. ES modules and tsconfig.json handle most cases.

When You See Them

Export Assignment (CommonJS Compatibility)

Some CommonJS modules export a single value:

// math.js
module.exports = function add(a, b) {
  return a + b;
};

Type it with export =:

// math.d.ts
declare function add(a: number, b: number): number;
export = add;

Import with:

import add = require('./math');

Avoid this pattern. Use ES modules.

Practical Patterns

Barrel Exports

// components/index.ts
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
// Consumers import from one place
import { Button, Input, Modal } from './components';

Cleans up imports. But adds a hop—every import goes through index.ts. Use judiciously.

Conditional Exports (package.json)

For library authors:

{
  "name": "my-library",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "types": "./dist/utils.d.ts"
    }
  }
}

Different files for ESM vs CommonJS. TypeScript understands this.

Type-Only Files

// types.ts
export interface User {
  id: string;
  name: string;
}

export type Status = 'active' | 'inactive';

Centralizes types. No runtime code. Tree-shakes away if unused.

Common Issues

Cannot Find Module

Error: Cannot find module './math' or its corresponding type declarations.

Causes:

Fix:

Circular Dependencies

// a.ts
import { B } from './b';
export class A {}

// b.ts
import { A } from './a';
export class B {}

TypeScript (and JavaScript) allow this, but it can cause runtime errors. Refactor to break the cycle.

Default Export Confusion

// user.ts
export default class User {}

// main.ts
import { User } from './user'; // Error: Module has no exported member 'User'

Default exports aren’t named exports. Use:

import User from './user';

Or:

import * as UserModule from './user';
const User = UserModule.default;

Better: use named exports.

What You’ve Learned

TypeScript’s module system bridges JavaScript’s fractured ecosystem. It supports all module formats, adds type safety, and provides tools for library authors.


Next: Chapter 10: The TypeScript Ecosystem