bastianplsfix

Values and references

Every value in JavaScript is either a primitive or an object. That sounds like trivia, but it decides what assignment does, and assignment happens on nearly every line you write.

When you assign a primitive, JavaScript copies the value itself. After the copy, the two names are fully independent: changing one has no effect on the other. When you assign an object, JavaScript copies a reference instead. A reference is not the object. It is the way of reaching the object, closer to an address than to the house that stands there. Copy an address onto a second piece of paper and you have two pieces of paper, but still one house. Anything done to the house is visible no matter which paper led you to it.

That single distinction explains three surprises, and this page earns each one as evidence: why two identical-looking objects refuse to be equal, why const does not stop contents from changing, and why a function can rewrite your data without returning anything.

Create programs/values-and-references.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertNotEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

assigning a primitive copies the value

Start with the ordinary case, because every later step contrasts with it. A number is assigned from one name to another, and then the first name changes. Predict the value of b before saving.

Deno.test("assigning a primitive copies the value", () => {
let a = 5;
const b = a;
a = 10;
assertEquals(a, 10);
assertEquals(b, 5);
});
Check programs/values-and-references.test.ts
running 1 test from ./programs/values-and-references.test.ts
assigning a primitive copies the value ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

Both assertions pass. Walk the three lines to see why.

  1. let a = 5 makes the name a refer to the number 5.
  2. const b = a evaluates a, gets the number 5, and copies that number into b. From this moment a and b hold two separate fives; nothing connects them anymore.
  3. a = 10 points a at a different number. b never hears about it, because b holds its own copy, which is why assertEquals(b, 5) passes.

That is what "assigning a primitive copies the value" means: after the assignment, the names share nothing.

assigning an object copies the reference

Keep the shape of the code and change only the kind of value. Instead of the number 5, the first name gets the object { count: 5 }. Instead of reassigning the second name, the test mutates through it, meaning it changes the object's contents in place. If assignment copies objects the way it copies numbers, then obj1 holds its own untouched object and obj1.count should still be 5. Add the test and save:

Deno.test("assigning an object copies the reference", () => {
const obj1 = { count: 5 };
const obj2 = obj1;
obj2.count = 10;
assertEquals(obj1.count, 5);
});
Check programs/values-and-references.test.ts
running 2 tests from ./programs/values-and-references.test.ts
assigning a primitive copies the value ... ok (0ms)
assigning an object copies the reference ... FAILED (10ms)

ERRORS

assigning an object copies the reference => ./programs/values-and-references.test.ts:12:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- 10
+ 5

FAILURES

assigning an object copies the reference => ./programs/values-and-references.test.ts:12:11

FAILED | 1 passed | 1 failed (11ms)

error: Test failed

The report names the step that missed, and the diff reads: actual 10, expected 5. So obj1.count is 10, and the failed assertion is the useful part, because it tells us exactly where the copy story breaks.

Count the objects in this step, line by line. An object is created when an object literal runs, and only one literal appears.

  1. const obj1 = { count: 5 } runs that one literal, so the program built exactly one object, and obj1 holds a reference to it.
  2. const obj2 = obj1 created no second object; there are no braces on that line. It copied the reference, the way of reaching the one object that exists. Two names now, still one object.
  3. obj2.count = 10 follows the reference in obj2, arrives at the one object, and writes 10 into its count property.
  4. assertEquals(obj1.count, 5) follows the reference in obj1, arrives at the same object, and finds the 10 written a moment ago. The prediction said 5, so the assertion fails.

Two names, one object: obj2.count = 10 and obj1.count = 10 are the same mutation.

Correct the prediction to 10:

Check programs/values-and-references.test.ts
running 2 tests from ./programs/values-and-references.test.ts
assigning a primitive copies the value ... ok (0ms)
assigning an object copies the reference ... ok (0ms)

ok | 2 passed | 0 failed (2ms)

You can now predict whether a change through one name is visible through another. Ask one question: do the names hold copies of a primitive, or references to one object?

a string method returns a new string

The previous step mutated an object. Can a primitive be mutated the same way? Strings are the right place to ask, because strings answer method calls, and a method call looks like exactly the kind of thing that could change a value in place.

Deno.test("a string method returns a new string", () => {
const name = "ada";
assertEquals(name.toUpperCase(), "ADA");
assertEquals(name, "ada");
});
a string method returns a new string ... ok (0ms)

Read the two assertions as one experiment.

  1. name.toUpperCase() produced "ADA", so the method did its job.
  2. name still holds "ada", so the method did its job without touching the original.

There was no mutation. The method built a brand-new string and handed it back, and the old string stayed exactly as it was.

Every string method behaves this way. None of them writes into the string it was called on; each one returns a new string and leaves the original alone. This is because primitives cannot be changed at all. Not "should not": cannot. The language provides no operation that alters a primitive in place. A primitive value is what it is, permanently, and anything that looks like a change is actually the construction of a new value.

It is worth knowing the full list. There are seven primitive types: undefined, null, boolean, number, bigint, string, and symbol. Everything that is not on that list is an object. That includes plain objects, arrays, functions, Map, Set, dates, and every class instance. Objects are the only values that mutation can touch, which is why the previous step could change count and this step could not change "ada".

One question opens here that this step does not answer: if "ada" is a primitive, and primitives hold no methods, how did "ada".toUpperCase() answer a method call at all? We'll explain that when we get to a wrapper object is not its primitive.

identity versus contents

Two objects built from identical literals, then two comparisons of the same pair. Predict each comparison before saving, and notice that they are allowed to disagree.

Deno.test("identity versus contents", () => {
const p1 = { x: 1 };
const p2 = { x: 1 };
assertEquals(p1 === p2, false);
assertEquals(p1, p2);
});
identity versus contents ... ok (0ms)

Both assertions pass, and they say different things, so take them one at a time.

  1. p1 === p2 asks: is this the same object? It compares the references, not what the references lead to. Count the literals again: { x: 1 } appears twice, each one ran, so the program built two objects. p1 and p2 lead to different objects, and the honest answer to "same object?" is false. The contents never entered into it. Two objects with identical contents are still two objects, the way two houses with identical floor plans are still two houses.
  2. assertEquals(p1, p2) asks a different question: do the contents match? It walks into both objects, visits each property, and compares what it finds. Both objects hold x: 1, so the answer is yes.

Neither comparison is wrong. They answer different questions, and the step's job is to make sure you always know which question you are asking. This is the first claim from the top of the page earned as evidence: two identical-looking objects are not === equal, because === never looks at contents.

parameters receive a copy of the reference

So far every assignment was written with an = you could see. A function call performs an assignment too, and it is invisible. Calling reassignParam(original) behaves as if the function's first line were o = original: the parameter o is a new name, and it receives a copy of whatever was passed. For an object, the previous steps tell us exactly what that copy is: a copy of the reference.

That gives a function two possible operations on its parameter, and they have very different reach. The step contains one function for each. Predict original.count and target.count before saving.

Deno.test("parameters receive a copy of the reference", () => {
function reassignParam(o: { count: number }) {
o = { count: 999 };
return o;
}
const original = { count: 1 };
const returned = reassignParam(original);
assertEquals(original.count, 1);
assertEquals(returned.count, 999);

function mutateParam(o: { count: number }) {
o.count = 42;
}
const target = { count: 1 };
mutateParam(target);
assertEquals(target.count, 42);
});
parameters receive a copy of the reference ... ok (0ms)

Follow each function separately.

First reassignParam:

  1. reassignParam(original) copies the reference in original into the parameter o. Two names, one object.
  2. o = { count: 999 } builds a second object and points o at it. That changed which object o reaches, and it changed nothing else.
  3. assertEquals(original.count, 1) passes, because original out in the calling code still holds its own reference, still leading to the first object. Reassigning a parameter is a private act; the caller never hears about it.

Then mutateParam:

  1. mutateParam(target) copies the reference in target into o, the same way.
  2. o.count = 42 never reassigns o. It follows the reference and writes into the object it leads to, the same object target leads to.
  3. assertEquals(target.count, 42) passes, because the write landed in the caller's object.

It is the same model as the first two steps, wearing a function call. And it has a practical edge worth stating plainly. Point it at any function you did not write: if that function takes an object, it can change your object, and its return value will not tell you whether it did. The signature mutateParam(o: { count: number }): void looks harmless, and it rewrote the caller's data.

const locks the name, not the contents

Look back over the steps so far. Every object sat in a const, and every mutation went through anyway. That deserves to be its own claim rather than an accident nobody mentions:

Deno.test("const locks the name, not the contents", () => {
const settings = { volume: 50 };
settings.volume = 80;
assertEquals(settings.volume, 80);
});
const locks the name, not the contents ... ok (0ms)

A direct mutation of a const, and the step passes. To see why, be precise about what const promises.

const fixes the binding, which is the connection between a name and the value it holds.

  1. const settings = { volume: 50 } promises that the name settings will hold this one reference for its whole life.
  2. settings = anythingElse would break that promise, so the program refuses to run it. That is the promise being kept.
  3. settings.volume = 80 never touches the binding. It follows the reference, arrives at the object, and writes into a property. The name still holds the same reference afterward, so as far as const is concerned, nothing it guards has changed.

The keyword is only half misleading: the binding really is constant. What const never promised is anything about the object on the other end of the reference. Holding the contents still takes a different tool, and we pick it up next, in Object.freeze stops mutation at one level.

Object.freeze stops mutation at one level

Object.freeze is that tool. Predict what an assignment to a frozen property does:

Deno.test("Object.freeze stops mutation at one level", () => {
const frozen = Object.freeze({ name: "config", nested: { level: 1 } });
frozen.name = "changed";
});
Check programs/values-and-references.test.ts
TS2540 [ERROR]: Cannot assign to 'name' because it is a read-only property.
frozen.name = "changed";
~~~~
at file:///programs/values-and-references.test.ts:58:12

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

The program never ran. Notice which system is speaking: this is a TypeScript diagnostic, produced during checking, before any JavaScript executed. Object.freeze returns a type with every property marked read-only, so the checker rejects the assignment on paper. That is evidence about TypeScript's model of the code. It says nothing yet about what JavaScript would actually do with this write.

To see JavaScript's own behavior, the assignment has to get past the checker, and as any does that: it tells TypeScript to stop tracking the value's type, so the checker no longer has grounds to object. Then the write really happens at runtime. Module code always runs in strict mode, and in strict mode a write to a frozen property throws a TypeError rather than failing silently. assertThrows states that as a test: it passes when the function it is handed throws the named error, and it fails if no throw happens.

Deno.test("Object.freeze stops mutation at one level", () => {
const frozen = Object.freeze({ name: "config", nested: { level: 1 } });
assertThrows(
() => {
(frozen as any).name = "changed";
},
TypeError,
"read only property",
);
assertEquals(frozen.name, "config");
frozen.nested.level = 2;
assertEquals(frozen.nested.level, 2);
});
Object.freeze stops mutation at one level ... ok (0ms)

So the freeze is runtime-real. JavaScript throws, with or without TypeScript watching, which means this protection holds even against code that never passes through a type checker.

And yet the last two lines went through untouched: frozen.nested.level = 2 succeeded. The reference model explains this.

  1. The value stored at frozen.nested is a reference to a separate object.
  2. Freezing frozen locked frozen's own properties, so that reference can no longer be replaced.
  3. The object the reference leads to is a different object, and nobody froze that one, so frozen.nested.level = 2 writes into it freely.

Object.freeze locks the object you called it on, never the objects reachable from it. That is what "shallow" means here.

A deep freeze, one that locks the whole structure, is something you write yourself by walking every nested object and freezing each one. Most codebases decide that is not worth the trouble and settle for a convention against mutation instead.

spread copies one level

Spread, written { ...cart }, is the usual way to copy an object. Here is precisely what it does: it builds one new object, then walks the properties of cart and assigns each value into the new object, one by one. Each of those assignments follows the same rule as every assignment in this file. A primitive value is copied. An object value contributes a copy of its reference.

Hold that rule while you predict: after pushing into copy.items, is cart.items still ["apple"]?

Deno.test("spread copies one level", () => {
const cart = { name: "cart", items: ["apple"] };
const copy = { ...cart };
copy.name = "wishlist";
assertEquals(cart.name, "cart");
copy.items.push("banana");
assertEquals(cart.items, ["apple"]);
});
Check programs/values-and-references.test.ts
running 8 tests from ./programs/values-and-references.test.ts
assigning a primitive copies the value ... ok (0ms)
assigning an object copies the reference ... ok (0ms)
a string method returns a new string ... ok (0ms)
identity versus contents ... ok (0ms)
parameters receive a copy of the reference ... ok (0ms)
const locks the name, not the contents ... ok (0ms)
Object.freeze stops mutation at one level ... ok (1ms)
spread copies one level ... FAILED (9ms)

ERRORS

spread copies one level => ./programs/values-and-references.test.ts:70:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"apple",
- "banana",
]

FAILURES

spread copies one level => ./programs/values-and-references.test.ts:70:11

FAILED | 7 passed | 1 failed (12ms)

error: Test failed

cart.items picked up "banana" from a push done through the copy. The rule predicts both halves of this result, so walk them:

  1. const copy = { ...cart } builds one new object and assigns each of cart's property values into it, one by one.
  2. cart.name holds a string, a primitive, so its assignment duplicated the string itself. The copy got its own "cart".
  3. copy.name = "wishlist" therefore renamed only the copy, and the first assertion passed.
  4. cart.items holds an array, an object, so its assignment duplicated only the reference. copy.items and cart.items lead to the same array.
  5. copy.items.push("banana") followed that shared reference and landed in the one array, which is why the push is visible through cart.items.

This is why the behavior is called a shallow copy: the top level is genuinely new, and everything one level down is shared. It is also the bug people ship, because the copy looked like a copy, and the array underneath was never copied at all. Object.assign({}, cart) builds its copy the same way and has the identical blind spot.

Correct the prediction to ["apple", "banana"]:

spread copies one level ... ok (0ms)

structuredClone copies all levels

If spread stops at one level, the next question is what goes all the way down. structuredClone does, and it is built into the runtime.

Deno.test("structuredClone copies all levels", () => {
const cart = { name: "cart", items: ["apple"] };
const deepCopy = structuredClone(cart);
deepCopy.items.push("banana");
assertEquals(cart.items, ["apple"]);
assertEquals(structuredClone(new Map([["a", 1]])) instanceof Map, true);
assertEquals(structuredClone(new Date(0)) instanceof Date, true);
});
structuredClone copies all levels ... ok (0ms)

The experiment is the same push that leaked through spread, and this time it stays contained: cart.items is still ["apple"] after the push into deepCopy.items. That can only mean the two names lead to different arrays now. structuredClone did not copy a reference to the inner array; it rebuilt the array itself, and it does that for the whole structure, however deep the nesting goes.

The other two assertions show that it rebuilds with the right types. A cloned Map is still a Map, and a cloned Date is still a Date. Structures that refer to themselves survive too. That is more than a JSON round trip manages, since JSON.stringify turns a Map into {} and cannot handle a cycle at all.

structuredClone copies data, not behavior

The deep copy has a price, and it is worth seeing here, deliberately, before it happens to you inside a failing test at the end of a long day. There are two limits. First, a function anywhere in the structure makes the clone throw. Second, a class instance comes back without its class. The step demonstrates the first with assertThrows, then clones an instance and asks assertEquals whether the clone equals its source:

Deno.test("structuredClone copies data, not behavior", () => {
assertThrows(() => structuredClone({ act: () => 1 }));

class Price {
amount = 1;
}
const price = new Price();
const clone = structuredClone(price);
assertEquals(clone.amount, 1);
assertEquals(clone instanceof Price, false);
assertEquals(price, clone);
});
Check programs/values-and-references.test.ts
running 10 tests from ./programs/values-and-references.test.ts
...
structuredClone copies data, not behavior ... FAILED (15ms)

ERRORS

structuredClone copies data, not behavior => ./programs/values-and-references.test.ts:88:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- Price {
+ {
amount: 1,
}

FAILURES

structuredClone copies data, not behavior => ./programs/values-and-references.test.ts:88:11

FAILED | 9 passed | 1 failed (21ms)

error: Test failed

Read the diff closely, because it is stranger than the earlier ones. amount: 1 appears on both sides. The only difference the diff can point at is the word Price on the actual side and its absence on the expected side.

Here is what that word means. A class instance is two things at once: a bag of data properties, and a hidden link to its class, which is where its methods and its instanceof answer live. structuredClone copies the bag and drops the link. The data survived, which is why clone.amount is 1. The class did not, which is why clone instanceof Price is false and why the clone prints as a plain { instead of Price {. And assertEquals is sensitive to that link even when every field matches, so a clone of a class instance does not deep-equal the instance it was cloned from. The hidden link is a prototype, and the prototypes and inheritance page measures exactly what a clone leaves behind.

The cruel part of meeting this failure in the wild is that the two values print identically almost everywhere else while you stare at the diff. Now that the diff has taught its lesson, record the fact it revealed: replace the last line with assertNotEquals(price, clone);.

structuredClone copies data, not behavior ... ok (0ms)

The function limit and the class limit are the same limit seen twice. structuredClone moves data. Behavior, whether a function value or a class link, does not survive the trip. When a structure must keep its functions or its classes, neither spread nor structuredClone will carry them, and you write that copy yourself.

Map keys compare by identity

A Map stores values under keys, and when the keys are objects, the identity-versus-contents distinction from earlier decides everything. Two lookalike keys go into one Map. Predict map.size, then predict what a third lookalike finds.

Deno.test("Map keys compare by identity", () => {
const key1 = { id: 1 };
const key2 = { id: 1 };
const map = new Map();
map.set(key1, "first");
map.set(key2, "second");
assertEquals(map.size, 2);
assertEquals(map.get(key1), "first");
assertEquals(map.get({ id: 1 }), undefined);
});
Map keys compare by identity ... ok (0ms)

Two lookalikes became two entries, and a fresh lookalike found nothing. The reason is that a Map asks the === question of its keys: same object or not.

  1. key1 and key2 came from two literals, so they are two objects.
  2. map.set(key1, "first") and map.set(key2, "second") therefore filed two separate entries, which is why size is 2.
  3. map.get(key1) receives the very reference the first entry was filed under, so identity matches and the lookup finds "first".
  4. map.get({ id: 1 }) builds a third object on the spot. No entry was ever filed under it, so the lookup matches nothing and produces undefined.

Contents never enter the comparison. Set membership works the same way.

When you want lookalikes to collide, take the decision away from identity: key on a primitive. Build a string or a number from the fields that identify the thing, for example the id itself, and use that as the key. Primitives compare by value, so two lookalike keys become the same key.

a wrapper object is not its primitive

The step a string method returns a new string left a debt. "ada" is a primitive, primitives hold no methods, and "ada".toUpperCase() answered anyway. Here is how: when a method is called on a primitive, JavaScript wraps the primitive in a temporary object for the duration of that one call, lets the object answer the method, and throws the wrapper away. You never see the wrapper, and the primitive is never changed.

Those wrapper types also exist as constructors that you can call yourself, with new String, new Number, and new Boolean. This step is the reason you never should:

Deno.test("a wrapper object is not its primitive", () => {
assertEquals(typeof "oak", "string");
assertEquals(typeof new String("oak"), "object");
const w1 = new String("a");
const w2 = new String("a");
assertEquals(w1 === w2, false);
});
a wrapper object is not its primitive ... ok (0ms)

typeof tells the story.

  1. typeof "oak" is "string": a primitive.
  2. typeof new String("oak") is "object", and this page has already established everything that follows from being an object: objects compare by identity.
  3. w1 and w2 are therefore two objects that happen to wrap the same text, so w1 === w2 is false, even though both wrap "a".

A value that behaves like a string everywhere else and fails the moment something compares it is a bug that hides well.

Keep one distinction in hand. new String(x) builds a wrapper object; never write it. String(x) without new is a conversion: it produces a genuine primitive string, and it is fine. The same split applies to Number and Boolean.

readonly exists only at compile time

TypeScript has its own vocabulary for immutability: readonly on a property or an array type, and as const on a literal. The last claim of this page is about when those exist. Push into a readonly array and save:

Deno.test("readonly exists only at compile time", () => {
const prices: readonly number[] = [1, 2];
prices.push(3);
});
Check programs/values-and-references.test.ts
TS2339 [ERROR]: Property 'push' does not exist on type 'readonly number[]'.
prices.push(3);
~~~~
at file:///programs/values-and-references.test.ts:122:12

error: Type checking failed.

The checker rejects the push before the program runs. This is the same kind of evidence as the first attempt in Object.freeze stops mutation at one level: a diagnostic, produced on paper, by TypeScript. The difference between the two steps is what stands behind the diagnostic. Behind Object.freeze, JavaScript stood ready with a runtime TypeError. Behind readonly, nothing stands, and a cast is enough to prove it. prices as number[] tells the checker to treat the array as mutable, and the checker believes it, because a cast changes only TypeScript's opinion, never the value:

Deno.test("readonly exists only at compile time", () => {
const prices: readonly number[] = [1, 2];
(prices as number[]).push(3);
assertEquals(prices.length, 3);
});
readonly exists only at compile time ... ok (0ms)

The push went through, and prices.length really is 3. The reason is that TypeScript's types are checked and then erased. The running program contains no trace of readonly, so at runtime nothing is protecting anything.

That is not a flaw. Static guarantees are cheaper than runtime ones, and they catch mistakes earlier, while you type instead of while you ship. The point is to keep the two promises distinct. readonly protects you from your own code, the code that goes through the checker. Object.freeze protects a value from any code at all, including code you do not control.

In practice