Building Type-Safe Libraries & Module Augmentation

0/5 in this phase0/21 across the roadmap

📖 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.ts files so consumers get types auto-complete
  • package.json exports — 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: true in tsconfig generates .d.ts files
  • declarationMap: true enables "Go to Definition" for your library
  • emitDeclarationOnly: true when 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

codeTap to expand ⛶
1// Building a typed event emitter library
2// ==========================================
3
4// Consumer defines their event map
5interface EventMap {
6 [event: string]: any; // Base (consumers extend this)
7}
8
9// Type-safe event emitter
10class TypedEmitter<Events extends EventMap> {
11 private listeners = new Map<keyof Events, Set<Function>>();
12
13 on<K extends keyof Events>(
14 event: K,
15 handler: (payload: Events[K]) => void
16 ): () => void {
17 if (!this.listeners.has(event)) {
18 this.listeners.set(event, new Set());
19 }
20 this.listeners.get(event)!.add(handler);
21
22 // Return unsubscribe function
23 return () => {
24 this.listeners.get(event)?.delete(handler);
25 };
26 }
27
28 emit<K extends keyof Events>(event: K, payload: Events[K]): void {
29 this.listeners.get(event)?.forEach((handler) => handler(payload));
30 }
31
32 off<K extends keyof Events>(
33 event: K,
34 handler: (payload: Events[K]) => void
35 ): void {
36 this.listeners.get(event)?.delete(handler);
37 }
38}
39
40// Consumer usage — fully type-safe!
41interface AppEvents {
42 login: { userId: string; timestamp: number };
43 logout: { reason: string };
44 error: { message: string; code: number };
45}
46
47const bus = new TypedEmitter<AppEvents>();
48
49bus.on("login", (payload) => {
50 console.log(payload.userId); // ✅ Type: string
51 console.log(payload.timestamp); // ✅ Type: number
52});
53
54bus.emit("login", { userId: "123", timestamp: Date.now() }); // ✅
55// bus.emit("login", { wrong: "field" }); // ❌ Type error!
56// bus.emit("typo", {}); // ❌ "typo" not in AppEvents
57
58// Builder pattern with fluent API
59class QueryBuilder<T extends Record<string, any>> {
60 private conditions: string[] = [];
61 private selectedFields: (keyof T)[] = [];
62
63 select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
64 this.selectedFields = fields as any;
65 return this as any;
66 }
67
68 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 }
76
77 build(): string {
78 const fields = this.selectedFields.length
79 ? this.selectedFields.join(", ")
80 : "*";
81 const where = this.conditions.length
82 ? ` WHERE ${this.conditions.join(" AND ")}`
83 : "";
84 return `SELECT ${fields}${where}`;
85 }
86}
87
88// Type-safe query builder usage
89interface User {
90 id: number;
91 name: string;
92 email: string;
93 age: number;
94}
95
96const query = new QueryBuilder<User>()
97 .select("name", "email") // Only valid User keys
98 .where("age", ">", 18) // Value must match type of 'age' (number)
99 .build();
100
101// Module augmentation — allow consumers to extend
102// In your library:
103export interface PluginRegistry {}
104
105export function registerPlugin<K extends keyof PluginRegistry>(
106 name: K,
107 plugin: PluginRegistry[K]
108): void {
109 // ...
110}
111
112// 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:

  1. Build a type-safe EventEmitter<EventMap> with on, emit, and off
  2. Create a builder pattern with fluent API that preserves types at each step
  3. Set up a library's package.json with proper types and exports fields
  4. Create a plugin system using module augmentation (consumers add their types)
  5. 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; enable declaration: true

  • Exporting too many types — minimize your public type surface; only export what consumers need

  • Not testing types — use tsd or expect-type to verify your library's types work as expected for consumers

  • Making every generic parameter required — use defaults (<T = any>) and inference so consumers rarely need to specify generics

  • Breaking 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