How Object-to-Primitive Conversion Works in JavaScript
JavaScript is a dynamically typed language where values are constantly being converted from one type to another. While converting between primitive types (string to number, number to boolean) is relatively straightforward, converting objects to primitives follows a more complex set of rules that every JavaScript developer should understand.
When you use an object in a context that expects a primitive value, such as string concatenation, arithmetic operations, or comparisons, JavaScript triggers an internal conversion process. This process follows a specific algorithm with well-defined steps, fallback mechanisms, and customization hooks. Understanding this process explains why {} + [] produces surprising results, why your custom objects display [object Object] everywhere, and how you can control exactly what happens when your objects are converted.
This guide walks you through every aspect of object-to-primitive conversion: when it happens, the three conversion hints, how to customize it with Symbol.toPrimitive, the legacy toString() and valueOf() methods, and the complete fallback rules the engine follows.
When and Why Objects Are Converted to Primitives
Objects are converted to primitives whenever they appear in a context that requires a primitive value. This happens more often than most developers realize.
String Contexts
When an object needs to become a string:
let user = { name: "Alice" };
// Template literal
console.log(`User: ${user}`); // "User: [object Object]"
// String concatenation
console.log("User: " + user); // "User: [object Object]"
// Explicit conversion
console.log(String(user)); // "[object Object]"
// alert (in browsers)
// alert(user); // "[object Object]"
// Used as object property key
let obj = {};
obj[user] = "value";
console.log(Object.keys(obj)); // ["[object Object]"]
Numeric Contexts
When an object needs to become a number:
let obj = { value: 42 };
// Unary plus
console.log(+obj); // NaN
// Arithmetic operations (except binary +)
console.log(obj - 1); // NaN
console.log(obj * 2); // NaN
console.log(obj / 3); // NaN
// Explicit conversion
console.log(Number(obj)); // NaN
// Comparison operators (>, <, >=, <=)
console.log(obj > 10); // false
// Math functions
console.log(Math.max(obj, 5)); // NaN
Default Contexts
Some operators work with multiple types and do not have a clear preference for string or number. These trigger a "default" conversion:
let obj = { value: 42 };
// Binary + (could be string concatenation OR numeric addition)
console.log(obj + 1); // "[object Object]1"
// == comparison with a primitive (not === which never converts)
console.log(obj == 1); // false
Boolean Context: The Exception
There is one important context where objects are not converted using the conversion algorithm described in this article: boolean conversion. In boolean contexts, all objects are always true, regardless of their content:
let emptyObj = {};
let emptyArr = [];
let zero = new Number(0);
let emptyStr = new String("");
console.log(Boolean(emptyObj)); // true
console.log(Boolean(emptyArr)); // true
console.log(Boolean(zero)); // true (even though 0 is falsy!)
console.log(Boolean(emptyStr)); // true (even though "" is falsy!)
if ({}) {
console.log("Objects are always truthy"); // This always runs
}
Boolean conversion of objects does not involve Symbol.toPrimitive, toString(), or valueOf(). All objects are simply true. This is a fixed rule with no customization hooks. The conversion process described in the rest of this article applies only to string, number, and default conversions.
Conversion Hints: "string", "number", "default"
When JavaScript needs to convert an object to a primitive, it does not just blindly convert. It provides a hint that indicates what type of primitive is preferred. There are exactly three possible hints.
The "string" Hint
The "string" hint is used when the operation clearly expects a string:
// Operations that trigger "string" hint:
// 1. Template literals
`${obj}`
// 2. String() conversion
String(obj)
// 3. Using an object as a property key
anotherObj[obj]
// 4. alert() in browsers
alert(obj)
The "number" Hint
The "number" hint is used when the operation clearly expects a number:
// Operations that trigger "number" hint:
// 1. Unary +
+obj
// 2. Subtraction, multiplication, division, modulo, exponentiation
obj - 1
obj * 2
obj / 3
obj % 4
obj ** 2
// 3. Number() conversion
Number(obj)
// 4. Greater/less than comparisons
obj > 5
obj < 10
obj >= 0
obj <= 100
// 5. Unary minus
-obj
The "default" Hint
The "default" hint is used when the operation could work with either strings or numbers, and JavaScript does not have a clear preference:
// Operations that trigger "default" hint:
// 1. Binary + (could be concatenation or addition)
obj + 1
obj + "hello"
// 2. == comparison with a primitive
obj == 1
obj == "hello"
In practice, the "default" hint is treated the same as "number" by all built-in JavaScript objects (except Date, which treats "default" like "string"). When you implement your own conversion logic, you can handle "default" however you choose, but most developers treat it identically to "number".
Quick Reference: Which Operations Use Which Hint
| Hint | Triggered By |
|---|---|
"string" | String(), template literals, property key access, alert() |
"number" | Number(), +obj (unary), -, *, /, %, **, >, <, >=, <= |
"default" | + (binary), == (with primitive) |
Symbol.toPrimitive
The recommended and most powerful way to customize object-to-primitive conversion is by defining a Symbol.toPrimitive method on your object. When this method exists, JavaScript calls it first, passing the hint as an argument, and uses whatever it returns.
Basic Implementation
let wallet = {
currency: "EUR",
amount: 150,
[Symbol.toPrimitive](hint) {
console.log(`Converting with hint: "${hint}"`);
if (hint === "string") {
return `${this.amount} ${this.currency}`;
}
if (hint === "number") {
return this.amount;
}
// hint === "default"
return this.amount;
}
};
// String hint
console.log(`Balance: ${wallet}`);
// Converting with hint: "string"
// "Balance: 150 EUR"
// Number hint
console.log(+wallet);
// Converting with hint: "number"
// 150
// Default hint
console.log(wallet + 50);
// Converting with hint: "default"
// 200
Output:
Converting with hint: "string"
Balance: 150 EUR
Converting with hint: "number"
150
Converting with hint: "default"
200
The Symbol.toPrimitive method receives exactly one argument (the hint string) and must return a primitive value. If it returns an object, JavaScript throws a TypeError.
A Complete Practical Example
let temperature = {
celsius: 100,
label: "Boiling point of water",
[Symbol.toPrimitive](hint) {
switch (hint) {
case "string":
return `${this.label}: ${this.celsius}°C`;
case "number":
return this.celsius;
case "default":
return this.celsius;
}
}
};
// String contexts
console.log(`${temperature}`); // "Boiling point of water: 100°C"
console.log(String(temperature)); // "Boiling point of water: 100°C"
// Numeric contexts
console.log(+temperature); // 100
console.log(temperature - 32); // 68
console.log(temperature > 50); // true
console.log(Math.round(temperature / 3)); // 33
// Default contexts
console.log(temperature + 10); // 110
console.log(temperature == 100); // true
Rules for Symbol.toPrimitive
The method must return a primitive. Returning an object causes an error:
let broken = {
[Symbol.toPrimitive](hint) {
return { value: 42 }; // ❌ Returning an object
}
};
// TypeError: Cannot convert object to primitive value
// console.log(+broken);
// ✅ CORRECT: Always return a primitive
let working = {
[Symbol.toPrimitive](hint) {
return 42; // number is a primitive
}
};
console.log(+working); // 42
If you need to customize how your object converts to primitives, use Symbol.toPrimitive. It is the most modern, most flexible, and most explicit approach. It handles all three hints in a single method, making the conversion logic clear and centralized.
toString() and valueOf() Methods
Before Symbol.toPrimitive existed (pre-ES6), JavaScript used two methods to handle object-to-primitive conversion: toString() and valueOf(). These methods still work today and serve as the fallback mechanism when Symbol.toPrimitive is not defined.
toString()
Every object in JavaScript inherits a toString() method from Object.prototype. The default implementation returns the string "[object Object]":
let user = { name: "Alice" };
console.log(user.toString()); // "[object Object]"
You can override toString() to return a more meaningful string:
let user = {
name: "Alice",
age: 30,
toString() {
return `${this.name} (age ${this.age})`;
}
};
console.log(String(user)); // "Alice (age 30)"
console.log(`User: ${user}`); // "User: Alice (age 30)"
valueOf()
Every object also inherits a valueOf() method. The default implementation simply returns the object itself (not a primitive), which means it effectively does nothing useful for conversion:
let user = { name: "Alice" };
console.log(user.valueOf()); // { name: "Alice" } (returns the object)
console.log(user.valueOf() === user); // true (same object)
You can override valueOf() to return a meaningful primitive value:
let wallet = {
amount: 150,
valueOf() {
return this.amount;
}
};
console.log(+wallet); // 150
console.log(wallet - 50); // 100
console.log(wallet * 2); // 300
How toString() and valueOf() Work Together
When Symbol.toPrimitive is not defined, JavaScript uses toString() and valueOf() as fallbacks, but the order in which it tries them depends on the hint:
For "string" hint:
- Call
toString(). If it returns a primitive, use it. - If
toString()does not return a primitive, callvalueOf(). If it returns a primitive, use it. - If neither returns a primitive, throw a
TypeError.
For "number" or "default" hint:
- Call
valueOf(). If it returns a primitive, use it. - If
valueOf()does not return a primitive, calltoString(). If it returns a primitive, use it. - If neither returns a primitive, throw a
TypeError.
let product = {
name: "Laptop",
price: 999,
toString() {
console.log("toString called");
return this.name;
},
valueOf() {
console.log("valueOf called");
return this.price;
}
};
// String hint: toString() is tried first
console.log(String(product));
// "toString called"
// "Laptop"
// Number hint: valueOf() is tried first
console.log(+product);
// "valueOf called"
// 999
// Default hint: valueOf() is tried first (same as number)
console.log(product + 1);
// "valueOf called"
// 1000
Output:
toString called
Laptop
valueOf called
999
valueOf called
1000
Only toString() Defined
When only toString() is defined and valueOf() returns the default (the object itself), toString() handles all conversions because the default valueOf() returns a non-primitive, causing the fallback to toString():
let user = {
name: "Alice",
toString() {
return this.name;
}
// valueOf() not overridden: returns the object itself (not a primitive)
};
// String hint: toString() called first. Returns "Alice" (primitive) ✓
console.log(`${user}`); // "Alice"
// Number hint: valueOf() called first. Returns the object (not a primitive)
// falls back to toString(). Returns "Alice" (primitive)
// "Alice" is then converted to NaN by the numeric context
console.log(+user); // NaN (because Number("Alice") is NaN)
// Default hint: same as number. valueOf() fails, toString() used
console.log(user + " rocks"); // "Alice rocks"
Only valueOf() Defined
When only valueOf() is overridden:
let counter = {
count: 42,
valueOf() {
return this.count;
}
// toString() not overridden, returns "[object Object]"
};
// Number hint: valueOf() called first, returns 42 (primitive) ✓
console.log(+counter); // 42
// String hint: toString() called first, returns "[object Object]" (primitive) ✓
// valueOf() is never reached!
console.log(String(counter)); // "[object Object]" (NOT "42"!)
// Default hint: valueOf() called first (returns 42 (primitive) ✓)
console.log(counter + 8); // 50
This can be surprising. Even though valueOf() returns the number 42, String(counter) still produces "[object Object]" because in the "string" hint path, toString() is called first, and the inherited Object.prototype.toString() returns "[object Object]", which is already a primitive, so valueOf() is never consulted.
If you override only one of toString() or valueOf(), the behavior can be unintuitive because of the fallback order. If you need full control, either override both methods or use Symbol.toPrimitive, which handles all hints in one place.
Conversion Rules and Fallback Behavior
Let's formalize the complete algorithm that JavaScript uses for object-to-primitive conversion.
The Complete Algorithm
When JavaScript needs to convert an object obj to a primitive with hint H:
Step 1: If obj[Symbol.toPrimitive] exists, call it with hint H.
- If the result is a primitive, return it.
- If the result is an object, throw
TypeError.
Step 2: If Symbol.toPrimitive does not exist, use the legacy fallback:
-
If hint is
"string":- Call
obj.toString(). If result is a primitive, return it. - Call
obj.valueOf(). If result is a primitive, return it. - Throw
TypeError.
- Call
-
If hint is
"number"or"default":- Call
obj.valueOf(). If result is a primitive, return it. - Call
obj.toString(). If result is a primitive, return it. - Throw
TypeError.
- Call
Visualizing the Fallback
Object-to-Primitive Conversion
│
├── Has Symbol.toPrimitive?
│ ├── YES → Call it with hint → Returns primitive? → Done!
│ │ → Returns object? → TypeError!
│ │
│ └── NO → Use legacy fallback:
│ │
│ ├── Hint is "string"?
│ │ ├── toString() → Returns primitive? → Done!
│ │ └── valueOf() → Returns primitive? → Done!
│ │ → Neither returns primitive → TypeError!
│ │
│ └── Hint is "number" or "default"?
│ ├── valueOf() → Returns primitive? → Done!
│ └── toString() → Returns primitive? → Done!
│ → Neither returns primitive → TypeError!
Important Detail: The Return Value Is Not Necessarily the Hinted Type
The conversion algorithm does not guarantee that the returned primitive matches the hint. The hint tells the method what type is preferred, but the method can return any primitive:
let odd = {
[Symbol.toPrimitive](hint) {
// Returns a string even when the hint is "number"
return "forty-two";
}
};
// JavaScript accepts the string, then the surrounding context may convert further
console.log(+odd); // NaN (Number("forty-two") is NaN)
console.log(`${odd}`); // "forty-two"
console.log(odd + 1); // "forty-two1" (string concatenation)
The Symbol.toPrimitive method returned "forty-two" (a string) even for the "number" hint. JavaScript accepted it because it is a primitive. The unary + then attempted to convert the string "forty-two" to a number, resulting in NaN.
The hint is a suggestion, not a requirement. The conversion algorithm guarantees only that the result is a primitive, not that it is the type matching the hint.
TypeError: When Conversion Fails
If neither Symbol.toPrimitive nor the legacy fallback methods return a primitive, JavaScript throws a TypeError:
let stubborn = {
toString() {
return {}; // Returns an object (not a primitive!)
},
valueOf() {
return {}; // Also returns an object!
}
};
// TypeError: Cannot convert object to primitive value
// console.log(+stubborn);
// console.log(`${stubborn}`);
In practice, this almost never happens with your own code because the default Object.prototype.toString() always returns a string. But it can occur if you intentionally override both methods to return objects.
How Built-In Objects Handle Conversion
JavaScript's built-in objects override toString() and valueOf() to provide sensible conversions:
// Arrays: toString() joins elements with commas
let arr = [1, 2, 3];
console.log(String(arr)); // "1,2,3"
console.log(arr + "!"); // "1,2,3!"
console.log(+arr); // NaN (Number("1,2,3") is NaN)
let singleArr = [42];
console.log(+singleArr); // 42 (Number("42") works!)
let emptyArr = [];
console.log(+emptyArr); // 0 (Number("") is 0)
// Dates: toString() returns readable string, valueOf() returns timestamp
let date = new Date("2024-01-15");
console.log(String(date)); // "Mon Jan 15 2024 ..."
console.log(+date); // 1705276800000 (timestamp)
console.log(date + "!"); // "Mon Jan 15 2024 ...!" (Date uses "string" for default hint!)
// Numbers (wrapped): valueOf() returns the primitive number
let num = new Number(42);
console.log(+num); // 42
console.log(`${num}`); // "42"
// Booleans (wrapped): valueOf() returns the primitive boolean
let bool = new Boolean(false);
console.log(+bool); // 0
console.log(`${bool}`); // "false"
The Date object is the only built-in that treats the "default" hint as "string" instead of "number". This is why date + "" produces a readable date string rather than a numeric timestamp. All other built-in objects treat "default" the same as "number".
The Binary + Operator: A Common Source of Confusion
The binary + operator triggers the "default" hint, which for most objects behaves like "number". But since + can also concatenate strings, the result depends on what the conversion returns:
let price = {
amount: 100,
valueOf() {
return this.amount;
}
};
// valueOf returns 100 (a number), so + does arithmetic
console.log(price + 50); // 150
console.log(price + "€"); // "100€" (number 100 + string "€" = string concatenation)
let label = {
text: "Total",
valueOf() {
return this.text;
}
};
// valueOf returns "Total" (a string), so + does concatenation
console.log(label + 1); // "Total1"
console.log(label + " Due"); // "Total Due"
The conversion happens first, and then the + operator decides whether to add or concatenate based on the types of its operands.
Putting It All Together: Priority and Comparison
Here is a comprehensive example showing how Symbol.toPrimitive takes priority over toString() and valueOf():
let item = {
name: "Widget",
price: 25,
[Symbol.toPrimitive](hint) {
console.log(` Symbol.toPrimitive called with hint "${hint}"`);
if (hint === "string") return `Widget ($${this.price})`;
return this.price;
},
toString() {
console.log(" toString called");
return this.name;
},
valueOf() {
console.log(" valueOf called");
return this.price;
}
};
console.log("--- String context ---");
console.log(String(item));
// Symbol.toPrimitive called with hint "string"
// "Widget ($25)"
console.log("--- Number context ---");
console.log(+item);
// Symbol.toPrimitive called with hint "number"
// 25
console.log("--- Default context ---");
console.log(item + 10);
// Symbol.toPrimitive called with hint "default"
// 35
Notice that toString() and valueOf() are never called. When Symbol.toPrimitive exists, it completely handles all conversion hints. The legacy methods are only used as fallbacks when Symbol.toPrimitive is absent.
Now remove Symbol.toPrimitive and see the fallback in action:
let item = {
name: "Widget",
price: 25,
toString() {
console.log(" toString called");
return this.name;
},
valueOf() {
console.log(" valueOf called");
return this.price;
}
};
console.log("--- String context ---");
console.log(String(item));
// toString called
// "Widget"
console.log("--- Number context ---");
console.log(+item);
// valueOf called
// 25
console.log("--- Default context ---");
console.log(item + 10);
// valueOf called
// 35
With Symbol.toPrimitive removed, the engine falls back to the toString/valueOf order based on the hint.
Further Conversion After the Primitive Is Obtained
Once the object has been converted to a primitive, JavaScript may perform an additional conversion to match the final expected type. The object-to-primitive conversion is just the first step.
let obj = {
[Symbol.toPrimitive](hint) {
return "42"; // Always returns the string "42"
}
};
// String context: "42" is already a string (no further conversion)
console.log(`${obj}`); // "42"
// Number context: "42" is a string, then converted to number 42
console.log(+obj); // 42 (Number("42"))
// Comparison: "42" is compared, potentially with further coercion
console.log(obj > 10); // true (compares 42 > 10 after numeric coercion of "42")
The object-to-primitive step produces a primitive. The operator or function that triggered the conversion may then convert that primitive further (string to number, for example).
Summary
- JavaScript converts objects to primitives when they appear in contexts expecting a primitive value: string concatenation, arithmetic, comparisons, template literals,
String(),Number(), and more. - Boolean conversion is the exception: all objects are always
true. No conversion algorithm is involved. - The conversion process uses a hint to indicate the preferred result type:
"string","number", or"default". Symbol.toPrimitiveis the modern, recommended way to customize conversion. It receives the hint as an argument and must return a primitive value. When defined, it takes full priority overtoString()andvalueOf().toString()andvalueOf()are the legacy fallback methods. For"string"hint,toString()is tried first. For"number"and"default"hints,valueOf()is tried first.- The default
Object.prototype.toString()returns"[object Object]". The defaultObject.prototype.valueOf()returns the object itself (not a primitive). - The conversion must return a primitive. If all methods return objects, JavaScript throws a
TypeError. - The
Dateobject is unique: it treats the"default"hint as"string", while all other built-in objects treat it as"number". - After the object-to-primitive conversion produces a primitive, the surrounding context may perform additional type conversion (for example, converting the resulting string to a number for arithmetic).
Understanding this conversion mechanism demystifies many of JavaScript's seemingly strange behaviors and gives you the power to make your objects behave predictably in any context.