← Concepts & practices
Concept Language and runtime models

Copying, identity, and equality

Know what your copy still shares.

You already copy objects before editing them, and compare them to see what changed. Let’s follow an address form whose draft looks like a copy, until a city edit reaches the saved shipment and the unsaved-changes check says nothing changed.

TypeScriptGo One shipment draft, two implementations.

01 / The idea

A spread copy is a fair start for a draft.

You’re building the address form for a shipping dashboard. Opening it makes a draft with a spread, { ...saved }, so the form can change things without touching the saved shipment. Change the recipient and the saved recipient stays Mina Patel. For a flat record, that really is a copy.

Read the first draftTypeScript · the version this lesson starts from
shipment.ts
// The first draft: a new shipment object, so editing the recipient leaves the saved one alone.
export function shallowDraft(saved: Shipment): Shipment {
	return { ...saved };
}

Go’s version copies the shipment struct, whose address field is a pointer. Both languages meet again at independentDraft in section 02.

Then someone changes the city. The saved shipment says Salem too. The unsaved-changes check compares the draft with the saved shipment, finds them equal, and lets the form close without asking. Cancel drops the draft, and the saved city stays changed.

A spread copies one object. The address is a separate object that both copies point at, so a write to it is shared. Identity asks whether two names reach the same object; equality asks whether two objects match by a rule you choose. And a change can only be detected against something that didn’t change. React’s documentation says the first part bluntly: “When the code executes, there is no such thing as a ‘nested’ object.”

Section 05 builds an address editor with a real unsaved-changes warning, in React and Svelte.

02 / See the shape

Copy what you edit, and name what “same” means.

The basic form is the copy boundary: copy every object the form can edit. In the wild names three kinds of sameness and checks for unsaved changes against a baseline. At the call site edits the city in each kind of draft, then loads the same shipment twice.

Both languages print the same three lines.

The copy boundary. Copy every object the form can edit: here, the shipment and its address.

TypeScriptReading
shipment.ts
// Copy every object the form can edit. For this shipment, that's the shipment and its address.
export function independentDraft(saved: Shipment): Shipment {
	return { ...saved, address: { ...saved.address } };
}
GoAlongside
shipment.go
// IndependentDraft copies every struct the form can edit: the shipment and its address.
func IndependentDraft(saved *Shipment) *Shipment {
	draft := *saved
	address := *saved.Address
	draft.Address = &address
	return &draft
}
Reading the TypeScriptSpreads, ===, and structuredClone

{ ...saved } copies the shipment’s own properties, and the address property is copied as a reference. The second spread in independentDraft makes a new address.

=== on objects asks whether they’re the same object. MDN is explicit that for “distinct objects” with “the same structure”, every equality operator evaluates to false. structuredClone makes a deep clone, has worked across browsers since March 2022, and throws a DataCloneError for anything it can’t serialize.

Reading the GoPointers compare by identity, structs by field

Pointer values are equal when they point to the same variable. Struct values are equal when their fields are, so *a == *b compares the two address pointers and is false for an independent draft with matching details.

SameDetails dereferences the address and compares its strings. A copied struct copies its pointer field, which is why ShallowDraft shares the address.

03 / Follow the draft

Watch what each draft shares.

Five steps, each running the lesson’s functions on real objects. Names point at lettered objects, and each shipment card shows which address it points at. Letters mark identity in the order objects appear, not memory addresses. Before each step, guess what saved says.

In Try it, open a draft either way, edit it, cancel, and reopen.

Copying and equality

What a draft copies, and what it still shares.

A shallow draft

Line 1 of 2

const draft = shallowDraft(saved);

A shallow draft. After draft.recipient = 'Mina Rao'; saved reaches A, draft reaches C. A (id shipment-42, recipient Mina Patel, address → B); B (street 18 Cedar St, city Portland); C (id shipment-42, recipient Mina Rao, address → B). sameObject(saved, draft) is false. The spread made a new shipment, C, so the recipient edit stayed in the draft. Both shipments still point at one address, B.

01/ 05
Open a shallow draft and edit the recipient

A shallow draft.

The form opens a draft with a spread and changes the recipient. The saved recipient stays Mina Patel.

Reduced motion: choose a scene to see its completed state.

Read this scene

The form opens a draft with a spread and changes the recipient. The saved recipient stays Mina Patel.

A shallow draft. After draft.recipient = 'Mina Rao'; saved reaches A, draft reaches C. A (id shipment-42, recipient Mina Patel, address → B); B (street 18 Cedar St, city Portland); C (id shipment-42, recipient Mina Rao, address → B). sameObject(saved, draft) is false. The spread made a new shipment, C, so the recipient edit stayed in the draft. Both shipments still point at one address, B.

Watch restarts when you return. Step through keeps your selected step. Try it starts from a freshly loaded shipment each time you open it.

What a deliberate copy buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Cancel that really cancels
With the address copied, dropping the draft leaves the saved city as it was.
Change detection that works
hasUnsavedChanges compares against a baseline nobody edited, so it sees the Salem edit.
Comparisons that say what they mean
sameObject, sameShipment, and sameDetails answer different questions, and the code says which one it asked.
Records found after a reload
Looking a shipment up by ID finds it in a new response. includes, which compares objects, doesn’t.
Copies sized to the job
Only the objects the form edits get copied. A read-only preview can keep sharing.

The review words are shallow copy, deep copy, identity versus equality, and entity identity for the ID that says which record something is. Section 08 covers what they cost.

04 / Try a decision

An unsaved-changes check that never fires.

The form opens its draft with a spread and asks for confirmation on close when hasUnsavedChanges(saved, draft) is true. The code is in unsaved.ts, and the lesson’s tests pin what happens.

The user changes the city to Salem and closes the form. What happens?

The form opens its draft with { ...saved }. Closing asks for confirmation only when hasUnsavedChanges(saved, draft) is true.

05 / Give it a real job

Keep a baseline, edit a copy, compare the two.

In the real dashboard, opening the address editor makes two deep copies of the saved shipment: a draft the form edits and a baseline nobody touches. Save sends the draft, Cancel throws it away, and the saved shipment only changes when a save succeeds.

Saved shipment

Untouched while editing

It changes only after a successful save.

Baseline

What changes are measured against

A copy made when the form opened.

Draft

What the form edits

A deep copy, so no write reaches the saved shipment.

The example leaves out saving, validation, and someone else editing the same shipment at the same time. Noticing that needs a version from the server, not a local baseline.

Build UIs?Every nested state update you write copies part of an object, and one day an unsaved-changes warning depends on getting that right.

Where it already is in your components

React’s documentation spreads both levels to change a nested field: a new artwork object, then a new person that points at it. The textbook React panel does the same for the address, so the previous state and the new one don’t share it.

Svelte’s $state makes nested objects reactive, so the textbook Svelte panel binds straight to shipment.address.city. It takes its baseline first with $state.snapshot, which gives “a static snapshot of a deeply reactive $state proxy”.

When you have to own it

Now it’s the address editor. It makes its baseline and draft with structuredClone, so nothing the form does can reach the saved shipment, and isDirty compares the draft with the baseline.

While there are unsaved changes it listens for beforeunload. MDN recommends adding that listener only when there’s something to lose, notes that the dialog shows only “a generic browser-specified string”, and says browsers show it only after the person has interacted with the page.

address-draft.ts
export type Address = { street: string; city: string };
export type Shipment = { id: string; recipient: string; address: Address };

// Two deep copies of the saved shipment: a draft the form edits, and a baseline nobody touches.
export function openEditor(saved: Shipment): { baseline: Shipment; draft: Shipment } {
	return { baseline: structuredClone(saved), draft: structuredClone(saved) };
}

// Unsaved changes: any editable detail that differs from the baseline. The ID isn't editable.
export function isDirty(baseline: Shipment, draft: Shipment): boolean {
	return (
		draft.recipient !== baseline.recipient ||
		draft.address.street !== baseline.address.street ||
		draft.address.city !== baseline.address.city
	);
}

A city field on a nested shipment: React copies both levels on each change, and Svelte binds to $state after taking a snapshot baseline.

ReactAlready in your code
ShipmentAddress.tsx
import { useState } from 'react';

type Shipment = { id: string; recipient: string; address: { street: string; city: string } };

export function ShipmentAddress() {
	const [shipment, setShipment] = useState<Shipment>({
		id: 'shipment-42',
		recipient: 'Mina Patel',
		address: { street: '18 Cedar St', city: 'Portland' }
	});

	// The address is its own object. Copy it as well as the shipment, or the old state
	// and the new one would share it.
	function changeCity(city: string) {
		setShipment({ ...shipment, address: { ...shipment.address, city } });
	}

	return (
		<label>
			City
			<input value={shipment.address.city} onChange={(event) => changeCity(event.target.value)} />
		</label>
	);
}

06 / Recognize it elsewhere

Anywhere a copy or a comparison crosses an object.

You’ve met all of these. For each one, find what’s copied and what’s still shared.

Familiar copies and comparisons, and what they share
Where you’ve seen itWhat’s copiedWhat’s still shared
{ ...shipment }The shipment’s own propertiesEvery object they point at
structuredClone(shipment)The whole shipment, address includedNothing
Assigning a Go structEvery fieldWhatever a pointer, slice, or map field reaches
A nested React state updateThe objects on the path to the changeEverything else, reused
a === b on objectsNothingIt asks whether they’re the same object

Before trusting a copy, follow every property that points at another object. Before trusting a comparison, name the question it answers.

07 / Already in your toolbox

Your platform already documents where copies stop.

Three places to look. For each one, find what’s copied and what’s compared.

React · Updating a nested object

The two-level spread, and a short section, “Objects are not really nested”, that draws the pointers between the objects explicitly.

Read the section ↗

MDN · Equality comparisons and sameness

Why two objects with the same structure are never equal under ===, ==, or Object.is, and how Object.is differs from === for NaN and signed zeroes.

Read the guide ↗

MDN · structuredClone

The deep copy built into the platform, the types it supports, and the DataCloneError it throws for anything else.

Read the reference ↗
A useful counterexample: a read-only previewWhen sharing everything is right

A shipment preview that never writes can reach the saved shipment directly. Copying it would only mean keeping two objects in step.

08 / The parts to watch

Copies and comparisons each answer less than they seem to.

These are the places it still goes wrong.

A spread copies one level

{ ...saved } gives you a new shipment and the same address. Copy each object the code will write to.

Equality needs an unchanged baseline

Once the saved object has changed through a shared reference, no comparison can find the edit. Keep a baseline from before editing started.

Same ID, different details

sameShipment says which record; it doesn’t say the details match. A form that compares IDs never sees an edit.

Exact comparison is a choice

Portland and portland differ here. If they shouldn’t, normalize on purpose, in one place.

structuredClone copies everything

It copies parts nothing edits as well as the parts that are, and it throws on values it can’t serialize. For a small plain record that’s fine; for a large or unusual one, copy what you edit.

Go’s == on structs compares pointer fields

*a == *b is false for two shipments with identical details and separate addresses. Compare the fields you mean.

09 / Make the call

What would you have to change tomorrow?

Give both drafts a plausible change and follow the work it creates.

How a change affects a shallow draft and a deep draft with a baseline
The changeShallow draftDeep draft with a baseline
The form only edits the recipientIsolated, and simpler.Also isolated, with an extra copy of the address.
The form edits the cityThe saved city changes.Only the draft changes.
Warn about unsaved changesMisses edits to the address.Compares against the baseline.
CancelDrops the draft, keeps any address write.Nothing to undo.
The shipment gains a list of delivery notesShared too.structuredClone copies it; a hand-written copy needs updating.

Reach for a deep copy and a baseline whenever a form edits nested data and must be cancelable. An address inside a shipment is the moment.

Keep the spread when the record is flat, or when nothing writes to it.

The question I’d leave beside the code is: which objects can this code write to, and who else points at them?

10 / Take the idea with you

Explain the Cancel bug without saying “shallow copy.”

“The draft was a new shipment, but it pointed at the same address as the saved one, so changing the city changed both. Copying the address too fixed it.” In a review, the words are shallow copy, deep copy, identity, and equality.

Before moving on, jot down why the city edit reached the saved shipment, why the unsaved-changes check stayed quiet, and one object in your own code that a copy still shares.

Connections to follow nextRelated lessons