typescript · level 8

Modules & Declarations

ESM vs CJS, .d.ts files, ambient declarations, module augmentation, types-only imports.

175 XP

Modules & Declarations

How TypeScript resolves an import is one of the most-asked questions and one of the most opaque areas of the language. The answer involves three pieces: the module system you're emitting, the declaration files that describe types, and the way TS finds them on disk.

ESM vs CJS

JavaScript has two module systems and you'll touch both:

ESM (ECMAScript Modules) — the standard:

import { readFile } from "node:fs/promises";
import type { User } from "./types";
export const VERSION = "1.0.0";
export default function main() { /* … */ }

CJS (CommonJS) — the older Node format:

const { readFile } = require("node:fs/promises");
module.exports = { VERSION: "1.0.0", main };

ESM is async, statically analyzable, and tree-shakeable. CJS is synchronous, dynamic, and pervasive in older npm packages. Modern code should be ESM; you'll still consume CJS dependencies for years.

In tsconfig.json, the module setting controls what TS emits:

Setting Meaning
esnext / es2022 Pure ESM (import/export preserved).
commonjs Compiles to require/module.exports.
nodenext Picks per file: .mts → ESM, .cts → CJS, .ts honors package.json's "type".

For new Node projects: "type": "module" in package.json + "module": "nodenext" in tsconfig — pure ESM.

Declaration files (.d.ts)

A .d.ts file is types-only. It contains no runtime code, gets erased entirely at build time, and has one job: describe the shape of code that TypeScript would otherwise have no information about.

Two main uses:

1. Ship types separately from JS

A library written in JS can ship types via a sibling .d.ts:

// math.js
export function add(a, b) { return a + b }

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

The runtime is JavaScript; the types live alongside in the declaration file. package.json's "types" field points to the entry .d.ts.

2. Describe untyped code

When you depend on a JS package without types and @types/<package> doesn't exist, write a declaration:

// types/legacy.d.ts
declare module "legacy-untyped-package" {
  export function compute(input: string): number;
  export const VERSION: string;
}

declare module introduces an ambient module — TypeScript treats imports of "legacy-untyped-package" as having that exact shape. No runtime cost.

Module augmentation

The killer feature: extending a third-party type from your own code.

// types/express.d.ts
import "express-serve-static-core";

declare module "express-serve-static-core" {
  interface Request {
    user?: { id: number; role: "admin" | "user" };
  }
}

Now every req: Request in your codebase has req.user typed correctly — even though express knows nothing about your auth layer.

This is how:

  • next-auth adds session.user.id to the typed session.
  • vite extends import.meta.env.
  • Your own monorepo wires up a typed event bus across packages.

The mechanism is declaration merging (covered in the interfaces lesson): two interface declarations with the same name in the same scope get combined. declare module "pkg" is the way to enter that scope from outside.

Triple-slash directives (the old way)

Before module augmentation, the way to reference an external declaration was:

/// <reference types="node" />

You'll see this in legacy code and at the top of lib.dom.d.ts. New code should not need it — tsconfig.json's types array does the same job declaratively.

Types-only imports

import type { User } from "./types";

The type keyword tells TS: this import contributes only to type checking; it's safe to erase at runtime. Two reasons it matters:

  1. Bundle size. A regular import { X } keeps the file around in your build even if X is only used as a type. import type removes the dependency from runtime entirely.
  2. Cycles. Types-only imports don't create runtime cycles. If you have a circular type reference, import type breaks it without breaking your build.

You can mix:

import { handler, type Config } from "./mod";

handler is a runtime value; Config is types-only. The type modifier inline scopes to that one symbol.

Module resolution: how TS finds files

Three settings interact:

  • moduleResolutionnode / node16 / bundler (newer). Determines the algorithm.
  • paths — alias resolution (e.g. @/lib/xsrc/lib/x).
  • baseUrl — root for non-aliased relative resolution.

A real example:

{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

import x from "@/lib/x" resolves to <project>/src/lib/x (with .ts, .tsx, .d.ts, or index.ts extensions tried automatically).

bundler is the new (TS 5+) resolution mode designed for Vite/esbuild/webpack — it understands subpath imports without requiring .js extensions in TS source. Use it unless you're emitting raw Node.js.

tsc --noEmit vs tsc

A common confusion: tsc does two things — type-check, and emit JS. They're separable:

  • tsc --noEmit — type-check only. Your CI's TypeScript gate. Fast, no output.
  • tsc — type-check AND emit JS to disk. What you use when shipping types or compiling in-place.

Most modern setups skip the emit entirely. Vite, Next.js, and friends compile TS via esbuild/swc (much faster, no type checking). You run tsc --noEmit separately for the type-check.

A quick checklist for "cannot find module"

When TS can't resolve an import:

  1. Is the file a module? Files without any import/export are script files in TS's eyes — their declarations leak into the global scope, not into a module. Add an empty export {} to make a file a module.
  2. Does the package have types? Check the package's package.json for "types" or "typings". Otherwise npm i -D @types/<package>.
  3. Is your paths alias correct? Hover the import in the IDE — it should resolve. If not, check baseUrl + paths.
  4. Is the file extension right? nodenext requires .js extensions in source imports of TS files. bundler doesn't.
  5. Does your bundler agree? TypeScript's resolution doesn't change what your bundler does. Vite, Next.js, Webpack all need their own paths config (or tsconfig-paths plugin).

When to write a .d.ts

You almost never write one for your own TS code — your .ts files already declare types. You write .d.ts files when:

  • You're shipping a library and the consumer's TypeScript needs types separated from JS.
  • You're typing a JS dependency that's missing types (and DefinitelyTyped doesn't have them).
  • You're declaring a global (declare global { interface Window { … } }).
  • You're augmenting a third-party module.

Keep them in a dedicated folder (e.g. types/), reference them from tsconfig.json's include, and treat them as code — they often get more reuse than the runtime they describe.

Tools in the wild

3 tools
  • tsc --noEmitfree tier

    Type-check without producing output. The standard CI gate for TypeScript.

    cli
  • Community declaration files for ~10,000 npm packages without their own types.

    library
  • Audits a published npm package's ESM/CJS/types interop story.

    service