U;
interface ContextualMapper<T> {
readonly context: 'sync' | 'async' | 'validated';
map<U>(transform: TransformFn<T, U>): ContextualMapper<U>;
extract(): T | Promise<T> | { success: boolean; data: T | null };
}
**Rationale:** The `context` property enables runtime strategy selection. The generic `map<U>` signature ensures type safety across transformations. `extract()` provides a controlled exit point, preventing accidental context leakage.
### Step 2: Implement Sync and Async Adapters
We create concrete implementations that adhere to the contract while respecting their execution model.
```typescript
class SyncBatch<T> implements ContextualMapper<T> {
readonly context = 'sync' as const;
constructor(private readonly data: T[]) {}
map<U>(transform: TransformFn<T, U>): SyncBatch<U> {
return new SyncBatch(this.data.map(transform));
}
extract(): T[] {
return [...this.data];
}
}
class AsyncQueue<T> implements ContextualMapper<T> {
readonly context = 'async' as const;
constructor(private readonly promise: Promise<T>) {}
map<U>(transform: TransformFn<T, U>): AsyncQueue<U> {
return new AsyncQueue(this.promise.then(transform));
}
extract(): Promise<T> {
return this.promise;
}
}
Why this architecture: Separating sync and async into distinct classes prevents race conditions and type confusion. The AsyncQueue wraps Promise.then() internally, exposing a uniform map() API. This allows pipelines to switch contexts explicitly rather than implicitly.
Step 3: Build a Validation Wrapper
Real-world data requires error handling. We implement a context that preserves success/failure state during transformation.
class ValidationShell<T> implements ContextualMapper<T> {
readonly context = 'validated' as const;
constructor(
private readonly value: T,
private readonly isValid: boolean
) {}
static of<T>(value: T): ValidationShell<T> {
return new ValidationShell(value, true);
}
static fail<T>(): ValidationShell<T> {
return new ValidationShell(null as unknown as T, false);
}
map<U>(transform: TransformFn<T, U>): ValidationShell<U> {
if (!this.isValid) return ValidationShell.fail<U>();
try {
return ValidationShell.of(transform(this.value));
} catch {
return ValidationShell.fail<U>();
}
}
extract(): { success: boolean; data: T | null } {
return { success: this.isValid, data: this.isValid ? this.value : null };
}
}
Architectural decision: The validation wrapper short-circuits on failure. This prevents cascading errors and eliminates the need for try/catch blocks inside transformation functions. It mirrors the Either/Result pattern used in functional ecosystems but remains accessible to imperative developers.
Step 4: Compose Pipelines
With the contract in place, we can chain transformations across contexts without losing type safety or execution semantics.
interface RawPayload {
rawId: string;
rawName: string;
rawScore: number;
}
interface CleanRecord {
id: number;
displayName: string;
normalizedScore: number;
}
const pipeline = (payload: RawPayload): ValidationShell<CleanRecord> => {
return ValidationShell.of(payload)
.map(p => ({
id: parseInt(p.rawId, 10),
displayName: p.rawName.trim().toUpperCase(),
normalizedScore: Math.min(100, Math.max(0, p.rawScore))
}))
.map(record => {
if (isNaN(record.id)) throw new Error('Invalid ID');
return record;
});
};
Why this works: Each .map() call returns a new ValidationShell, preserving the validation context. If parseInt fails or rawScore is out of bounds, the pipeline gracefully degrades to a failed state without throwing unhandled exceptions. The transformation logic remains pure and testable.
Pitfall Guide
1. The Allocation Avalanche
Explanation: Chaining multiple .map() calls on large arrays creates intermediate collections. Each step allocates new memory, triggering garbage collection spikes.
Fix: Use lazy evaluation or batch processing. For datasets >10k items, switch to for...of with pre-allocated arrays, or implement a lazy iterator that defers computation until extract() is called.
2. Silent Context Loss
Explanation: Mixing synchronous transformations with asynchronous sources without explicit wrappers causes type mismatches and race conditions.
Fix: Always wrap async sources in an explicit context class (AsyncQueue). Never pass a Promise directly into a sync pipeline. Use await at pipeline boundaries, not inside transformation functions.
3. The Side-Effect Trap
Explanation: Embedding I/O, state mutations, or network calls inside transformation functions breaks purity, making pipelines untestable and unpredictable.
Fix: Restrict transformation functions to pure computations. Use dedicated tap() or observe() operators for side effects. Log, cache, or emit events outside the core mapping chain.
4. Type Erosion in Chains
Explanation: Long transformation chains often lose generic type parameters, forcing developers to use any or manual type assertions.
Fix: Leverage TypeScript's satisfies keyword and explicit generic parameters. Define intermediate interfaces for complex transformations. Use ContextualMapper<IntermediateType> to maintain inference across steps.
5. Over-Abstracting Simple Mappings
Explanation: Wrapping primitive data in heavy context classes for trivial operations adds unnecessary boilerplate and runtime overhead.
Fix: Apply the unified pattern only when context preservation, error handling, or cross-domain reuse is required. For simple DTO mapping, native .map() or object destructuring remains optimal.
6. Error Propagation Blindness
Explanation: Different containers handle failures differently. Promises reject, arrays ignore, and validation wrappers short-circuit. Mixing them causes silent data loss.
Fix: Standardize on a single error-handling strategy. Use ValidationShell or Result types for all pipelines that touch external data. Never assume a transformation will succeed; always handle the failure path explicitly.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency UI updates (<1k items) | Native .map() with React keys | Minimal overhead, framework-native, optimal rendering | Negligible |
| Batch ETL jobs (>100k records) | Lazy iterator or for...of with pre-allocation | Prevents intermediate allocations, reduces GC pressure | Low (dev time), High (savings) |
| Real-time event streams | AsyncQueue + reactive operators | Maintains backpressure, preserves async context | Medium (infrastructure) |
| External API ingestion | ValidationShell pipeline | Guarantees error isolation, prevents cascade failures | Low (dev time), High (stability) |
| Simple DTO projection | Object destructuring or native .map() | No abstraction overhead, direct mapping | Negligible |
Configuration Template
Copy this module into your project to enable type-safe, context-preserving transformations across sync, async, and validated pipelines.
// pipeline-context.ts
export type TransformFn<T, U> = (value: T) => U;
export interface ContextualMapper<T> {
readonly context: 'sync' | 'async' | 'validated';
map<U>(transform: TransformFn<T, U>): ContextualMapper<U>;
extract(): T | Promise<T> | { success: boolean; data: T | null };
}
export class SyncBatch<T> implements ContextualMapper<T> {
readonly context = 'sync' as const;
constructor(private readonly data: T[]) {}
map<U>(transform: TransformFn<T, U>): SyncBatch<U> {
return new SyncBatch(this.data.map(transform));
}
extract(): T[] { return [...this.data]; }
}
export class AsyncQueue<T> implements ContextualMapper<T> {
readonly context = 'async' as const;
constructor(private readonly promise: Promise<T>) {}
map<U>(transform: TransformFn<T, U>): AsyncQueue<U> {
return new AsyncQueue(this.promise.then(transform));
}
extract(): Promise<T> { return this.promise; }
}
export class ValidationShell<T> implements ContextualMapper<T> {
readonly context = 'validated' as const;
constructor(private readonly value: T, private readonly isValid: boolean) {}
static of<T>(value: T): ValidationShell<T> { return new ValidationShell(value, true); }
static fail<T>(): ValidationShell<T> { return new ValidationShell(null as unknown as T, false); }
map<U>(transform: TransformFn<T, U>): ValidationShell<U> {
if (!this.isValid) return ValidationShell.fail<U>();
try { return ValidationShell.of(transform(this.value)); }
catch { return ValidationShell.fail<U>(); }
}
extract(): { success: boolean; data: T | null } {
return { success: this.isValid, data: this.isValid ? this.value : null };
}
}
Quick Start Guide
- Install & Import: Place
pipeline-context.ts in your src/utils/ directory. Import ValidationShell, AsyncQueue, and SyncBatch into your data layer.
- Wrap External Data: Replace raw API responses or file reads with
ValidationShell.of(rawData) to establish an error-safe boundary.
- Define Pure Transformers: Create standalone functions that accept a single input and return a transformed output. Avoid side effects.
- Chain & Extract: Apply
.map() calls to build your pipeline. Call .extract() at the final step to retrieve data or handle failure states explicitly.
- Profile & Optimize: Run memory profiling on production-like datasets. If allocation spikes occur, swap
SyncBatch for a lazy iterator or batch processor before deployment.