Modules & Declarations
ESM vs CJS, .d.ts files, ambient declarations, module augmentation, types-only imports.
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-authaddssession.user.idto the typed session.viteextendsimport.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:
- Bundle size. A regular
import { X }keeps the file around in your build even if X is only used as a type.import typeremoves the dependency from runtime entirely. - Cycles. Types-only imports don't create runtime cycles. If you have a circular type reference,
import typebreaks 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:
moduleResolution—node/node16/bundler(newer). Determines the algorithm.paths— alias resolution (e.g.@/lib/x→src/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:
- Is the file a module? Files without any
import/exportare script files in TS's eyes — their declarations leak into the global scope, not into a module. Add an emptyexport {}to make a file a module. - Does the package have types? Check the package's
package.jsonfor"types"or"typings". Otherwisenpm i -D @types/<package>. - Is your
pathsalias correct? Hover the import in the IDE — it should resolve. If not, checkbaseUrl+paths. - Is the file extension right?
nodenextrequires.jsextensions in source imports of TS files.bundlerdoesn't. - Does your bundler agree? TypeScript's resolution doesn't change what your bundler does. Vite, Next.js, Webpack all need their own
pathsconfig (ortsconfig-pathsplugin).
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- clitsc --noEmitfree tier
Type-check without producing output. The standard CI gate for TypeScript.
- libraryDefinitelyTyped (@types/*)free tier
Community declaration files for ~10,000 npm packages without their own types.
- serviceAre The Types Wrongfree tier
Audits a published npm package's ESM/CJS/types interop story.