Module Augmentation & Global Types

Advanced TypeScript — Module Augmentation & Global Types
Advanced TypeScript
Course 4 · Chapter 7 · Module Augmentation & Global Types

🧩 Module Augmentation & Global Types

Every type built so far in this course lived in code you own. Sometimes the type that needs changing belongs to a library — Express's 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.

→ Solution

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.

→ Solution

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.

→ Solution

⚠️ Gotcha: A Type Declaration Is Not an Implementation

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.