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:
- Create a generic state management library with type-safe actions
- Implement function overloads for flexible API
- Add a fluent builder pattern for configuration
- Create a typed event emitter for state changes
- Write comprehensive JSDoc documentation
- Configure proper package.json exports for types
- 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.