Monday, June 9, 2025

Unlocking Object Power in JavaScript; valueOf, toJSON, and Well-Known Symbols



In JavaScript, objects can participate in a wide variety of built-in operations by implementing specific methods or well-known symbols. Beyond the familiar


obj.valueOf() // for numeric coercion
obj.toString() // for string coercion

you can hook into almost every language feature. In this post we will explore the most useful methods and symbols, show multiple examples of each, and explain when to use them.

1. Custom Primitive Conversion with Symbol.toPrimitive

When the engine needs to coerce your object to a primitive value, say in arithmetic, string interpolation, or loose equality, it will look first for a [Symbol.toPrimitive] method. If you define it, neither valueOf nor toString will be called.


class Color {
  constructor(r, g, b) {
    this.r = r;
    this.g = g;
    this.b = b;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === 'number') {
      // return a grayscale equivalent
      return 0.3 * this.r + 0.59 * this.g + 0.11 * this.b;
    }
    else if (hint === 'string') {
      // return CSS rgb notation
      return `rgb(${this.r},${this.g},${this.b})`;
    }

    // default hint; use string form
    return this.toString();
  }

  toString() {
    return `Color(${this.r},${this.g},${this.b})`;
  }
}

const c = new Color(10, 20, 30);
console.log(+c); // numeric hint: 0.3*10+0.59*20+0.11*30 → 18.2
console.log(`${c}`); // string hint: "rgb(10,20,30)"
console.log(c + '!'); // default hint: "Color(10,20,30)!"

Other examples

  • Use numeric hint to control sorting or comparison in arrays
  • Use string hint to integrate with template engines

2. Locale-Aware Strings with toLocaleString()

Many built-ins, such as Array.prototype.join or Date.prototype.toLocaleString, defer to your object’s toLocaleString method. Implement it to format dates, numbers, or currencies according to locale settings.


class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  toLocaleString(locale, options = {}) {
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: this.currency,
      ...options
    }).format(this.amount);
  }
}

const m = new Money(1234.56, 'USD');
console.log(m.toLocaleString('en-US')); // "$1,234.56"
console.log(m.toLocaleString('de-DE')); // "1.234,56 $"
console.log([m, m].join(' and ')); // uses toLocaleString, then join → "$1,234.56 and $1,234.56"

Tips

  • Accept options to override locale defaults
  • Use in custom collections that need human-readable output

3. Controlling JSON Serialization with toJSON()

When JSON.stringify(obj) runs, it checks for a toJSON() method and uses its return value instead of the original object. You can prune or reshape data for network transfer.


class User {
  constructor(id, name, passwordHash) {
    this.id = id;
    this.name = name;
    this.passwordHash = passwordHash;
  }

  toJSON() {
    // exclude sensitive fields
    return { id: this.id, name: this.name };
  }
}

const user = new User(1, 'alice', 'xyz123hash');
console.log(JSON.stringify(user)); // → '{"id":1,"name":"alice"}'

Variations

  • Return an array rather than an object
  • Embed type metadata for deserialization

4. Custom Object Tag with Symbol.toStringTag

By default Object.prototype.toString.call(obj) yields [object Object]. You can override the tag to improve debugging or logging.


class Matrix {
  get [Symbol.toStringTag]() {
    return 'Matrix';
  }
}

const mat = new Matrix();
console.log(Object.prototype.toString.call(mat)); // → "[object Matrix]"

Use cases

  • Distinguish custom collections or domain models in debug logs
  • Integrate with libraries that inspect object tags

5. Iteration Protocols: Symbol.iterator and Symbol.asyncIterator

To make your object iterable in a for…of loop or spread syntax, define [Symbol.iterator]. For asynchronous streams, define [Symbol.asyncIterator].


class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
}

for (const n of new Range(1, 3)) {
  console.log(n);
}
// → 1
// → 2
// → 3

// Async example
class AsyncRange {
  constructor(limit) {
    this.limit = limit;
  }

  async *[Symbol.asyncIterator]() {
    for (let i=1; i <=this.limit; i++) {
      // simulate delay
      await new Promise(r=> setTimeout(r, 100));
      yield i;
    }
  }
}

(async () => {
  for await (const n of new AsyncRange(3)) {
    console.log(n);
  }
})();

6. Other Well-Known Symbols and Hooks

Symbol or Method Purpose
Symbol.hasInstance Customize obj instanceof Constructor
Symbol.isConcatSpreadable Control whether [].concat(obj) spreads your object
Symbol.match / Symbol.replace / Symbol.search / Symbol.split / Symbol.match    All Make your object behave like a RegExp in string methods
util.inspect.custom (Node.js) Customize Node’s REPL and util.inspect() output

7. Summary

By implementing these methods and symbols you can:

  1. Control how your objects convert to primitives (Symbol.toPrimitive, valueOf, toString)
  2. Format locale-sensitive output (toLocaleString)
  3. Shape JSON payloads (toJSON)
  4. Label your objects in debug logs (Symbol.toStringTag)
  5. Enable synchronous or asynchronous iteration (Symbol.iterator, Symbol.asyncIterator)
  6. Participate in regex operations and instanceof checks (various Symbol.*)
  7. Integrate smoothly with Node’s inspection tools (util.inspect.custom)

These hooks let your objects feel like first-class citizens of the language. You can start by overriding one or two in your domain classes, see how that improves ergonomics, and gradually adopt more as needed.