d-hoc console calls to a structured serialization strategy. The implementation involves three distinct layers: safe stringification, type-safe field extraction, and environment-aware routing.
Step 1: Implement Circular-Reference-Safe Serialization
The native JSON.stringify() method throws a TypeError when it encounters circular references. To prevent this, we inject a replacer function that tracks visited objects using a WeakSet. WeakSet is chosen over Set because it holds weak references, allowing garbage collection of logged objects without memory leaks.
type SerializableValue = unknown;
function createSafeStringifier(): (value: SerializableValue) => string {
const visited = new WeakSet<object>();
return (value: SerializableValue): string => {
return JSON.stringify(value, (key, current) => {
if (typeof current === 'object' && current !== null) {
if (visited.has(current)) {
return '[CircularReference]';
}
visited.add(current);
}
return current;
});
};
}
Architecture Rationale:
- Encapsulating the
WeakSet inside a closure ensures thread-safe (in single-threaded JS context) isolation per logger instance.
- Returning a function allows dependency injection into logging utilities without polluting global scope.
- The replacer intercepts every key-value pair during traversal, enabling graceful degradation instead of hard crashes.
Full serialization is unnecessary when only specific metrics matter. Extracting known fields avoids serialization overhead and prevents accidental leakage of sensitive nested data. TypeScript's type narrowing ensures compile-time safety.
interface TelemetryPayload {
eventId: string;
timestamp: number;
metadata: Record<string, unknown>;
}
function extractTelemetryFields(payload: unknown): string {
if (typeof payload !== 'object' || payload === null) {
return '[InvalidPayload]';
}
const typed = payload as Partial<TelemetryPayload>;
const fields = [
`eventId=${typed.eventId ?? 'MISSING'}`,
`timestamp=${typed.timestamp ?? 'MISSING'}`,
`hasMetadata=${typeof typed.metadata === 'object'}`
];
return fields.join(' | ');
}
Architecture Rationale:
- Using
Partial<T> prevents runtime errors when payloads are incomplete.
- Explicit field mapping creates a contract between data producers and consumers.
- String interpolation is limited to primitive values, guaranteeing predictable log output.
Step 3: Route Based on Execution Context
Development and production environments have divergent logging requirements. Dev environments prioritize full payload visibility; production prioritizes performance, compliance, and structured parsing.
enum LogMode {
DEVELOPMENT = 'dev',
PRODUCTION = 'prod'
}
class PayloadLogger {
private readonly stringify: ReturnType<typeof createSafeStringifier>;
constructor(private mode: LogMode) {
this.stringify = createSafeStringifier();
}
log(payload: unknown, label: string): void {
const output = this.mode === LogMode.DEVELOPMENT
? this.stringify(payload)
: extractTelemetryFields(payload);
console.log(`[${label}] ${output}`);
}
}
Why this choice:
- Environment branching prevents accidental exposure of full payloads in production.
- Centralizing the logic in a class enables future extensions (redaction, sampling, log level filtering) without modifying call sites.
- The pattern aligns with OpenTelemetry and structured logging standards used in modern observability stacks.
Pitfall Guide
1. The Circular Reference Crash
Explanation: JSON.stringify() traverses object graphs depth-first. When it encounters a reference pointing back to an ancestor node, it throws TypeError: Converting circular structure to JSON. This crashes the logging thread and can mask the original error.
Fix: Always use a WeakSet-based replacer or a dedicated library like flatted or safe-stable-stringify in production code. Never call JSON.stringify() directly on untrusted or framework-managed objects.
2. Silent Data Loss (Functions, Symbols, Undefined)
Explanation: JSON.stringify() drops functions, Symbol values, and undefined properties. If your payload contains method references or metadata stored as symbols, they vanish without warning, leading to incomplete debugging traces.
Fix: Use a custom replacer that converts functions to "[Function]" and symbols to "[Symbol]", or switch to structured logging formats that support richer type representation (e.g., console.dir with depth options, or JSON-based log shippers).
Explanation: Serialization is CPU-intensive. Calling JSON.stringify() on large objects (10KB+) inside tight loops, event handlers, or request interceptors can block the main thread and increase latency.
Fix: Profile serialization cost using performance.now(). In hot paths, extract only necessary fields or implement sampling (e.g., log full payload every 100th request). Consider async log shipping to avoid blocking execution.
4. Async Mutation Race Conditions
Explanation: console.log(obj) in devtools often shows a live reference. If the object mutates after the log call but before you expand it in the console, you'll see the mutated state, not the state at log time.
Fix: Always serialize immediately: console.log(JSON.stringify(obj)). This captures a snapshot. Never rely on interactive console expansion for temporal debugging.
5. Over-Logging Sensitive Payloads
Explanation: Full serialization dumps all properties, including passwords, tokens, PII, or internal flags. In production, this violates GDPR/CCPA and creates compliance liabilities.
Fix: Implement a redaction layer before serialization. Use allowlists instead of blocklists. Example: JSON.stringify(obj, ['allowed_field_1', 'allowed_field_2']).
6. Ignoring Type Narrowing in TypeScript
Explanation: Logging unknown or any payloads without type guards leads to runtime property access errors or meaningless logs. TypeScript's static analysis cannot protect against malformed runtime data.
Fix: Always validate shape before extraction. Use runtime type guards (e.g., zod or custom isTelemetryPayload() functions) to bridge compile-time safety and runtime reality.
7. console.dir vs console.log Misconceptions
Explanation: Developers assume console.dir(obj) solves the [object Object] problem. While console.dir forces object inspection in devtools, it still outputs [object Object] when piped to stdout or log aggregators.
Fix: Treat console.dir as a devtool-only utility. For programmatic logging, always serialize explicitly. Never rely on environment-specific console behavior for production telemetry.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Local debugging / hot reloading | Full JSON.stringify() with safe replacer | Maximum visibility accelerates iteration | Low (dev environment only) |
| Production API response logging | Targeted field extraction | Minimizes payload size, ensures compliance, reduces CPU | Low (deterministic performance) |
| Framework state / DOM tree inspection | Safe serialization with depth limit | Prevents circular crashes, captures structure | Medium (requires depth configuration) |
| High-throughput event streaming | Field extraction + sampling | Avoids main thread blocking, reduces log volume | Low (architectural trade-off) |
| Compliance-heavy environments (HIPAA/GDPR) | Allowlist serialization + redaction | Guarantees no PII leakage, audit-ready | Medium (requires schema maintenance) |
Configuration Template
// logger.config.ts
import { createSafeStringifier } from './safe-stringify';
export const LoggerConfig = {
mode: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
maxDepth: 5,
redactKeys: ['password', 'token', 'secret', 'authorization', 'ssn'],
allowedProdFields: ['eventId', 'timestamp', 'status', 'durationMs'],
stringify: createSafeStringifier(),
format(level: string, label: string, data: unknown): string {
const timestamp = new Date().toISOString();
const payload = this.mode === 'prod'
? this.extractSafeFields(data)
: this.stringify(data);
return JSON.stringify({
ts: timestamp,
lvl: level,
ctx: label,
msg: payload
});
},
extractSafeFields(data: unknown): string {
if (typeof data !== 'object' || data === null) return '[Invalid]';
const safe: Record<string, unknown> = {};
for (const key of this.allowedProdFields) {
if (key in data) safe[key] = (data as Record<string, unknown>)[key];
}
return JSON.stringify(safe);
}
};
Quick Start Guide
- Replace implicit coercion: Search your codebase for
`${...}` patterns containing objects. Replace them with LoggerConfig.stringify(obj) or explicit field access.
- Integrate the safe stringifier: Copy the
createSafeStringifier utility into your shared utilities directory. Import it into your logging module.
- Configure environment routing: Set
NODE_ENV or use a configuration manager to switch between full serialization (dev) and field extraction (prod).
- Add redaction rules: Populate
redactKeys with sensitive field names relevant to your domain. Test with sample payloads to verify stripping behavior.
- Validate in staging: Deploy to a staging environment and verify that log aggregators receive valid JSON. Check for circular reference handling and performance impact under load.
By replacing ad-hoc console calls with a deterministic serialization strategy, engineering teams eliminate [object Object] noise, reduce debugging latency, and establish a foundation for production-grade observability. The pattern scales from small utilities to enterprise telemetry pipelines without sacrificing runtime safety or developer velocity.