<< Home

Chapter 6: Modules and Project Organization

Modular code keeps projects understandable and maintainable; it also enables incremental builds and clean public surfaces. TypeScript compiles to JavaScript while preserving your module boundaries, so understanding formats, resolution rules, and project layout is essential. This chapter explains how module kinds affect runtime behavior, how to organize exports and imports, how to describe ambient shapes with .d.ts files, and how to ship type definitions that work for consumers using different toolchains.

ES modules, CommonJS, and compilerOptions

JavaScript has two major module systems in real projects. ES modules use import and export syntax with strict semantics and static analysis; CommonJS uses require and module.exports with dynamic semantics. TypeScript supports both. The combination of your tsconfig.json settings and your package.json determines how the emitted code behaves at runtime.

Aspect ES modules CommonJS
Syntax import { x } from "./x.js" const x = require("./x")
Interop Default and named imports; live bindings module.exports object; values copied on require
Top level await Supported in modern runtimes Not natively supported
Node detection "type": "module" in package.json or .mjs Default in Node when no "type": "module" and files use .cjs or .js

For TypeScript, the module compiler option selects the emitter target. The moduleResolution option selects how imports are resolved. Modern Node workflows usually choose "module": "NodeNext" and "moduleResolution": "NodeNext" to align with current Node behavior. Browser builds often use bundler resolution through tools that understand bare specifiers and aliases.

💡 When migrating from CommonJS, enable esModuleInterop to smooth default import semantics; it keeps your source readable while the codebase transitions.

tsconfig.json choices that map to runtime behavior

Your compiler options should reflect the environment where the output runs. Pick a module target that your runtime understands; pick a resolution strategy that matches how your runtime or bundler finds files.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "strict": true
  }
}

With NodeNext, TypeScript respects package.json fields like type and exports, and it requires file extensions in import paths that match runtime expectations.

package.json alignment for Node and bundlers

Your package metadata influences module loading. Use "type" to set the default format and use "exports" to define public entry points. Provide both import and require conditions when you publish libraries that support dual consumers.

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}
⚠️ For NodeNext, include file extensions in relative import paths such as import "./util.js"; omitting the extension compiles but fails at runtime in strict ESM setups.

Examples of ESM and CJS source

The following snippets show equivalent exports and imports using each style. Prefer one style within a project for clarity; mixed styles make resolution rules harder to reason about.

// ESM: src/math.ts
export function add(a: number, b: number) { return a + b; }
export default function mul(a: number, b: number) { return a * b; }
// ESM consumer
import mul, { add } from "./math.js";
console.log(add(2, 3), mul(2, 3));
// CJS: src/math.cjs
function add(a, b) { return a + b; }
function mul(a, b) { return a * b; }
module.exports = { add, default: mul };
// CJS consumer
const { add, default: mul } = require("./math.cjs");
console.log(add(2, 3), mul(2, 3));

Barrel files and path mapping

A barrel groups exports from multiple modules into a single public surface. This reduces import verbosity and makes refactors simpler; the trade off is that careless wildcards can reexport more than intended. Path mapping shortens import strings by creating aliases that map to real directories during compilation.

Creating a focused barrel

Export exactly what callers need. Avoid blanket reexports from deep trees where side effects are not obvious. Keep the barrel small and intentional.

// src/index.ts
export { parseUser } from "./users/parseUser.js";
export { saveUser } from "./users/saveUser.js";
export type { User, UserId } from "./users/types.js";
💡 Reexport types with export type so the emitter can erase them; this keeps JavaScript output minimal.

paths and baseUrl in tsconfig.json

Path mapping replaces long relative segments with short aliases. The compiler rewrites these during type checking; your runtime still needs matching behavior through a bundler or a loader.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@lib/*": ["src/lib/*"],
      "@models/*": ["src/models/*"]
    }
  }
}

After configuring the mapping, import from aliases instead of long relative paths.

// before
import { Widget } from "../../../lib/widgets/widget.js";
// after
import { Widget } from "@lib/widgets/widget.js";
⚠️ Node does not understand paths by itself. Use a bundler that honors tsconfig paths or install a runtime resolver such as a loader that maps aliases to real paths.

Project references with barrels

Large repositories often split code into buildable units that reference each other. Combine project references with barrels to expose crisp entry points while keeping incremental builds fast.

// tsconfig.build.json
{
  "files": [],
  "references": [{ "path": "./packages/core" }, { "path": "./packages/web" }]
}

Each referenced package should emit declarations so the top level barrel can surface types across boundaries without extra wiring.

Ambient declarations and .d.ts files

Ambient declarations describe shapes that exist at runtime without providing implementations. Use them for globals, non TS modules, and augmentation. Keep them separate from implementation files so consumers can rely on them without pulling code.

Declaring globals with declare

Put global types in a dedicated .d.ts file. Use declare global to avoid accidental pollution of the module scope.

// global.d.ts
export {};
declare global {
  interface Window {
    appVersion: string;
  }
}

The empty export marks the file as a module; this prevents unintended global declarations that leak during compilation.

Declaring modules for untyped packages

When a dependency has no types, stub the module with an ambient declaration. Start minimal; refine later as usage grows.

// types/external-lib/index.d.ts
declare module "external-lib" {
  export interface Options { verbose?: boolean; }
  export function run(options?: Options): Promise<void>;
}
💡 Place ambient stubs under a types folder and add it to typeRoots if you want strict control over discovery.

typeRoots, types, and library filtering

Use typeRoots to point TypeScript at folders that contain package style types; use types to enable a specific list of global packages. These options reduce noise in projects that only need a subset of declarations.

{
  "compilerOptions": {
    "typeRoots": ["./types", "./node_modules/@types"],
    "types": ["node", "jest"]
  }
}

If you omit types, TypeScript includes all packages under @types by default, which may add unwanted globals.

Publishing type definitions

Libraries should publish stable .d.ts files so users get IntelliSense and safety. You can emit declarations from your source or you can hand write them. Include proper entry points in package.json so all toolchains find the types.

Emit .d.ts from source

Turn on declaration emit and keep your public API explicit. The compiler will generate matching .d.ts files that mirror your exports.

// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": false,
    "outDir": "dist"
  }
}
// src/index.ts
export interface Config { retries?: number; }
export function connect(url: string, cfg?: Config) { /* … */ }

After compilation, publish dist/index.d.ts along with your JavaScript files. Declaration maps help editors jump to source when available.

types entry and dual support

Point consumers at your primary declaration file by adding a types entry. For packages with multiple entry points, prefer conditional exports with a types key to keep everything aligned.

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./cli": {
      "import": "./dist/cli.js",
      "require": "./dist/cli.cjs",
      "types": "./dist/cli.d.ts"
    }
  }
}
⚠️ Keep file paths in exports consistent with what you publish. A missing declaration path breaks editor tooling even when the runtime code works.

Publishing to @types versus bundling your own

If your package is authored in JavaScript, you can ship a separate @types/your-package definition on DefinitelyTyped or bundle .d.ts files directly in your package. Bundling is simple for consumers because npm install delivers runtime code and types in one step; hosting on DefinitelyTyped works when you do not control the original package or you want the community to maintain the declarations.

// package.json for JS source that includes types
{
  "name": "cool-js-lib",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist", "README.md", "LICENSE"]
}

When contributing to DefinitelyTyped, follow its guidelines and versioning rules; declaration packages track the major version of the upstream library to keep compatibility clear.