Primitive Conversion via ToPrimitive
Before any addition or concatenation occurs, the engine attempts to convert both operands into primitive values using the ToPrimitive abstract operation. This step is critical because it determines whether the expression will proceed to the string path or the numeric path.
ToPrimitive follows a strict hierarchy:
- Symbol.toPrimitive Check: If the operand has a
Symbol.toPrimitive method, the engine invokes it with a hint.
- OrdinaryToPrimitive Fallback: If no
Symbol.toPrimitive exists, the engine calls OrdinaryToPrimitive, which attempts valueOf and toString methods.
The hint parameter dictates the order of method invocation:
"string" hint: Calls toString first, then valueOf.
"number" hint: Calls valueOf first, then toString.
undefined hint: Defaults to "number" for most objects, but "string" for Date objects.
Implementation Example:
Consider a scenario where we combine a user identifier with a session object.
interface SessionData {
id: string;
expires: number;
}
const userId = 1042;
const session: SessionData = { id: "sess-99", expires: 3600 };
// Evaluation trace:
// 1. ToPrimitive(1042) -> 1042 (primitive)
// 2. ToPrimitive(session) -> session has no Symbol.toPrimitive.
// OrdinaryToPrimitive(session, "number") called.
// session.valueOf() returns the object itself (not primitive).
// session.toString() returns "[object Object]" (primitive).
// 3. Result: 1042 and "[object Object]".
const logEntry = userId + session;
// logEntry is "1042[object Object]"
Date Objects are Unique:
Date instances implement Symbol.toPrimitive. When invoked without a hint (as happens in additive expressions), the internal logic forces a "string" hint. This causes Date.prototype.toString to execute first, returning a human-readable date string. Consequently, any additive expression involving a Date will almost always result in string concatenation.
const priority = true;
const timestamp = new Date("2026-05-11T12:00:00Z");
// Evaluation trace:
// 1. ToPrimitive(true) -> true (primitive).
// 2. ToPrimitive(timestamp) -> Date has Symbol.toPrimitive.
// Hint is undefined, Date forces "string".
// timestamp.toString() returns "Mon May 11 2026...".
// 3. Result: true and "Mon May 11 2026...".
const auditLog = priority + timestamp;
// auditLog is "trueMon May 11 2026 12:00:00 GMT+0000..."
Phase 2: Type Routing
Once both operands are primitives, the engine evaluates the types to determine the operation.
The String Gate:
If either primitive is a string, the engine performs string concatenation. This is the most common path for mixed-type expressions. The non-string operand is converted to a string via ToString.
const configKey = "max_retries";
const configValue = 5;
// configValue is number, not string.
// However, if we had:
const configValueStr = "5";
const result = configKey + configValueStr;
// "max_retries5"
The Numeric Path and Twin Gate:
If neither operand is a string, the engine proceeds to numeric evaluation. It calls ToNumeric on both operands. ToNumeric preserves BigInt values but converts others to Number.
Crucially, the spec enforces a Twin Gate: both operands must be of the same numeric type. Mixing Number and BigInt throws a TypeError.
const packetCount = 100;
const totalBytes = 5000000000n;
// ToNumeric(100) -> 100 (Number).
// ToNumeric(5000000000n) -> 5000000000n (BigInt).
// SameType check fails.
const metric = packetCount + totalBytes;
// Throws TypeError: Cannot mix BigInt and other types.
ToNumeric Conversions:
When operands pass the String Gate and are not BigInt, they are converted via ToNumber. Key behaviors include:
null converts to 0.
undefined converts to NaN.
true converts to 1, false to 0.
- Strings are parsed; invalid strings become
NaN.
const attempts = null;
const delay = undefined;
const totalAttempts = attempts + 1; // 0 + 1 = 1
const totalDelay = delay + 100; // NaN + 100 = NaN
Pitfall Guide
Production codebases frequently encounter these pitfalls. Understanding them prevents subtle bugs and improves code reliability.
-
The Date Concatenation Trap
- Explanation: Developers assume
Date objects might coerce to timestamps (numbers) in math operations. In additive expressions, Date always coerces to a string due to its Symbol.toPrimitive implementation.
- Fix: Explicitly call
.getTime() or Number(date) before addition if a numeric timestamp is required.
-
BigInt Type Mismatch
- Explanation:
BigInt and Number are distinct types. Adding them throws a runtime error. This often happens when migrating legacy code to use BigInt for large integers.
- Fix: Ensure type consistency. Convert
Number to BigInt using BigInt(num) or vice versa, acknowledging precision loss.
-
Array vs. Object Coercion
- Explanation: Arrays and objects both coerce to strings, but the string representation differs. Arrays join elements with commas; objects return
"[object Object]". Developers may expect arrays to behave like numbers or merge.
- Fix: Avoid using
+ with arrays. Use .join() or spread syntax for concatenation.
-
Primitive Wrapper Inconsistency
- Explanation:
new Number(5) behaves differently from the literal 5. The wrapper object unwraps via valueOf to a primitive number, allowing it to pass the String Gate and participate in numeric addition. This can mask type errors.
- Fix: Never use primitive wrapper objects (
new Number, new String). Use literals and explicit conversion functions.
-
Null and Undefined Arithmetic
- Explanation:
null coerces to 0, which can silently validate missing data. undefined coerces to NaN, which propagates through calculations. Relying on these coercions makes code intent unclear.
- Fix: Validate inputs explicitly. Use default parameters or nullish coalescing (
??) to handle missing values before arithmetic.
-
Symbol Coercion Errors
- Explanation:
Symbol values cannot be coerced to strings or numbers in additive expressions. Attempting sym + "" or sym + 1 throws a TypeError.
- Fix: Do not use
+ with symbols. Access Symbol.description or convert explicitly if string representation is needed.
-
Implicit Coercion in Logging
- Explanation: Using
+ to construct log messages can hide type mismatches. log("Error: " + errorCode) works, but log("Error: " + errorObj) produces unreadable output.
- Fix: Use template literals with explicit formatting or structured logging libraries that handle object serialization.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| String Construction | Template literals `${a}${b}` | Readable, handles types safely, no coercion surprises. | Low |
| Numeric Math | Explicit Number() or BigInt() | Prevents type errors, makes intent clear, avoids NaN traps. | Low |
| Date Arithmetic | .getTime() conversion | Ensures numeric operation, avoids string concatenation. | Low |
| Mixed Type Logging | Structured logger or JSON.stringify | Preserves object structure, avoids [object Object] output. | Medium |
| Performance Critical Loop | Pre-validated primitives | Avoids runtime coercion overhead and type checks. | High |
Configuration Template
Use this ESLint configuration to enforce safe additive practices in your project.
{
"rules": {
"no-implicit-coercion": ["error", {
"boolean": false,
"number": true,
"string": true,
"disallowTemplateShorthand": true,
"allow": []
}],
"no-unsafe-negation": "error",
"eqeqeq": ["error", "always"]
}
}
For TypeScript projects, ensure strict mode is enabled in tsconfig.json to leverage compile-time checks for coercion and type mismatches.
Quick Start Guide
- Install Dependencies: Run
npm install eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev.
- Configure ESLint: Create
.eslintrc.json with the configuration template above.
- Run Analysis: Execute
npx eslint . to identify implicit coercion violations.
- Refactor: Update flagged expressions to use explicit conversions or template literals.
- Verify: Run tests to ensure behavior remains correct after refactoring. Monitor for
TypeError exceptions related to BigInt or Symbol.