Module Augmentation & Global Types
๐งฉ Module Augmentation & Global Types
Request, the global Window, even Array.prototype itself. This chapter covers the mechanism that makes that safe: declaration merging, module augmentation, and where a type-safe polyfill's type declaration ends and its actual implementation must begin.
The Foundational Mechanic: Declaration Merging
Two interface declarations with the same name don't conflict โ they merge into one interface with every member from both. This is the one rule everything else in this chapter builds on:
interface User { id: string; } interface User { email: string; } // TypeScript merges both declarations automatically: const user: User = { id: "1", email: "a@b.com" }; // โ both fields required // type User = { id: string }; // โ `type` aliases do NOT merge โ this would be a duplicate-name error
type aliases are single, fixed declarations โ only interface (and namespace) support this merging behavior, which is why library type definitions almost always use interface for anything meant to be extensible.
๐ฆ Augmenting a Third-Party Module
The most common real-world use: adding req.user to Express's Request type after an authentication middleware attaches it โ a gap left open by the Authentication & Session Security and Express courses' JWT middleware pattern:
Augmenting Express's Request
// types/express/index.d.ts import "express"; interface AuthUser { id: string; role: "admin" | "member"; } declare module "express" { interface Request { user?: AuthUser; } }
Before vs After Augmentation
Without Augmentation
app.get("/profile", (req, res) => { console.log(req.user); // โ Property 'user' does not exist on type 'Request' });
With Augmentation
app.get("/profile", (req, res) => { console.log(req.user?.role); // โ req.user is typed as AuthUser | undefined everywhere });
import "express";
Required at the top of the augmentation file โ without a real import/export, TypeScript treats the file as a global script, not a module augmentation.
Merges Into Every Import
Once declared, Request's extra field is visible anywhere Express's types are imported โ no per-file re-declaration needed.
Augmenting Your Own Modules Across Files
The same merging mechanic works for your own large interfaces โ useful for splitting a plugin system's config across files without one giant file:
// core.ts export interface PluginConfig { name: string; } // analytics-plugin.ts import { PluginConfig } from "./core"; declare module "./core" { interface PluginConfig { analyticsEnabled?: boolean; } }
๐ Global Augmentation
Extending the global scope itself (Window in a browser, globalThis in Node) uses declare global instead of declare module:
// global.d.ts export {}; // makes this file a module so `declare global` is allowed declare global { interface Window { myAppVersion: string; } } // anywhere in the browser codebase: console.log(window.myAppVersion); // โ typed, no cast needed
Type-Safe Polyfills
Declaring a method exists on Array.prototype only tells the compiler about it โ it does not make the method actually exist at runtime. The type declaration and the real implementation are two separate, equally necessary steps:
// 1. The type declaration โ describes the shape, changes nothing at runtime declare global { interface Array<T> { groupBy<K extends PropertyKey>(keyFn: (item: T) => K): Record<K, T[]>; } } // 2. The actual polyfill โ this is what makes it real at runtime if (!Array.prototype.groupBy) { Array.prototype.groupBy = function(keyFn) { const result = {} as any; for (const item of this) { const key = keyFn(item); (result[key] ??= []).push(item); } return result; }; } [1, 2, 3, 4].groupBy((n) => (n % 2 === 0 ? "even" : "odd")); // { odd: [1, 3], even: [2, 4] } โ works because BOTH steps happened
๐ป Coding Challenges
Challenge 1: Merge Two Interfaces
Declare an interface Config in one code block with a port: number field, then declare a second interface Config adding a host: string field. Show that a single object literal must satisfy both fields at once.
Goal: Practice the core declaration-merging mechanic before applying it to a real module.
Challenge 2: Augment Express's Request
Write a declare module "express" augmentation adding a requestId: string field to Request (simulating a correlation-ID middleware from the Logging & Observability chapter), then write a route handler that reads req.requestId without a type error.
Goal: Practice the real-world module augmentation pattern end-to-end.
Challenge 3: Type and Implement a Polyfill
Add a type declaration for Array.prototype.last(): T | undefined via declare global, then write the actual runtime implementation that makes it work, and call it on a real array.
Goal: Practice keeping the type declaration and runtime implementation in sync, as this chapter's gotcha warns is easy to forget.
Writing declare global { interface Array<T> { groupBy... } } and stopping there compiles cleanly and then crashes at runtime with "array.groupBy is not a function" โ the declaration only tells the compiler what to expect, it does nothing to make the method exist. Also watch the build config: an augmentation `.d.ts` file that isn't included by your tsconfig.json's include pattern (or isn't imported/referenced from anywhere) is silently ignored โ the augmentation simply never applies, with no error to point at why.
๐ฏ What's Next
With the type system extended safely to code outside your control, the next chapter turns inward: Performance & Type Checker Optimization โ understanding why some of the patterns from this course can make tsc itself slow, and how to keep type inference fast on a large codebase.