he: Map<string, T> = new Map();
constructor(schema: EntitySchema<T>) {
this.schema = schema;
}
async persist(record: T): Promise<T> {
if (!this.schema.validate(record)) {
throw new Error(Validation failed for ${this.schema.tableName});
}
this.cache.set(record.id, record);
return record;
}
async retrieve(identifier: string): Promise<T | undefined> {
return this.cache.get(identifier);
}
}
**Architecture rationale:** The constraint `T extends { id: string }` guarantees that every entity managed by the repository has a stable string identifier. This prevents runtime failures when indexing or caching. The `EntitySchema` interface separates configuration from implementation, allowing the repository to remain generic while domain-specific validation lives outside the abstraction.
### Step 2: Leverage Inference Over Explicit Annotation
TypeScript's compiler can resolve type parameters from argument types. Rely on inference to reduce noise. Explicit annotations should only be used when the compiler lacks sufficient context or when you need to enforce a specific contract across module boundaries.
```typescript
function mapPayload<T, U>(
source: T,
transformer: (input: T) => U
): U {
return transformer(source);
}
// Inference resolves T = { raw: string }, U = { formatted: number }
const result = mapPayload(
{ raw: '2024-01-15' },
(input) => ({ formatted: new Date(input.raw).getTime() })
);
Why this choice: Explicit type arguments (mapPayload<{ raw: string }, { formatted: number }>(...)) increase maintenance burden without adding safety. The compiler propagates types through callback signatures automatically. Reserve explicit annotations for generic classes or when working with higher-order functions where inference chains break.
Step 3: Compose with Utility Types for Domain Contracts
Generics become exponentially more powerful when combined with TypeScript's built-in utility types. Use them to derive DTOs, API responses, and configuration objects without duplicating base definitions.
type BaseEntity = { id: string; createdAt: Date; updatedAt: Date };
type CreateUserInput = Omit<BaseEntity, 'id' | 'createdAt' | 'updatedAt'>;
type UserUpdatePayload = Partial<Pick<BaseEntity, 'email' | 'displayName'>>;
interface ApiResponse<T> {
payload: T;
meta: {
requestId: string;
timestamp: string;
pagination?: { page: number; limit: number; total: number };
};
}
type PaginatedUserList = ApiResponse<User[]>;
Architecture rationale: Omit and Pick enforce strict boundaries between creation, update, and retrieval contracts. ApiResponse wraps any payload while guaranteeing consistent metadata structure. This pattern eliminates manual type duplication and ensures that schema changes propagate automatically to dependent contracts.
Step 4: Architect for Type-Safe Event Routing
Event systems are ideal candidates for generics because they map string identifiers to strongly-typed payloads. Use mapped types and conditional inference to guarantee that handlers receive exactly the data they expect.
type EventMap = Record<string, unknown>;
class TypedEventBus<E extends EventMap> {
private listeners = new Map<keyof E, Set<(payload: E[keyof E]) => void>>();
subscribe<K extends keyof E>(
event: K,
handler: (payload: E[K]) => void
): () => void {
const bucket = this.listeners.get(event) ?? new Set();
bucket.add(handler as any);
this.listeners.set(event, bucket);
return () => bucket.delete(handler as any);
}
dispatch<K extends keyof E>(event: K, payload: E[K]): void {
this.listeners.get(event)?.forEach(handler => handler(payload));
}
}
// Usage
interface AppEvents {
'user:registered': { userId: string; email: string };
'payment:processed': { transactionId: string; amount: number };
'system:shutdown': void;
}
const bus = new TypedEventBus<AppEvents>();
bus.subscribe('user:registered', (data) => {
console.log(data.userId); // Strictly typed
});
Why this works: The E extends EventMap constraint ensures the event map contains only valid string keys and unknown payloads. keyof E restricts subscription and dispatch to declared events. Conditional typing (E[K]) guarantees payload shape matches the event key. This eliminates string-matching bugs and provides autocomplete for every event in the system.
Pitfall Guide
1. The object Constraint Trap
Explanation: Using T extends object or T extends {} allows arrays, functions, and null-like structures in older TS versions. It provides minimal type safety.
Fix: Constrain to specific shapes or use Record<string, unknown> when you need key-value structures. Prefer T extends { id: string } over T extends object.
2. Constraint Overreach
Explanation: Adding unnecessary constraints forces callers to adapt their types to your abstraction, breaking composability.
Fix: Start with unconstrained generics (<T>) and only add extends when the implementation actually requires specific properties or methods.
3. Generic Parameter Proliferation
Explanation: Declaring <T, U, V, W> across multiple functions creates cognitive overload and makes inference chains fragile.
Fix: Consolidate related types into a single configuration object or interface. Use conditional types to derive secondary parameters from primary ones.
4. Ignoring Callback Context
Explanation: Generic functions that accept callbacks often lose type information if the callback signature isn't explicitly tied to the generic parameter.
Fix: Always declare callback parameters using the generic: (item: T) => boolean instead of (item: any) => boolean.
5. Runtime Expectations
Explanation: Generics are erased at compile time. Attempting to use T in instanceof checks, typeof comparisons, or constructor calls will fail.
Fix: Pass constructor references or type discriminators as runtime arguments when you need dynamic behavior. Use type guards for runtime narrowing.
6. Missing Default Fallbacks
Explanation: Forcing explicit type arguments on every call increases boilerplate and discourages adoption.
Fix: Provide sensible defaults: interface Cache<T = Record<string, unknown>>. This allows consumers to omit the parameter when the default matches their use case.
7. Utility Type Misapplication
Explanation: Chaining too many utility types (Partial<Required<Pick<Omit<T>>>>) creates unreadable contracts and slows compiler performance.
Fix: Extract intermediate types into named aliases. Keep utility chains under three operations per type definition.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single function handling multiple unrelated types | Generic with constraint | Maintains safety without overload bloat | Low |
| Library exposing public API with flexible payloads | Generic interface + defaults | Reduces consumer boilerplate | Medium |
| Event system with known event list | Mapped generic class | Guarantees handler payload accuracy | Low |
| Runtime type checking required | Type guards + discriminators | Generics are compile-time only | Low |
| Complex DTO transformations | Utility type composition | Eliminates manual type duplication | Low |
| Performance-critical hot path | Concrete types over generics | Avoids compiler resolution overhead | High |
Configuration Template
Copy this template to establish a consistent generic repository pattern across your codebase:
// types/entity.ts
export interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
export type EntityInput<T extends BaseEntity> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;
export type EntityUpdate<T extends BaseEntity> = Partial<Pick<T, Exclude<keyof T, 'id' | 'createdAt' | 'updatedAt'>>>;
// repositories/base-repository.ts
export interface RepositoryConfig<T extends BaseEntity> {
collection: string;
indexFields: (keyof T)[];
}
export class BaseRepository<T extends BaseEntity> {
protected config: RepositoryConfig<T>;
protected storage: Map<string, T> = new Map();
constructor(config: RepositoryConfig<T>) {
this.config = config;
}
async create(input: EntityInput<T>): Promise<T> {
const entity = {
...input,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
} as T;
this.storage.set(entity.id, entity);
return entity;
}
async findById(id: string): Promise<T | undefined> {
return this.storage.get(id);
}
async update(id: string, changes: EntityUpdate<T>): Promise<T> {
const existing = await this.findById(id);
if (!existing) throw new Error(`Entity ${id} not found`);
const updated = { ...existing, ...changes, updatedAt: new Date() } as T;
this.storage.set(id, updated);
return updated;
}
async delete(id: string): Promise<boolean> {
return this.storage.delete(id);
}
}
Quick Start Guide
- Define your base entity shape with required identifiers and timestamps. This establishes the constraint boundary for all downstream generics.
- Create a repository or service class with
<T extends BaseEntity>. Implement core CRUD operations using the constrained type.
- Derive input and update contracts using
Omit and Partial<Pick>. This separates creation payloads from full entity shapes.
- Consume the abstraction by instantiating with concrete domain types. Verify that TypeScript infers correct return types and rejects invalid property access.
- Run type checking with
tsc --noEmit to confirm that all generic chains resolve correctly before deploying to production.