Skip to main content

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
}
Objects Are Always Truthy

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

HintTriggered 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
Symbol.toPrimitive Is the Preferred Approach

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:

  1. Call toString(). If it returns a primitive, use it.
  2. If toString() does not return a primitive, call valueOf(). If it returns a primitive, use it.
  3. If neither returns a primitive, throw a TypeError.

For "number" or "default" hint:

  1. Call valueOf(). If it returns a primitive, use it.
  2. If valueOf() does not return a primitive, call toString(). If it returns a primitive, use it.
  3. 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.

Override Both or Use Symbol.toPrimitive

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.
  • 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.

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"
Date Is Special

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.toPrimitive is 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 over toString() and valueOf().
  • toString() and valueOf() 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 default Object.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 Date object 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.