- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team writes an Express middleware that decodes a JWT and attaches
the decoded user to the request. The handler downstream reads
req.user.id and the editor flags it red. Property 'user' does not. Someone adds
exist on type 'Request'(req as any).user.id,
review approves, the file ships. Weeks later the same as any
hides a refactor where user becomes currentUser upstream, and
nobody catches it because the cast erased the type. A 500 lands in
production over the weekend.
That cast was unnecessary. The TypeScript type system has a way to
say Request has a user field now, in this codebase, applied to
every import of Request everywhere. It is called module
augmentation, and most engineers who reach for as any either do
not know it exists or have tried to write it and watched it
silently do nothing.
The silent failure is the part worth knowing. Module augmentation
has one foot-gun that is invisible at the call site: where you put
the declare module block changes whether it merges or gets
treated as a new declaration. Get it wrong and the editor still
shows the same red squiggle; the augmentation is a no-op.
Three patterns below show where module augmentation pays off, with
the placement rule that makes them work and a debug recipe for when
they don't.
What it actually is
The TypeScript handbook on module augmentation
describes module augmentation as a way to extend modules you do
not own. You write a declare module 'name' { ... } block. The
compiler merges the contents into the existing typings for that
module across the program.
The mechanism is declaration merging. Two interface User { ... }
blocks in the same scope merge their fields. The same rule applies
to interfaces inside two declare module 'foo' blocks for the
same module specifier. The merge happens once during type-checking,
and the merged shape is what every importer sees.
That is the whole machinery. The patterns below are three places
the merge does work the regular type system cannot.
Pattern 1: extending Express's Request
The Express type for Request ships with body, params, query,
a few headers helpers, and not much else. Every middleware that
attaches data has the same problem: the typing does not know.
// types/express.d.ts
import "express";
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
email: string;
roles: readonly string[];
};
requestId?: string;
}
}
Two things to notice. The file imports "express" at the top.
That import turns this file into a module rather than a global
script. Module augmentation only merges when both the augmenter
and the augmented file are modules. A .d.ts with no imports or
exports is treated as a global script, and the declare module
inside it does not merge — it declares a new ambient module of
the same name, which the loader then ignores.
The second thing: the augmented module is
"express-serve-static-core", not "express". Express re-exports
Request from the core package. You augment the file the type
actually lives in. The import "express" at the top is what
pulls the type graph in; the declare module inside targets the
package where Request is defined.
// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { randomUUID } from "node:crypto";
import { verifyJwt } from "./jwt";
export function authn(
req: Request,
res: Response,
next: NextFunction,
): void {
const token = req.header("authorization")?.replace("Bearer ", "");
if (!token) {
res.status(401).end();
return;
}
const decoded = verifyJwt(token);
req.user = {
id: decoded.sub,
email: decoded.email,
roles: decoded.roles ?? [],
};
req.requestId = req.header("x-request-id") ?? randomUUID();
next();
}
// routes/me.ts
import { Router } from "express";
export const me = Router();
me.get("/me", (req, res) => {
if (!req.user) {
res.status(401).end();
return;
}
res.json({ id: req.user.id, email: req.user.email });
});
req.user is typed. req.requestId is typed. The handler reads
them without a cast. The middleware writes them without a cast.
Refactor id to userId upstream and the handler fails to compile
in the same commit.
The ? on each field is load-bearing. The augmentation says these
fields might exist; the middleware is the place they get set;
handlers downstream check before using. Mark them required and you
have lied to every handler that runs before the middleware does.
Pattern 2: adding fields to Window
A frontend app initializes a feature-flags client and a session
store on the window so the rest of the bundle can find them
without an import. The TypeScript globals know nothing about
window.flags and the IDE flags every read.
// types/window.d.ts
export {};
declare global {
interface Window {
flags: {
get: (key: string) => boolean;
refresh: () => Promise<void>;
};
session: {
userId: string | null;
tenantId: string | null;
};
}
}
The export {} line is the trick on this one. The file has nothing
to export, but without export {} it is a script, not a module,
and the declare global block becomes a global declaration itself
rather than augmenting the existing global scope. The export {}
flips the file to module mode; declare global then opens a
window into the global scope from inside the module. Same merge
rule, different door.
The same tsconfig caveat from the foot-gun section applies here:
types/window.d.ts only takes effect if the include array in
tsconfig.json covers the types/ folder. A file outside the
program is a file the compiler never reads.
// app/init.ts
import { createFlagsClient } from "./flags";
window.flags = createFlagsClient(import.meta.env.VITE_FLAGS_URL);
window.session = { userId: null, tenantId: null };
// components/feature-toggle.tsx
export function FeatureToggle({ k, on, off }: {
k: string;
on: React.ReactNode;
off: React.ReactNode;
}) {
return window.flags.get(k) ? <>{on}</> : <>{off}</>;
}
The same shape works for globalThis and NodeJS.ProcessEnv. A
typed process.env:
// types/env.d.ts
export {};
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
REDIS_URL: string;
STRIPE_SECRET: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
}
}
}
process.env.DATABASE_URL becomes string instead of
string | undefined. Every other env access stays
string | undefined. The augmentation is additive: fields you do
not declare keep their original typing.
Pattern 3: branding a third-party export
A library exports a Money type. You want to ban arithmetic
between Money values of different currencies inside your
application without forking the library. Augmentation lets you
add a phantom field and keep the original type intact.
// types/money-brand.d.ts
import "money-lib";
declare module "money-lib" {
interface Money {
readonly __currency: string;
}
}
// money/usd.ts
import { Money, fromCents } from "money-lib";
export type USD = Money & { readonly __currency: "USD" };
export type EUR = Money & { readonly __currency: "EUR" };
export function usd(cents: number): USD {
return fromCents(cents, "USD") as USD;
}
export function eur(cents: number): EUR {
return fromCents(cents, "EUR") as EUR;
}
export function add<C extends string>(
a: Money & { readonly __currency: C },
b: Money & { readonly __currency: C },
): Money & { readonly __currency: C } {
return fromCents(toCents(a) + toCents(b), a.currency) as
Money & { readonly __currency: C };
}
const a = usd(100);
const b = usd(200);
const c = eur(50);
add(a, b); // ok
add(a, c); // error: Type 'EUR' is not assignable to type 'USD'.
The library's Money does not change at runtime. The augmentation
adds a type-only field that the application's branded subtypes
can pin. Library users that do not import this declaration file
see the original Money. The discipline is local.
The same pattern works on classes. If a vendor exports a class and
you want to add a method that exists on every instance through a
prototype patch, augmenting the class declaration is how you tell
the type system the patched method exists. The runtime patch and
the type-level augmentation live in the same file, side by side.
The foot-gun: where declare module lives
Module augmentation has one rule with two failure modes that look
identical from the outside. Both render the augmentation a silent
no-op.
Failure mode one: the augmenter file is a script, not a module.
// types/bad.d.ts — broken
declare module "express-serve-static-core" {
interface Request {
user?: { id: string };
}
}
No import statement. No export statement. The TypeScript
compiler treats this file as a global script. The declare module
inside a script is a new ambient module declaration, not an
augmentation. The original Express types still win, and req.user
remains untyped.
The fix is one line.
// types/good.d.ts
import "express"; // this line makes the file a module
declare module "express-serve-static-core" {
interface Request {
user?: { id: string };
}
}
export {} works too. Anything that turns the file into a module
will do. The pattern that hurts most is .d.ts files that used
to import something, then someone removed the import during a
cleanup, and the augmentation broke without any visible diff at
the call sites.
Failure mode two: the augmentation is inside a non-module file's
top level.
// app/setup.ts — broken
declare module "express-serve-static-core" {
interface Request {
user?: { id: string };
}
}
console.log("setting up");
If app/setup.ts has imports, the augmentation works. If
app/setup.ts is a top-level script with no imports or exports,
the same declare module block declares a new ambient module
named "express-serve-static-core" inside the global namespace.
The compiler now believes there are two unrelated modules with
the same name. Imports resolve to the original. The augmentation
goes nowhere.
The rule, plain: declare module 'foo' augments foo only
when it lives inside a module. A file is a module when it has at
least one top-level import or export. Anywhere else, the same
syntax declares a new ambient module that collides with the real
one rather than merging into it.
The reason both failure modes are silent is that no error fires.
The compiler sees a valid declaration. It just ends up in the
wrong namespace bucket. The tooling has no way to know you meant
to augment.
Debugging when augmentation does not fire
Step one: open the file at the call site, hover the property the
augmentation was supposed to add, and read what the editor says
the type actually is. If the property is missing, the augmentation
did not merge.
Step two: confirm the augmenter file is a module. Look for an
import or export statement at the top. If neither exists, add
export {}; and save. If the property type changes in the editor,
that was the bug.
Step three: confirm the augmented module specifier matches the
file the type lives in, not the package's main entry. For Express,
that is "express-serve-static-core". For most React-based
libraries, it is the package itself. Open node_modules/<pkg> and
search for interface <TheTypeYouAreExtending> to find which file
the original definition lives in; the declare module specifier
needs to match that file's module name.
Step four: confirm the augmenter file is included by the
TypeScript program. A .d.ts outside include in tsconfig.json
is invisible to the compiler. The types/*.d.ts convention works
because most tsconfig files have "include": ["src", "types"]
or equivalent. A file dropped into a folder the config does not
cover is the same as a file that does not exist.
Step five, when the first four did not surface anything, ask the
compiler to trace its module resolution.
tsc --traceResolution > resolution.log 2>&1
The output is large. Search it for the module specifier you
augmented. Each entry shows where the compiler looked for the
module, which file it resolved to, and which other files it
considered. If the augmenter file is missing from the trace, the
program did not include it. If the trace shows the augmenter
resolving to a different module than the original (because of a
typo, a path mismatch, or a stale .d.ts shim), the merge cannot
happen.
The trace is also how you catch the case where two augmenters
resolve to different files even though they target what looks
like the same module — node_modules/foo/index.d.ts versus a
local types/foo.d.ts shim. The compiler picks one; the other
goes nowhere.
Where this fits in a real codebase
A typical TypeScript app ships with three augmentation files in
types/. One for the framework (Express, Fastify, Koa). One for
the global window or process env. One for any vendor library that
needs branding or a prototype patch. Each is short. Each lives
under version control next to the code that depends on it. Each
starts with an import or export {} to keep it a module.
That is the whole adoption arc. The patterns are not deep. The
foot-gun is the part that takes a Saturday to debug the first
time you hit it. After that, the rule sticks: every file with
declare module in it needs to be a module.
The next time a teammate reaches for as any to put a property
on req, point them at the four-line .d.ts that does the same
job and fails the build when the upstream rename lands. A cast
silences the compiler; an augmentation gets the compiler to do
the work for you.
If this was useful
Module augmentation and declaration merging are part of the
mechanism The TypeScript Type System covers, alongside generics,
mapped and conditional types, infer, and the template-literal
machinery that lets you write the kind of library-grade typings
the augmentation patterns above lean on. If the foot-gun surprised
you, the chapter on declaration merging is the one to read first.
If you are coming from JVM languages where extension functions and
companion objects fill a similar role, Kotlin and Java to
TypeScript covers the bridge into TypeScript's structural and
declaration-merge model. If you are coming from PHP 8+, PHP to
TypeScript covers the same ground from the other side.
TypeScript in Production covers the build, monorepo, and
dual-publish concerns that the type system itself does not touch,
including how to ship .d.ts augmentations in a published package
without breaking your consumers.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)