achieve robust JSON handling, implement a structured approach that separates sanitization, type hydration, and performance optimization. The following patterns provide production-ready solutions.
1. The Sanitization Pipeline
Use a replacer function to filter sensitive data and handle circular references safely. This pattern is critical for logging and API responses.
interface SanitizationConfig {
sensitiveKeys: string[];
maxDepth?: number;
}
function createSafeStringifier(config: SanitizationConfig) {
const seen = new WeakSet<object>();
let depth = 0;
return function replacer(key: string, value: unknown): unknown {
// Handle depth limit to prevent stack overflow
if (depth > (config.maxDepth ?? 10)) {
return '[MaxDepthExceeded]';
}
// Filter sensitive keys
if (config.sensitiveKeys.includes(key)) {
return '[REDACTED]';
}
// Handle circular references
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
depth++;
}
return value;
};
}
// Usage
const payload = {
id: 'usr_123',
credentials: { token: 'abc', password: 'secret' },
metadata: null
};
const safeReplacer = createSafeStringifier({
sensitiveKeys: ['password', 'token']
});
const output = JSON.stringify(payload, safeReplacer, 2);
// Result: password and token are redacted; circular refs are safe.
Rationale: WeakSet is used instead of Set to avoid memory leaks when tracking objects. The depth counter prevents stack overflows in recursive structures. Sensitive key filtering ensures PII compliance without manual object manipulation.
2. Type Hydration with Revivers
Implement a reviver to restore type information during parsing. This centralizes type conversion logic and ensures consistency.
type ReviverMap = Record<string, (value: string) => unknown>;
function createTypeReviver(mappings: ReviverMap) {
return function reviver(key: string, value: unknown): unknown {
if (typeof value !== 'string') return value;
// Check for type markers (e.g., "__type:Date")
const typeMarker = mappings[value];
if (typeMarker) {
return typeMarker(value);
}
// Auto-detect ISO dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
return new Date(value);
}
return value;
};
}
// Usage
const jsonInput = '{"created":"2024-05-20T10:00:00Z","id":"usr_456"}';
const reviver = createTypeReviver({});
const parsed = JSON.parse(jsonInput, reviver);
// parsed.created is now a Date object.
Rationale: The reviver executes bottom-up, meaning child values are processed before parents. This allows nested structures to be fully reconstructed before parent objects are finalized. Auto-detection of ISO strings reduces boilerplate for common date formats.
3. Domain-Driven Serialization via toJSON
Classes should define their own serialization behavior using toJSON(). This method is invoked by JSON.stringify() before the replacer, giving the object control over its representation.
class MonetaryAmount {
constructor(
private readonly cents: number,
private readonly currency: string
) {}
toJSON() {
return {
__type: 'MonetaryAmount',
value: this.cents / 100,
currency: this.currency
};
}
static fromJSON(json: { value: number; currency: string }) {
return new MonetaryAmount(Math.round(json.value * 100), json.currency);
}
}
// Usage
const price = new MonetaryAmount(1999, 'USD');
const serialized = JSON.stringify({ item: 'Widget', price });
// Serialized: {"item":"Widget","price":{"__type":"MonetaryAmount","value":19.99,"currency":"USD"}}
Rationale: Returning a structured object with a __type marker enables the reviver to reconstruct the class instance accurately. This pattern avoids the ambiguity of serializing complex objects as primitive strings and maintains data integrity across serialization boundaries.
4. Asynchronous Parsing for Large Payloads
Offload JSON parsing to a Web Worker to prevent main thread blocking. This is essential for applications handling large datasets or real-time streams.
// worker.ts
self.onmessage = (event) => {
const { payload } = event.data;
try {
const result = JSON.parse(payload);
self.postMessage({ type: 'success', data: result });
} catch (error) {
self.postMessage({ type: 'error', message: error.message });
}
};
// main.ts
const worker = new Worker('worker.js');
worker.onmessage = (event) => {
if (event.data.type === 'success') {
const parsedData = event.data.data;
// Process data
}
};
// Send large JSON string
worker.postMessage({ payload: largeJsonString });
Rationale: Web Workers run in separate threads, ensuring the UI remains responsive during heavy parsing operations. This approach scales linearly with payload size and is the standard solution for performance-critical applications.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Silent Value Dropping | undefined, functions, and Symbols are omitted from objects. In arrays, they become null. This causes data loss without errors. | Use a replacer to convert undefined to null or a marker string if preservation is required. Validate payloads after serialization. |
| NaN/Infinity Coercion | NaN and Infinity are converted to null. Mathematical calculations resulting in these values will silently corrupt data. | Check for Number.isNaN() or !Number.isFinite() before serialization. Use a replacer to map these to error codes or strings. |
| Circular Reference Crash | Objects referencing themselves throw a TypeError. Common in DOM nodes, graph structures, and state trees. | Implement a WeakSet guard in the replacer to detect and replace circular references with placeholders. |
| Reviver Traversal Order | The reviver processes values bottom-up. Attempting to access parent state during reviver execution can lead to incomplete data. | Design revivers to be stateless or rely only on the current value. Ensure child transformations are complete before parent logic runs. |
| BigInt Serialization Error | JSON.stringify() throws a TypeError when encountering BigInt. This breaks APIs handling large integers. | Add a replacer to convert BigInt to strings or numbers. Use a reviver to restore BigInt types if needed. |
| Main Thread Jank | Synchronous parsing blocks the event loop for large payloads, causing UI freezes and latency. | Offload parsing to Web Workers or use streaming parsers for Node.js. Limit payload sizes where possible. |
toJSON Precedence | toJSON() is called before the replacer. If both are used, the replacer receives the output of toJSON(), not the original object. | Ensure toJSON() returns a serializable structure. Test interactions between custom toJSON methods and global replacers. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Deep Cloning | structuredClone() | Native, handles circular refs, preserves types. | Low; built-in API. |
| API Payload Sanitization | JSON.stringify() with Replacer | Filters sensitive keys, controls output format. | Low; minimal overhead. |
| Logging Complex Objects | Safe Stringifier with WeakSet | Prevents crashes, redacts secrets, handles cycles. | Low; reusable utility. |
| Large File Parsing | Web Worker / Streaming Parser | Prevents main thread blocking, scales with size. | Medium; requires worker setup. |
| Type Preservation | toJSON() + Reviver | Maintains domain types across serialization. | Low; centralized logic. |
| BigInt Handling | Custom Replacer | Prevents TypeError, ensures compatibility. | Low; simple conversion. |
Configuration Template
// safe-json.ts
export const SafeJSON = {
stringify(
value: unknown,
options?: {
replacer?: (key: string, value: unknown) => unknown;
space?: number | string;
sensitiveKeys?: string[];
maxDepth?: number;
}
): string {
const seen = new WeakSet<object>();
let depth = 0;
const baseReplacer = (key: string, val: unknown): unknown => {
if (depth > (options?.maxDepth ?? 10)) return '[MaxDepth]';
if (options?.sensitiveKeys?.includes(key)) return '[REDACTED]';
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]';
seen.add(val);
depth++;
}
return val;
};
const combinedReplacer = options?.replacer
? (key: string, val: unknown) => baseReplacer(key, options.replacer!(key, val))
: baseReplacer;
return JSON.stringify(value, combinedReplacer, options?.space);
},
parse(
text: string,
reviver?: (key: string, value: unknown) => unknown
): unknown {
return JSON.parse(text, reviver);
}
};
Quick Start Guide
- Import the Utility: Replace direct
JSON.stringify() calls with SafeJSON.stringify() in your codebase.
- Configure Sensitive Keys: Pass
sensitiveKeys: ['password', 'token'] to automatically redact sensitive data.
- Add Type Revivers: Use
SafeJSON.parse() with a reviver to restore Date objects and domain types.
- Test Edge Cases: Verify behavior with circular references,
NaN, and large payloads to ensure stability.
- Monitor Performance: Use Web Workers for parsing operations on payloads exceeding 1MB to maintain UI responsiveness.