TypeScript

Building Type-Safe Libraries

25 min Lesson 37 of 40

Building Type-Safe Libraries

Creating TypeScript libraries requires careful attention to type safety, API design, and developer experience. In this lesson, we'll explore generic library patterns, function overloads, type inference optimization, and how to publish libraries with proper type definitions.

Library Structure

A well-structured TypeScript library should have:

my-library/ ├── src/ │ ├── index.ts # Main entry point │ ├── types.ts # Type definitions │ ├── core/ # Core functionality │ └── utils/ # Utility functions ├── dist/ # Compiled output │ ├── index.js # CommonJS │ ├── index.d.ts # Type declarations │ └── index.esm.js # ES modules ├── package.json ├── tsconfig.json └── README.md

tsconfig.json for Libraries

{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], "declaration": true, // Generate .d.ts files "declarationMap": true, // Generate .d.ts.map for debugging "outDir": "./dist", "rootDir": "./src", "removeComments": false, // Keep JSDoc comments "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] }

Generic Library Patterns

Generics are the foundation of reusable, type-safe libraries:

// Generic collection utilities export class Collection<T> { private items: T[] = []; add(item: T): this { this.items.push(item); return this; // Method chaining } remove(predicate: (item: T) => boolean): T | undefined { const index = this.items.findIndex(predicate); if (index === -1) return undefined; return this.items.splice(index, 1)[0]; } find(predicate: (item: T) => boolean): T | undefined { return this.items.find(predicate); } filter(predicate: (item: T) => boolean): Collection<T> { const filtered = new Collection<T>(); filtered.items = this.items.filter(predicate); return filtered; } map<U>(mapper: (item: T) => U): Collection<U> { const mapped = new Collection<U>(); mapped.items = this.items.map(mapper); return mapped; } toArray(): T[] { return [...this.items]; } get length(): number { return this.items.length; } } // Usage const numbers = new Collection<number>(); numbers.add(1).add(2).add(3); const doubled = numbers.map(n => n * 2); // Collection<number> const strings = numbers.map(n => n.toString()); // Collection<string>

Advanced Generic Constraints

// Generic with constraints interface Identifiable { id: string | number; } export class Repository<T extends Identifiable> { private items = new Map<string | number, T>(); save(item: T): void { this.items.set(item.id, item); } findById(id: string | number): T | undefined { return this.items.get(id); } update(id: string | number, updater: (item: T) => Partial<T>): T | undefined { const item = this.items.get(id); if (!item) return undefined; const updates = updater(item); const updated = { ...item, ...updates }; this.items.set(id, updated); return updated; } delete(id: string | number): boolean { return this.items.delete(id); } findAll(): T[] { return Array.from(this.items.values()); } } // Usage with constraint interface User extends Identifiable { id: string; name: string; email: string; } const userRepo = new Repository<User>(); userRepo.save({ id: '1', name: 'John', email: 'john@example.com' }); // Type error: missing 'id' property // const invalidRepo = new Repository<{ name: string }>(); // Error!

Function Overloads

Function overloads provide multiple type signatures for different use cases:

// Query builder with overloads export interface QueryOptions { limit?: number; offset?: number; sort?: string; } export class QueryBuilder<T> { // Overload 1: Query with options object query(options: QueryOptions): Promise<T[]>; // Overload 2: Query with limit only query(limit: number): Promise<T[]>; // Overload 3: Query with limit and offset query(limit: number, offset: number): Promise<T[]>; // Implementation signature (not visible to consumers) query( limitOrOptions: number | QueryOptions, offset?: number ): Promise<T[]> { let options: QueryOptions; if (typeof limitOrOptions === 'number') { options = { limit: limitOrOptions, offset }; } else { options = limitOrOptions; } return this.executeQuery(options); } private async executeQuery(options: QueryOptions): Promise<T[]> { // Implementation return []; } } // Usage - all type-safe const builder = new QueryBuilder<User>(); await builder.query({ limit: 10, sort: 'name' }); // Overload 1 await builder.query(10); // Overload 2 await builder.query(10, 20); // Overload 3

Type Inference Optimization

// Helper type for extracting array element type type ArrayElement<T> = T extends (infer U)[] ? U : T; // Infer return type from callback export function createProcessor<T, R>( processor: (item: T) => R ) { return { process: (items: T[]): R[] => { return items.map(processor); }, processOne: (item: T): R => { return processor(item); } }; } // Usage with automatic inference const numberProcessor = createProcessor((n: number) => n * 2); // Type inferred: { process: (items: number[]) => number[]; processOne: (item: number) => number } const result = numberProcessor.process([1, 2, 3]); // number[] // String processor - different types inferred const stringProcessor = createProcessor((s: string) => s.length); const lengths = stringProcessor.process(['a', 'bb', 'ccc']); // number[]

Builder Pattern with Fluent API

// Type-safe builder pattern export class HttpRequestBuilder { private url?: string; private method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET'; private headers: Record<string, string> = {}; private body?: unknown; private queryParams: Record<string, string> = {}; setUrl(url: string): this { this.url = url; return this; } setMethod(method: 'GET' | 'POST' | 'PUT' | 'DELETE'): this { this.method = method; return this; } addHeader(key: string, value: string): this { this.headers[key] = value; return this; } setBody<T>(body: T): this { this.body = body; return this; } addQueryParam(key: string, value: string): this { this.queryParams[key] = value; return this; } async execute<T = unknown>(): Promise<T> { if (!this.url) { throw new Error('URL is required'); } const queryString = new URLSearchParams(this.queryParams).toString(); const fullUrl = queryString ? `${this.url}?${queryString}` : this.url; const response = await fetch(fullUrl, { method: this.method, headers: this.headers, body: this.body ? JSON.stringify(this.body) : undefined }); return response.json(); } } // Usage const request = new HttpRequestBuilder() .setUrl('https://api.example.com/users') .setMethod('POST') .addHeader('Content-Type', 'application/json') .addHeader('Authorization', 'Bearer token') .setBody({ name: 'John', email: 'john@example.com' }) .addQueryParam('source', 'web'); const result = await request.execute<User>();
Note: The builder pattern with method chaining (returning this) provides an excellent developer experience while maintaining type safety.

Event Emitter Pattern

// Type-safe event emitter type EventMap = Record<string, unknown>; export class TypedEventEmitter<Events extends EventMap> { private listeners = new Map<keyof Events, Set<(data: unknown) => void>>(); on<K extends keyof Events>( event: K, listener: (data: Events[K]) => void ): () => void { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(listener as (data: unknown) => void); // Return unsubscribe function return () => this.off(event, listener); } off<K extends keyof Events>( event: K, listener: (data: Events[K]) => void ): void { const eventListeners = this.listeners.get(event); if (eventListeners) { eventListeners.delete(listener as (data: unknown) => void); } } emit<K extends keyof Events>(event: K, data: Events[K]): void { const eventListeners = this.listeners.get(event); if (eventListeners) { eventListeners.forEach(listener => listener(data)); } } once<K extends keyof Events>( event: K, listener: (data: Events[K]) => void ): void { const onceWrapper = (data: Events[K]) => { listener(data); this.off(event, onceWrapper); }; this.on(event, onceWrapper); } } // Usage with typed events interface AppEvents { userLogin: { userId: string; timestamp: number }; userLogout: { userId: string }; dataUpdate: { type: string; data: unknown }; } const emitter = new TypedEventEmitter<AppEvents>(); // Type-safe listeners emitter.on('userLogin', (data) => { // data is typed as { userId: string; timestamp: number } console.log(`User ${data.userId} logged in at ${data.timestamp}`); }); // Emit with type checking emitter.emit('userLogin', { userId: '123', timestamp: Date.now() }); // Type error: wrong data shape // emitter.emit('userLogin', { userId: 123 }); // Error!

Publishing Type Definitions

Configure package.json for proper type distribution:

{ "name": "my-library", "version": "1.0.0", "main": "dist/index.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.esm.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" }, "./utils": { "import": "./dist/utils/index.esm.js", "require": "./dist/utils/index.js", "types": "./dist/utils/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc && tsc --module es2015 --outDir dist/esm", "prepublishOnly": "npm run build" }, "devDependencies": { "typescript": "^5.0.0" } }

JSDoc for Enhanced Types

/** * Validates an email address * @param email - The email address to validate * @returns True if valid, false otherwise * @example * ```typescript * validateEmail('test@example.com'); // true * validateEmail('invalid'); // false * ``` */ export function validateEmail(email: string): boolean { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return regex.test(email); } /** * Generic retry function with exponential backoff * @typeParam T - The return type of the operation * @param operation - The async operation to retry * @param options - Retry configuration options * @returns Promise resolving to operation result * @throws {Error} If all retry attempts fail */ export async function retry<T>( operation: () => Promise<T>, options: { maxAttempts?: number; initialDelay?: number; maxDelay?: number; } = {} ): Promise<T> { const { maxAttempts = 3, initialDelay = 1000, maxDelay = 10000 } = options; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await operation(); } catch (error) { if (attempt === maxAttempts) throw error; const delay = Math.min(initialDelay * Math.pow(2, attempt - 1), maxDelay); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error('Unreachable'); }
Tip: JSDoc comments are preserved in generated .d.ts files, providing rich IntelliSense documentation for library consumers.

Testing Library Types

// Use type assertions to test types import { expectType } from 'tsd'; import { Collection, Repository } from './index'; // Test Collection types const collection = new Collection<number>(); expectType<Collection<number>>(collection); const mapped = collection.map(n => n.toString()); expectType<Collection<string>>(mapped); // Test Repository constraints interface User { id: string; name: string; } const repo = new Repository<User>(); expectType<Repository<User>>(repo); // This should cause a type error (User extends Identifiable) const user = repo.findById('123'); expectType<User | undefined>(user);
Exercise: Build a type-safe library:
  1. Create a generic state management library with type-safe actions
  2. Implement function overloads for flexible API
  3. Add a fluent builder pattern for configuration
  4. Create a typed event emitter for state changes
  5. Write comprehensive JSDoc documentation
  6. Configure proper package.json exports for types
  7. Test types using tsd or similar tools

Summary

In this lesson, you learned how to build type-safe libraries with TypeScript. We covered generic patterns, function overloads, type inference optimization, fluent APIs, event emitters, and proper type publishing. These techniques enable you to create libraries that provide excellent developer experience with full type safety.