Why Strings Fall Short as Identifiers

Strings are the default choice for distinguishing between things. Objects with name, id, or label properties are everywhere, and checking those properties is how we usually tell one object from another.

if (element.label === "title") {
    make_bold(element);
}

As a project scales, this approach starts to hurt. More entities require more strings, and strings require more characters. Renaming a label value means hunting down every occurrence and updating it, bloating what should be a trivial commit. Worse, editors and IDEs won’t catch a typo in a string literal. You’re on your own when it comes to correctness.

A common remedy is to extract those strings into constants. That solves the typo problem, but it still leaves you open to collisions. Consider admin, which might appear in entirely different contexts — or as the name of a company — depending on where you look.

const john_smith_a_person = "John Smith";
const john_smith_a_company = "John Smith";

// Do they have the same name?
john_smith_a_person === john_smith_a_company; // true

// Are they the same thing?
john_smith_a_person === john_smith_a_company; // true

Objects themselves offer a cleaner solution. Instead of comparing an object’s properties to a string, you compare the object directly to a reference object that stands for the thing you want. The intent becomes immediately obvious to anyone reading the code.

// Do they have a same name?
john_smith_a_person.name === john_smith_a_company.name; // true

// Are they the same thing?
john_smith_a_person === john_smith_a_company; // false

Labels in a localized app make this pattern shine. If you keep all your labels in a single curated module, a special-case label can carry everything it needs — text, styling hints, icon — all arranged ahead of time.

import React from "react";
import labels from "./labels.js";

const render_label(label) => (
    <Label
        className={label === labels.title ? "bold" : "plain"}
        icon={label.icon}
        text={label.text}
    />
)

function TableOfContents({ items }) {
    return (
        <ul className="my-menu">
            {items.map(render_label(item.label)}
        </ul>
    );
}

Used this way, an object is no longer just a passive data bag. It encodes identity, meaning, and presentation in one place. Still, JavaScript objects are too flexible for reliable identity comparison on their own. They can be mutated or created anonymously, which is why you rarely see two objects compared directly for equality. They tend to be compared by the values of their properties. The fix is to strip objects of their excess capabilities and add specific ones — the outcome is what I call Primitive Objects.

In this first part, we’ll look at the JavaScript features that make ordinary objects behave more like primitives, opening the door to a wider use of operators and comparisons. Part two will cover practical patterns and tooling. Let’s start by mapping out what we actually need from a primitive-like object.

Four Traits Primitive Objects Should Have

  • Immutability
    Primitives are read-only. If you hand a label object to code outside your control, that code shouldn’t be able to silently change its text or icon. Once an object represents a fixed idea, it should stay fixed.
  • Operator support
    Arithmetic operators return numbers; comparison operators return booleans. Primitive-like objects should participate in common expressions without surprising results.
  • Literal syntax
    When you write "hello" in source, you get a value that is created once and reused. Objects need a similar permanence: each distinct value should map to the same object instance every time it’s referenced.
  • Type recognition
    The typeof operator tells you a primitive’s kind (with the usual null caveat). For objects, we want reliable type information before we touch any properties.

Those traits are ordered by usefulness and also by how easily they can be introduced into JavaScript. Here we’ll cover the first one fully and step toward the second, showing how to lock objects down and how to define their primitive representation.

The Mechanics of Object Identity

It’s a rite of passage to run {} === {}; // false in a console and wonder why the language can’t tell two equal-looking things apart.

In JavaScript, everything you may want to think of as an object behaves like one. The one-liner syntax for creating an object hides what’s actually happening:

// Instead of this.
const my_object = new Object();
my_object.first_property = "First property";
my_object.nth_property = "Next property";

// You can do this.
const my_object = {
    first_property: "First property",
    nth_property: "Next property"
};

That comparison is not checking whether two object literals represent the same content. It is asking whether two distinct objects — each instantiated in its own expression — are the same reference.

new Object() === new Object(); // false

By that reasoning, the expression is akin to asking whether 5 === 3. They’re different values; no sane language would answer otherwise.

A quick sanity check shows what happens when two variables do point to the same object:

const my_object = {};
const other_thing = my_object;
my_object === other_thing; // true

The object is created once, and both variables bind to that same instance. The comparison then succeeds, much like comparing two numbers of equal value.

This is the key insight. If we can treat object identity like primitive equality, we gain a dependable membership check: does this variable refer to the object I’m thinking of? Strings and numbers work that way inside the engine — the runtime compares references, not character-by-character content. That simplification is possible only because primitives are immutable. And it’s immutability that lets us extend a similar design to objects.

Treating Objects Like Primitives

Primitive values in JavaScript are immutable. You cannot alter a single character in a string, nor can you make the number five become six. If you initialize a variable with const and assign a primitive to it, that binding is permanent — no one can change the value, and no one can reassign the variable.

Consider how numbers behave. You can derive six from five by incrementing, but that operation changes nothing about five itself:

const five = 5;
const six = 5 + 1;
five === 5; // true

Some might argue that using let changes this behavior, but it does not. Five remains five:

const five = 5;
let result = 5;
result++;
result === 6; // true
five === 5; // true

The reason is that ++ is shorthand for += 1, which is itself shorthand for an assignment. What actually happens is that a new value — the result of result + 1 — is assigned to the result variable. The const keyword simply prevents reassignment to a variable, which is what guarantees that five always points to a 5.

So, the only way to "change" a primitive is through variable assignment — meaning it is the variable that changes, not the value. But what about objects? After initialization, you can freely mutate an object's properties: delete them, add new ones, or reassign existing ones. Aside from that, however, objects behave exactly like primitives. If you adopt the mental model that objects and primitives are fundamentally the same kind of thing, JavaScript's behavior in many situations becomes much clearer.

Passing Values to Functions

A common question is whether variables are passed by value or by reference. The typical answer states that primitives are passed by value and objects by reference. That answer, however, relies on a flawed comparison. Consider what happens when you pass a variable to a function: the variable's value is assigned to the function's argument, which is a local variable in the function's scope. There is no connection back to the original variable.

Look at these two functions. They do exactly the same thing — pass a value through — but one defines a parameter while the other does not:

function single(arg) {
    return arg;
}
    
function none() {
        
    // The first parameter is assigned to a variable `arg`.
    // Notice the `let`; it will be significant later.
    let arg = arguments[0];

    return arg;
}
    
single("hi"); // "hi"
none(5);      // 5

They work identically. Now, let's attempt to change values inside a function. The function below changes its argument and returns it. Try to predict what the console will output:

function reassign(arg) {
    arg = "OMG";
}

const unreassignable = "What";
let reassignable = "is";
let non_primitive = { val: "happening" };

reassign(unreassignable);
reassign(reassignable);
reassign(non_primitive);

console.log(unreassignable, reassignable, non_primitive.val, "😱");

The output is "What is happening 😱." No matter what gets passed, reassigning only changes the argument variable. Neither const nor let makes a difference here because the function never receives the original variable. But what happens if we try to change a property of an argument?

Here is a function that attempts to set the val property of its argument:

function change_val_prop(arg) {
    try {
        arg.val = "OMG";
    } catch (ignore) {}
}

const a_string = "What";
const a_number = 15;
const non_primitive = { val: "happening" };
const non_primitive_read_only = Object.freeze({ my_string: "here" });

change_val_prop(a_string);
change_val_prop(a_number);
change_val_prop(non_primitive);
change_val_prop(non_primitive_read_only);

console.log(
    a_string.val,
    a_number.val,
    non_primitive.val,
    non_primitive_read_only.val,
    "😱"
);

The message is "undefined undefined OMG undefined 😱." The function could only change the property when it received a regular, unfrozen object. This suggests there is no meaningful distinction in how primitives and objects are passed — only in whether the properties you try to touch are writable.

This is where many tutorials employ a sleight of hand. They claim that primitives and objects are passed differently, then demonstrate it by treating them differently. The MDN function description, for instance, states:

Arguments may be passed by value (in the case of primitive values) or by reference (in the case of objects). This means that if a function reassigns a primitive type parameter, the value won't change outside the function. In the case of an object type parameter, if its properties are mutated, the change will impact outside of the function.

But as you have just seen, reassigning an object parameter also does not affect the original variable. And you cannot mutate primitive properties because they are read-only — the same is true for frozen objects. Most examples first declare a difference between the two, then prove it using different operations for each.

Seeing primitives as immutable, frozen objects offers a more coherent and truthful model for how JavaScript actually operates under the hood.

Defining Representations

While primitives stand alone and are universally understood, objects can represent anything. To make custom objects more useful, you can define how they convert into primitive values. Take an object modeling a rating from zero to five. You might want to compare or sort it numerically, and also output it as text.

By default, turning an object into a string yields the not-very-helpful [object Object]:

String({}); // "[object Object]"

That can be changed by overriding the toString method on your object:

String({ toString: () => "hello there" }); // "hello there"

Here is a factory function that creates and freezes such rating objects, validates the range, and returns undefined for invalid values:

function new_rating(value) {
    const max = 5;

    // That symbol forces textual representation (who needs emoji anyway 🙄).
    const text_only = "\ufe0e";

    const star = "⭑" + text_only;
    const no_star = "⭐" + text_only;
        
    if (
        !Number.isSafeInteger(value) ||
        (value < 0 || value > max)
    ) {
        return undefined;
    }
        
    return Object.freeze({
        value,
        toString: () => star.repeat(value) + no_star.repeat(max - value)
    });
}

Now rate an item — say, a particularly good pen:

const ratings = new WeakMap();
ratings.set(jetstream_pen, new_rating(5));

A WeakMap can be used to attach ratings to objects without mutating them directly. Whenever you need a text representation, wrap the object in a template literal or call String on it to trigger toString:

if (ratings.has(jetstream_pen)) {
    console.log(`${jetstream_pen} ${ratings.get(jetstream_pen)}`);
    // "Uni-Ball Jetstream 0.5 ⭑︎⭑︎⭑︎⭑︎⭑︎"
}

Numeric Conversion and Operators

For numeric contexts, JavaScript calls the valueOf method. This happens during comparisons, math operations (except the + operator), and various coercions. Add it to the rating factory:

function new_rating(value) {
    // ...
        
    return Object.freeze({
        value,
        valueOf: () => value,
        toString: () => star.repeat(value) + no_star.repeat(max - value)
    });
}

While returning the value property directly may seem redundant, it provides a universal numeric representation for anything that consumes the object. For instance, filtering items with a rating property of at least four stars:

articles.filter((item) => item.rating > 3);
// [ { name: "Uni-Ball Jetstream 0.5", ... } ]

Or sorting an array of rating objects with Array.prototype.sort:

function sorter(first, second) {
    return second.rating - first.rating;
}

const sorted_by_rating = array_of.sort(sorter);

The result is a sorted array of the best-rated items, relying entirely on the object's own conversion logic.

What Primitive Objects Enable

JavaScript does not let you define new operators or literal syntax, but primitive objects let you define new types of values that participate naturally in existing language operations. By freezing objects, you make them read-only. By implementing toString and valueOf, you give them primitive representations that work with arithmetic, comparison, and output.

This model relies on stable, core JavaScript features. Even where the abstraction is imperfect, viewing objects as primitive-like values makes working with the language less brittle and reduces the need for external tooling.

Smashing Editorial