Turning data into intent
JavaScript is forgiving at first, but junior developers eventually discover what senior developers already know: the flexibility is dangerous. An incoming object might have a property or not, its properties could hold undefined or simply be missing, and there is no way to tell at a glance. That ambiguity discourages risky code and produces defensive programs that spend most of their execution on checks rather than logic.
Primitive objects relocate that uncertainty to a single point: initialization. If objects are created with a fixed set of properties and those properties always hold a value, then nothing downstream has to ask does this property exist
or is the value actually
. That predictability is worth pursuing even if the read-only nature of these objects sounds restrictive.undefined?
Start with the simplest possible primitive object:
const my_object = Object.freeze({});
Such an empty object is surprisingly useful. A tabbed interface, for instance, can start as an array of empty objects where each one represents a tab:
import React, { useState } from "react";
const summary_tab = Object.freeze({});
const details_tab = Object.freeze({});
function TabbedContainer({ summary_children, details_children }) {
const [ active, setActive ] = useState(summary_tab);
return (
<div className="tabbed-container">
<div className="tabs">
<label
className={active === summary_tab ? "active" : ""}
onClick={() => {
setActive(summary_tab);
}}
>
Summary
</label>
<label
className={active === details_tab ? "active": ""}
onClick={() => {
setActive(details_tab);
}}
>
Details
</label>
</div>
<div className="tabbed-content">
{active === summary_tab && summary_children}
{active === details_tab && details_children}
</div>
</div>
);
}
export default TabbedContainer;
Bulk initialization without the repetition
That structure screams for refactoring. Tabs share a common shape: an object identity and a label. Attaching the label directly to each tab and freezing the whole array reduces surprises but produces verbose, repetitive code:
const tab_kinds = Object.freeze([
Object.freeze({ label: "Summary" }),
Object.freeze({ label: "Details" })
]);
The verbose form also demands discipline: every new property must be remembered, typed, and manually frozen. A factory that bakes in the freeze call and declares property names once removes that burden. The resulting function closes over the key names and returns another function that maps row arrays to objects:
function populate(...names) {
return function(...elements) {
return Object.freeze(
elements.map(function (values) {
return Object.freeze(names.reduce(
function (result, name, index) {
result[name] = values[index];
return result;
},
Object.create(null)
));
})
);
};
}
A more verbose, easier-to-read equivalent:
function populate(...names) {
return function(...elements) {
const objects = [];
elements.forEach(function (values) {
const object = Object.create(null);
names.forEach(function (name, index) {
object[name] = values[index];
});
objects.push(Object.freeze(object));
});
return Object.freeze(objects);
};
}
Both versions solve the same problem. Call the factory, then feed it rows of values:
const tab_kinds = populate(
"label"
)(
[ "Summary" ],
[ "Details" ]
);
Adding a property means updating the inner array (["title", "subtitle"]) and supplying a corresponding value in each row:
const tab_kinds = populate(
"label",
"color",
"icon"
)(
[ "Summary", colors.midnight_pink, "💡" ],
[ "Details", colors.navi_white, "🔬" ]
);
Separating the two calls leaves a visual gap between the lists of keys and values, which reads nicely for tabular data. It also permits reuse: the same generator can produce primitives for distinct components stored in separate arrays.
The resulting tab definition is clear and obvious, which makes validations easier to spot:
import React, { useState } from "react";
import populate_label from "./populate_label";
const tabs = populate_label(
[ "Summary" ],
[ "Details" ]
);
const [ summary_tab, details_tab ] = tabs;
function TabbedContainer({ summary_children, details_children }) {
const [ active, setActive ] = useState(summary_tab);
return (
<div className="tabbed-container">
<div className="tabs">
{tabs.map((tab) => (
<label
key={tab.label}
className={tab === active ? "active" : ""}
onClick={() => {
setActive(tab);
}}
>
{tab.label}
</label>
)}
</div>
<div className="tabbed-content">
{summary_tab === active && summary_children}
{details_tab === active && details_children}
</div>
</div>
);
}
export default TabbedContainer;
Don’t let list items manage themselves
A common alternative keeps selection state on each tab object:
const tabs = [
{
label: "Summary",
selected: true
},
{
label: "Details",
selected: false
},
];
The check becomes tab === active? No, it would become a comparison on the selected property. But toggling the selection requires clearing the flag on every other tab before setting it on the new one:
function select_tab(tab, tabs) {
tabs.forEach((tab) => tab.selected = false);
tab.selected = true;
}
That works for two elements, but iterates an entire list on every change. It is error-prone even with a small array. Assigning the selection responsibility to the list, not the tab, is the primitive alternative. State exists as a single variable pointing to the selected object (or to undefined when nothing is selected) — you never scramble to unselect siblings beforehand.
Checkboxes follow the same pattern by replacing the single variable with an array. Selecting pushes an object into the array; deselecting filters it out. Redux implementations would create new arrays when an element appears or disappears.
let selected = []; // Nothing is selected.
// Select.
selected = selected.concat([ to_be_selected ]);
// Unselect.
selected = selected.filter((element) => element !== to_be_unselected);
// Check if an element is selected.
selected.includes(element);
Any implementation that stores selected as a property of the element suffers when the same element appears in multiple lists with independent selections. Primitive objects sidestep that entire class of bug: a list element carries no self-describing state. It is not the coordinate for storing whether it has been chosen.
Strings are for display more often than for logic
Strings make a convenient unit for humans. They are names, and names make understanding easy for the reader of the code. Programs do not share that advantage. Your IDE will not flag a typo in a string, and === can only tell equality, never correctness.
So, internal checks often turn into confusing sequences. Pattern: if a variable might hold null or undefined, a property access on it is impossible. Objects that arrive safely still may lack id. That leads to nested conditionals just to ask is it this object
:
const myID = "Oh, it's so unique";
function magnification(value) {
if (value && typeof value === "object" && value.id === myID) {
// do magic
}
}
With explicit primitives, those checks collapse down to object identity:
import data from "./the file where data is stored";
function magnification(value) {
if (value === data.myObject) {
// do magic
}
}
Anything other than a primitive object here means the comparison fails. The code immediately reads as the intent — verify the object is the one we care about with no auxiliary property checks.
Empty strings are a silent trap the same way. Without a literal, mistakes manifest as reference errors, and reference errors are something tooling can locate before saving or pushing. undefined remains the single result to guard against.
Choose strings for visible output and little else. Primitive objects might shortchange interoperability with non-code systems, but that loss rarely matters when comparisons and array operations are the fundamental needs.
Boundaries and repeatability replace defensive bookkeeping because conditions behave. The fewer possibilities remain unchecked, the stronger the program’s direction becomes.



