Logical Assignment, Object.hasOwn & structuredClone
Three small features that landed in the language within a year of each other, each one quietly killing a piece of boilerplate you’ve probably written a hundred times. None of them is a big idea. All of them remove a papercut.
- The logical assignment operators
??=,||=and&&=collapse “check, then maybe assign” into one operator. Object.hasOwnis the safe way to ask “does this object have its own key named X?” — the answerhasOwnPropertywas always supposed to give but couldn’t reliably.structuredClonemakes a genuine deep copy of almost any value, cycles and Maps included, without you reaching for a library or the oldJSONtrick.
All three are Baseline widely available — supported across Chrome, Firefox, Safari and Edge since around March 2022, and in Node since v17. You can use them in production today with no flag, no polyfill, no transpiler. Let’s take them one at a time.
Logical assignment operators
You already know the shorthand x += 1 means x = x + 1. Logical assignment applies that same “operate-then-store” pattern to the three short-circuiting logical operators.
a ||= b; // assign b to a — only if a is falsy
a &&= b; // assign b to a — only if a is truthy
a ??= b; // assign b to a — only if a is null or undefined
The mental model that trips people up: these are not the same as a = a || b. The difference is subtle but it matters, and we’ll get to exactly why in a moment. First, the plain version of each.
||= — fill in any falsy hole
||= writes the right side only when the left side is falsy — that’s 0, "", false, NaN, null, undefined, and nothing else.
let title = "";
title ||= "Untitled";
console.log(title); // "Untitled" — "" is falsy, so it got replaced
let count = 0;
count ||= 10;
console.log(count); // 10 — careful! 0 is falsy too
That second example is the classic || footgun carried over to ||=: a legitimate 0 gets clobbered because it’s falsy. When you mean “only if truly absent,” you want ??=.
??= — fill in only what’s missing
??= writes only when the left side is null or undefined. A 0, an empty string, or false all survive.
let count = 0;
count ??= 10;
console.log(count); // 0 — 0 is defined, so it stays
const settings = {};
settings.theme ??= "light";
settings.theme ??= "dark";
console.log(settings.theme); // "light" — second line is a no-op
This is the one you’ll reach for most. It’s the assignment cousin of the nullish coalescing operator — same “is this actually missing?” question, now writing the answer back in place.
&&= — overwrite only what already exists
&&= is the odd one out: it writes only when the left side is truthy. Reach for it when you want to transform a value that’s already there, and leave absent values untouched.
let user = { name: "ada" };
user.name &&= user.name.trim().toUpperCase();
console.log(user.name); // "ADA"
let empty = null;
empty &&= empty.trim(); // no crash — empty is falsy, right side never runs
console.log(empty); // null
That second case is the real value of &&=: because the right side never runs when the left is falsy, you don’t have to guard against calling .trim() on null.
current value of a | a ||= b | a &&= b | a ??= b |
|---|---|---|---|
| undefined | writes b | skips | writes b |
| null | writes b | skips | writes b |
| 0 | writes b | skips | skips |
| “” (empty string) | writes b | skips | skips |
| false | writes b | skips | skips |
| 42 (any truthy) | skips | writes b | skips |
Read that table by column. ||= writes on every falsy value. ??= writes only on the two nullish values — it lets 0, "" and false through. &&= is the mirror image: it writes only when there’s already a truthy value there.
Rather than memorize the table, drive it. Pick a starting value for a, pick an operator, and watch whether the assignment of b = "NEW" actually fires:
The part everyone misses: it short-circuits the assignment
Here’s where these operators are more than sugar. a ||= b is not defined as a = a || b. It’s defined as a || (a = b).
The difference: if the assignment isn’t needed, it never happens. No write occurs at all. The right-hand side isn’t evaluated, and — this is the important bit — the property isn’t even touched.
Why does an untouched property matter? Two reasons, both real:
const obj = {
get x() { console.log("read x"); return 1; },
set x(v) { console.log("write x"); }
};
obj.x ||= 99;
// logs: "read x"
// does NOT log "write x" — x is truthy, so the setter never fires
With a = a || b you’d always run the setter, even when the value doesn’t change. With a ||= b the setter fires only if an actual write is needed. That’s not academic: firing a setter can trigger a re-render in a reactive framework, a network write in a proxy, or a mutation observer callback.
The same short-circuit rule applies to all three: &&= only evaluates and assigns the right side when the left is truthy; ??= only when the left is nullish. If the condition to assign isn’t met, the right-hand expression — function calls, side effects and all — simply never runs.
function expensive() {
console.log("computed!");
return 42;
}
let cached = 7;
cached ??= expensive(); // nothing logged — cached is defined, so expensive() never runs
Object.hasOwn — the safe “does it have this key?”
To check whether a key lives directly on an object (rather than being inherited from its prototype), the old move was obj.hasOwnProperty("key"). It works most of the time. The problem is the two cases where it doesn’t — and they’re exactly the cases where you can least afford a surprise.
Problem one: null-prototype objects. An object made with Object.create(null) or { __proto__: null } has no prototype chain, so it doesn’t inherit hasOwnProperty at all. Calling it throws.
const dict = Object.create(null);
dict.apple = 1;
dict.hasOwnProperty("apple");
// ❌ TypeError: dict.hasOwnProperty is not a function
Problem two: a key named hasOwnProperty. If the object itself carries a property called hasOwnProperty — common when the object is parsed from untrusted JSON — you’d be calling the data, not the method.
const parsed = JSON.parse('{ "hasOwnProperty": "gotcha" }');
parsed.hasOwnProperty("anything");
// ❌ TypeError: parsed.hasOwnProperty is not a function (it's a string now)
The historical workaround was a mouthful you had to remember and get exactly right: Object.prototype.hasOwnProperty.call(obj, key). It borrows the real method and applies it to your object. It works — it’s just ugly and easy to fumble.
Object.hasOwn is that pattern, given a name. It’s a static method on Object, so it never touches the target’s own properties or prototype:
Object.hasOwn(dict, "apple"); // true — works on null-prototype
Object.hasOwn(parsed, "anything"); // false — ignores the "hasOwnProperty" data key
Object.hasOwn({ a: undefined }, "a"); // true — the key exists, value is irrelevant
The behavior is otherwise identical to hasOwnProperty: it returns true for own enumerable and non-enumerable properties, false for inherited ones, and — a common point of confusion — it reports on the existence of the key, not its value. A property set to undefined still returns true.
The object below has one own key (color) and inherits toString from Object.prototype. Type a key and watch the two checks disagree exactly where inheritance is involved:
structuredClone — real deep copies
Copying an object in JavaScript is famously a place to get burned. The spread operator and Object.assign both make a shallow copy: the top level is duplicated, but nested objects are still shared by reference.
const original = { user: { name: "ada" } };
const copy = { ...original };
copy.user.name = "grace";
console.log(original.user.name); // "grace" — oops, they share the same inner object
We cover the shallow-copy story fully in object copying and references. When you need a copy where mutating the copy can never reach back and touch the original, you need a deep copy. That’s what structuredClone gives you.
Try it yourself. Make a copy with either technique, then edit the nested name in the copy — and see whether the original changes with it:
const original = { user: { name: "ada" }, tags: new Set(["x"]) };
const copy = structuredClone(original);
copy.user.name = "grace";
copy.tags.add("y");
console.log(original.user.name); // "ada" — untouched
console.log(original.tags); // Set(1) { "x" } — untouched
The signature is structuredClone(value, options?). The value can be almost anything; the optional options.transfer is an array of transferable objects (like ArrayBuffer) to hand over rather than copy — a niche feature for moving big buffers to a Worker without duplicating them.
For years, people faked it with JSON
Before structuredClone existed, the go-to hack for a deep copy was a round-trip through JSON:
const copy = JSON.parse(JSON.stringify(original)); // the old deep-copy hack
It works for plain data. But it silently corrupts anything that isn’t plain data, and silently is the dangerous word — you get a copy back with no error, just wrong.
| input value | after JSON round-trip | structuredClone |
|---|---|---|
| new Date() | string “2026-07-08T…” | Date object |
| () => {} | key dropped | throws DataCloneError |
| undefined value | key dropped | preserved |
| new Map([[“a”,1]]) | {} (empty object) | Map preserved |
| NaN, Infinity | null | preserved |
| object with a cycle | throws TypeError | preserved |
Look at that middle column. A Date comes back as a string. Functions and undefined-valued keys just vanish. A Map becomes an empty object — arguably the nastiest, because {} looks plausible until you call .get() on it and get undefined forever. NaN and Infinity turn into null. structuredClone handles every one of these correctly, and where it can’t copy something (a function), it throws loudly instead of dropping it silently.
Cycles: where JSON gives up entirely
A circular reference is an object that, somewhere down its tree, points back at itself. JSON.stringify can’t represent that — it would recurse forever — so it throws. structuredClone tracks what it has already visited and wires the cycle back up in the copy.
const node = { name: "root" };
node.self = node; // a cycle: node.self points back at node
JSON.stringify(node);
// ❌ TypeError: Converting circular structure to JSON
const copy = structuredClone(node);
console.log(copy.self === copy); // true — the cycle is rebuilt, pointing at the copy
console.log(copy.self === node); // false — and it's the copy, not the original
What structuredClone still can’t do
It’s powerful, not magic. Three limits to keep in mind:
Functions and DOM nodes throw. Any function anywhere in the tree — a method, a callback, a getter’s function object — makes the whole call fail with a DataCloneError. Same for DOM elements. This is deliberate: there’s no meaningful way to deep-copy a function’s closure, so it refuses rather than guessing.
structuredClone({ run: () => {} });
// ❌ DataCloneError: () => {} could not be cloned
The prototype chain is flattened. Clone a class instance and you get a plain object with the same own properties — but its prototype is Object.prototype, not the class’s. Methods, instanceof, private fields: all gone.
class Point { constructor(x) { this.x = x; } dist() { return this.x; } }
const p = new Point(3);
const c = structuredClone(p);
console.log(c.x); // 3 — own data survives
console.log(c instanceof Point); // false — it's a plain object now
c.dist(); // ❌ TypeError: c.dist is not a function
Getters, setters and property descriptors are lost. A cloned property is a plain data property. Accessors are read once (to get a value) and copied as static data; non-enumerable flags and writable: false don’t carry over. Symbol keys are dropped too.
Putting them together
These three tend to show up in the same neighborhood: normalizing an options object, defensively copying input, checking what a caller actually passed.
function createWidget(userOptions = {}) {
// Deep-copy so we never mutate the caller's object.
const options = structuredClone(userOptions);
// Backfill defaults only where the caller left a gap.
options.theme ??= "light";
options.retries ??= 3;
options.timeout ??= 5000;
// Was a callback explicitly provided as an own property?
if (Object.hasOwn(options, "onReady")) {
// ...but functions don't survive structuredClone, so read it off the original:
userOptions.onReady?.();
}
return options;
}
That last comment points at a real interaction worth internalizing: because structuredClone strips functions, if your options can carry callbacks you either clone before pulling them off, or copy the plain-data fields and handle functions separately. The tools are sharp; knowing their edges is the whole game.
Support and compatibility
All three are Baseline widely available — you can ship them to the general public without a fallback:
- Logical assignment (
??=,||=,&&=) — ES2021. Chrome/Edge 85, Firefox 79, Safari 14, Node 15+. Object.hasOwn— ES2022. Available across all major browsers since March 2022; Node 16.9+.structuredClone— WHATWG HTML standard (not part of ECMAScript itself, so it’s a host-provided global). In browsers since March 2022; Node 17+.
If you must support an environment older than that, Object.hasOwn has a trivial one-line replacement (Object.prototype.hasOwnProperty.call), the logical assignments are transpilable by any modern build tool, and structuredClone has userland polyfills — though a polyfill can’t replicate transferables. In 2026, for anything targeting evergreen browsers or current Node, reach for the real thing.
Summary
||=assigns when the left is falsy;&&=when it’s truthy;??=only when it’snullorundefined. Pick??=for defaults so a real0or""survives.- All three short-circuit the assignment: if no write is needed, the right side isn’t evaluated and the target property isn’t even touched — its setter never fires.
Object.hasOwn(obj, key)is the safe replacement forhasOwnProperty. It works onObject.create(null)objects and on objects that shadowhasOwnProperty, because it’s a static method that reaches the object from outside.structuredClone(value)makes a true deep copy: nested objects, cycles,Map,Set,Date,RegExp, typed arrays andErrortypes all survive.- It beats
JSON.parse(JSON.stringify(x)), which silently turnsDateinto a string, drops functions andundefined, flattensMapto{}, and throws on cycles. structuredClonecopies data, not behavior: functions and DOM nodes throwDataCloneError, and the prototype chain, accessors and private fields are not preserved.- Every one of these is Baseline widely available since ~2022 — safe to use in production today.