Building Type-Safe Libraries & Module Augmentation
📖 Concept
Building TypeScript libraries requires a different mindset than application code — you're designing APIs that OTHER developers consume. The types ARE your API surface.
Key concerns for library authors:
- Exported types — What types do consumers see? Minimize exposed surface area
- Declaration files — Ship
.d.tsfiles so consumers get types auto-complete package.jsonexports— The modern way to define entry points and type resolution- Generic APIs — Make types flow through without forcing consumers to specify generics
- Module augmentation — Allow consumers to extend your types
Declaration file generation:
declaration: truein tsconfig generates.d.tsfilesdeclarationMap: trueenables "Go to Definition" for your libraryemitDeclarationOnly: truewhen using a separate bundler (esbuild, Rollup)
package.json setup for typed libraries:
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs" }
}
Module augmentation lets consumers add types to YOUR library:
declare module "your-lib" {
interface Config { customField: string; }
}
🏠 Real-world analogy: Building a typed library is like manufacturing a power tool with standardized fittings. The tool (library) must work with any compatible accessory (consumer types). The specification sheet (.d.ts) tells users exactly what's compatible. Module augmentation is like an adapter kit that lets users add new fittings.
💻 Code Example
1// Building a typed event emitter library2// ==========================================34// Consumer defines their event map5interface EventMap {6 [event: string]: any; // Base (consumers extend this)7}89// Type-safe event emitter10class TypedEmitter<Events extends EventMap> {11 private listeners = new Map<keyof Events, Set<Function>>();1213 on<K extends keyof Events>(14 event: K,15 handler: (payload: Events[K]) => void16 ): () => void {17 if (!this.listeners.has(event)) {18 this.listeners.set(event, new Set());19 }20 this.listeners.get(event)!.add(handler);2122 // Return unsubscribe function23 return () => {24 this.listeners.get(event)?.delete(handler);25 };26 }2728 emit<K extends keyof Events>(event: K, payload: Events[K]): void {29 this.listeners.get(event)?.forEach((handler) => handler(payload));30 }3132 off<K extends keyof Events>(33 event: K,34 handler: (payload: Events[K]) => void35 ): void {36 this.listeners.get(event)?.delete(handler);37 }38}3940// Consumer usage — fully type-safe!41interface AppEvents {42 login: { userId: string; timestamp: number };43 logout: { reason: string };44 error: { message: string; code: number };45}4647const bus = new TypedEmitter<AppEvents>();4849bus.on("login", (payload) => {50 console.log(payload.userId); // ✅ Type: string51 console.log(payload.timestamp); // ✅ Type: number52});5354bus.emit("login", { userId: "123", timestamp: Date.now() }); // ✅55// bus.emit("login", { wrong: "field" }); // ❌ Type error!56// bus.emit("typo", {}); // ❌ "typo" not in AppEvents5758// Builder pattern with fluent API59class QueryBuilder<T extends Record<string, any>> {60 private conditions: string[] = [];61 private selectedFields: (keyof T)[] = [];6263 select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {64 this.selectedFields = fields as any;65 return this as any;66 }6768 where<K extends keyof T>(69 field: K,70 op: "=" | "!=" | ">" | "<",71 value: T[K]72 ): this {73 this.conditions.push(`${String(field)} ${op} ${JSON.stringify(value)}`);74 return this;75 }7677 build(): string {78 const fields = this.selectedFields.length79 ? this.selectedFields.join(", ")80 : "*";81 const where = this.conditions.length82 ? ` WHERE ${this.conditions.join(" AND ")}`83 : "";84 return `SELECT ${fields}${where}`;85 }86}8788// Type-safe query builder usage89interface User {90 id: number;91 name: string;92 email: string;93 age: number;94}9596const query = new QueryBuilder<User>()97 .select("name", "email") // Only valid User keys98 .where("age", ">", 18) // Value must match type of 'age' (number)99 .build();100101// Module augmentation — allow consumers to extend102// In your library:103export interface PluginRegistry {}104105export function registerPlugin<K extends keyof PluginRegistry>(106 name: K,107 plugin: PluginRegistry[K]108): void {109 // ...110}111112// Consumer code:113// declare module "your-lib" {114// interface PluginRegistry {115// analytics: { track(event: string): void };116// auth: { login(token: string): void };117// }118// }119// registerPlugin("analytics", { track: (e) => {} }); // ✅ Type-safe!
🏋️ Practice Exercise
Mini Exercise:
- Build a type-safe
EventEmitter<EventMap>withon,emit, andoff - Create a builder pattern with fluent API that preserves types at each step
- Set up a library's
package.jsonwith propertypesandexportsfields - Create a plugin system using module augmentation (consumers add their types)
- Write a generic
Validator<Schema>that infers the output type from the schema
⚠️ Common Mistakes
Not generating declaration files — without
.d.ts, consumers won't get any type information; enabledeclaration: trueExporting too many types — minimize your public type surface; only export what consumers need
Not testing types — use
tsdorexpect-typeto verify your library's types work as expected for consumersMaking every generic parameter required — use defaults (
<T = any>) and inference so consumers rarely need to specify genericsBreaking types in minor versions — type changes are breaking changes! A type change can break consumer builds
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Building Type-Safe Libraries & Module Augmentation