← Concepts & practices
Choice Data modeling and type design

Immutability

What should happen when somebody tries to write?

A published document can be read by a preview, handed to an integration, edited in a form, or stored as one version among many. A readonly type, a frozen object, a defensive copy, and a structurally shared update each answer a different part of that problem.

The judgment to keep

Choose the boundary and ownership policy first, then decide whether runtime enforcement or shared identity is worth its cost.

TypeScriptGo Still, safe, owned, or shared?
Start with one document

“Immutable” hides several decisions.

A release-notes document has a title, body, and nested tags. A preview should not edit it. An integration may be outside the code owner’s control. A form needs an editable draft. A state store may publish a new version while keeping most of a large tree unchanged.

Those callers need different guarantees. readonly changes what TypeScript permits at a checked call site. Object.freeze adds a runtime write barrier. Copying changes ownership. Structural sharing changes how a new value is produced.

Our shared contractKeep published state safe while callers do their jobs.

Hold the document’s meaning fixed while changing the protection and update policy.

Published value
Readers should observe a stable document, not an accidental plugin edit.
Editable draft
A form may mutate its own draft without mutating the published value.
New version
An update returns a new root and may reuse branches that did not change.
Put the choices on the same table

Four ways to keep a value still.

These alternatives can combine. A store can structurally share branches and freeze the published result. A function can return a readonly view over an owned value. The fair comparison asks where writes are prevented, where data is copied, and where the next value comes from.

A

Readonly view

Expose a type-level promise that trusted TypeScript callers will not write through this reference.

No runtime cost or isolation; an unchecked writer can still mutate the shared object.
B

Frozen value

Freeze the object graph so direct writes fail at runtime and replacement becomes the update path.

Freeze work and write errors are real costs; shallow freeze does not protect nested objects.
C

Defensive copy

Give an editor an independently owned mutable graph, copying the branches it may change.

Allocation and copy depth grow with the mutable boundary.
D

Structural sharing

Return new values while reusing unchanged branches, keeping version identity useful to subscribers.

Update helpers need discipline; sharing is not a runtime shield for direct mutation.
What does each policy actually guarantee?
PolicyProtects againstProducesMain cost
Readonly viewChecked writes through one referenceSame object identityTrust remains at runtime
Frozen valueDirect writes to the frozen graphReplacement valuesFreeze work and rejected edits
Defensive copyAliasing across an ownership boundaryIndependent mutable graphAllocation and copy depth
Structural sharingAccidental mutation when updates are disciplinedNew root, shared unchanged branchesUpdate complexity and contract discipline
Keep the document fixed

Now change who touches it.

A read-only preview, a third-party plugin, an editable form, and a versioned state store put different pressure on the same value. Run each boundary with each policy. The probe runs the TypeScript in the complete file below, including a deliberate runtime bypass of its readonly type.

Constraint lab

Who owns the document?

Keep the document fixed. Change the boundary or the protection policy.

Boundary
Protection / update policy
Current boundary Third-party plugin

An integration receives a reference and tries to add a tag.

Choose a boundary and protection policy, then run the boundary.

Why freeze deeply, not shallowly?Nested tags are another object

The lab’s freeze is recursive for a reason. Freezing only the document root prevents replacing title through that object, but a separate mutable metadata object or its tags array can still change. A recursive freeze covers this small example. For a larger graph, measure the runtime cost and document which parts are actually protected.

Separate the two comparisons

Hold the behavior steady. Change the language.

Read the document policies in TypeScript and Go.

The examples preserve the same document contract. The browser lab runs TypeScript; the panes show how ownership and updates differ across languages.

Expose a boundary

TypeScriptDocument boundaries
document.ts · boundaries
export function asReadOnly(document: Document): ReadonlyDocument {
	return document;
}

export function freezeDocument(document: Document): ReadonlyDocument {
	return deepFreeze(document) as ReadonlyDocument;
}

export function copyDocument(document: Document): Document {
	return {
		title: document.title,
		body: document.body,
		metadata: { tags: [...document.metadata.tags] }
	};
}

export function updateTitle(document: Document, title: string): Document {
	return { ...document, title };
}

export function updateTags(document: Document, tags: string[]): Document {
	return { ...document, metadata: { ...document.metadata, tags: [...tags] } };
}
GoDocument boundaries
document.go · values
func NewDocument() Document {
	return Document{Title: "Release notes", Body: "A small, useful update.", Metadata: Metadata{Tags: []string{"product"}}}
}

func CopyDocument(document Document) Document {
	tags := append([]string(nil), document.Metadata.Tags...)
	document.Metadata.Tags = tags
	return document
}

func UpdateTitle(document Document, title string) Document {
	document.Title = title
	return document
}

func UpdateTags(document Document, tags []string) Document {
	document.Metadata.Tags = append([]string(nil), tags...)
	return document
}

TypeScript can describe a readonly nested view, while Go’s value copy still needs explicit slice ownership when tags are mutable.

Copy or share on change

TypeScriptCopy and update
document.ts · copy/update
/** A form edits its own copy; the published document never sees the draft. */
export function draftEdit(document: Document): { published: Document; draft: Document } {
	const draft = copyDocument(document);
	draft.title = 'Edited draft';
	return { published: document, draft };
}

/** A structural update replaces the changed branch and shares the unchanged one. */
export function sharedAfterTitleUpdate(document: Document, title: string) {
	const next = updateTitle(document, title);
	return { next, metadataShared: next.metadata === document.metadata };
}
GoCopy and update
document.go · copy/update
// TryDraftEdit edits a copy; the published document never sees the draft.
func TryDraftEdit(document Document) (Document, Document) {
	draft := CopyDocument(document)
	draft.Title = "Edited draft"
	return document, draft
}

// SharedAfterTitleUpdate reports whether the updated value still shares the
// tags backing array. With no tags there is no backing array to share.
func SharedAfterTitleUpdate(document Document, title string) (Document, bool) {
	next := UpdateTitle(document, title)
	shared := len(document.Metadata.Tags) > 0 && &next.Metadata.Tags[0] == &document.Metadata.Tags[0]
	return next, shared
}

The changed branch is copied; unchanged data can remain shared only when the update path preserves the contract.

See the update call siteReturn the next value
TypeScriptUpdate boundary
document.ts · update boundary
export function saveTitle(document: Document, title: string): Document {
	return updateTitle(document, title);
}
GoUpdate boundary
document.go · update boundary
func SaveTitle(document Document, title string) Document {
	return UpdateTitle(document, title)
}

The caller receives a new value instead of asking a published object to change in place.

Copy the complete examplesStandard library only

These files are complete and copyable. The browser lab is a focused mutation/update probe, not an arbitrary-code REPL.

TypeScriptComplete document example
document.ts
export type Document = {
	title: string;
	body: string;
	metadata: { tags: string[] };
};

export type ReadonlyDocument = Readonly<{
	title: string;
	body: string;
	metadata: Readonly<{ tags: readonly string[] }>;
}>;

export type Protection = 'readonly' | 'freeze' | 'copy' | 'structural';

export type MutationResult = {
	status: 'mutated' | 'rejected';
	originalChanged: boolean;
	message: string;
};

export function createDocument(): Document {
	return {
		title: 'Release notes',
		body: 'A small, useful update.',
		metadata: { tags: ['product'] }
	};
}

export function asReadOnly(document: Document): ReadonlyDocument {
	return document;
}

export function freezeDocument(document: Document): ReadonlyDocument {
	return deepFreeze(document) as ReadonlyDocument;
}

export function copyDocument(document: Document): Document {
	return {
		title: document.title,
		body: document.body,
		metadata: { tags: [...document.metadata.tags] }
	};
}

export function updateTitle(document: Document, title: string): Document {
	return { ...document, title };
}

export function updateTags(document: Document, tags: string[]): Document {
	return { ...document, metadata: { ...document.metadata, tags: [...tags] } };
}

/** A form edits its own copy; the published document never sees the draft. */
export function draftEdit(document: Document): { published: Document; draft: Document } {
	const draft = copyDocument(document);
	draft.title = 'Edited draft';
	return { published: document, draft };
}

/** A structural update replaces the changed branch and shares the unchanged one. */
export function sharedAfterTitleUpdate(document: Document, title: string) {
	const next = updateTitle(document, title);
	return { next, metadataShared: next.metadata === document.metadata };
}

// The lab's probes: they deliberately bypass readonly at run time to show what each policy
// actually stops. They are a harness for the browser lab, not a pattern to copy.
function deepFreeze<T>(value: T): T {
	if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value;
	for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
	return Object.freeze(value);
}

export function tryDirectMutation(document: Document, protection: Protection): MutationResult {
	const exposed =
		protection === 'readonly'
			? asReadOnly(document)
			: protection === 'freeze'
				? freezeDocument(document)
				: protection === 'copy'
					? copyDocument(document)
					: document;

	try {
		(exposed as Document).metadata.tags.push('plugin-edit');
		return {
			status: 'mutated',
			originalChanged: document.metadata.tags.includes('plugin-edit'),
			message:
				protection === 'copy'
					? 'The plugin changed its private copy; the published document stayed unchanged.'
					: protection === 'structural'
						? 'Structural sharing is an update strategy, not a runtime shield for a rogue writer.'
						: 'Readonly is a TypeScript promise; JavaScript can still mutate the shared object at runtime.'
		};
	} catch {
		return {
			status: 'rejected',
			originalChanged: false,
			message:
				'The frozen value rejected the write at runtime. The caller must create a new value to edit.'
		};
	}
}

export function editDraft(document: Document, protection: Protection): MutationResult {
	if (protection === 'copy') {
		const draft = copyDocument(document);
		draft.title = 'Edited draft';
		return {
			status: 'mutated',
			originalChanged: document.title === 'Edited draft',
			message:
				'The form owns an independent draft and can edit it without changing the published value.'
		};
	}
	if (protection === 'structural') {
		const next = updateTitle(document, 'Edited draft');
		return {
			status: 'mutated',
			originalChanged: document.title === 'Edited draft',
			message: `The edit returns a new root (${next !== document ? 'new identity' : 'same identity'}); unchanged metadata can remain shared.`
		};
	}
	const exposed = protection === 'readonly' ? asReadOnly(document) : freezeDocument(document);
	try {
		(exposed as Document).title = 'Edited draft';
		return {
			status: 'mutated',
			originalChanged: document.title === 'Edited draft',
			message:
				'The draft edit reached the published object; the boundary did not provide independent ownership.'
		};
	} catch {
		return {
			status: 'rejected',
			originalChanged: false,
			message:
				'The frozen value rejected the edit. The editor needs a replacement draft or update function.'
		};
	}
}

export function saveTitle(document: Document, title: string): Document {
	return updateTitle(document, title);
}

export function example() {
	const published = createDocument();
	const next = saveTitle(published, 'Launch notes');
	return { published, next, metadataShared: published.metadata === next.metadata };
}

console.log(example());
GoComplete document example
document.go
package main

import "fmt"

type Document struct {
	Title    string
	Body     string
	Metadata Metadata
}

type Metadata struct {
	Tags []string
}

func NewDocument() Document {
	return Document{Title: "Release notes", Body: "A small, useful update.", Metadata: Metadata{Tags: []string{"product"}}}
}

func CopyDocument(document Document) Document {
	tags := append([]string(nil), document.Metadata.Tags...)
	document.Metadata.Tags = tags
	return document
}

func UpdateTitle(document Document, title string) Document {
	document.Title = title
	return document
}

func UpdateTags(document Document, tags []string) Document {
	document.Metadata.Tags = append([]string(nil), tags...)
	return document
}


// TryDraftEdit edits a copy; the published document never sees the draft.
func TryDraftEdit(document Document) (Document, Document) {
	draft := CopyDocument(document)
	draft.Title = "Edited draft"
	return document, draft
}

// SharedAfterTitleUpdate reports whether the updated value still shares the
// tags backing array. With no tags there is no backing array to share.
func SharedAfterTitleUpdate(document Document, title string) (Document, bool) {
	next := UpdateTitle(document, title)
	shared := len(document.Metadata.Tags) > 0 && &next.Metadata.Tags[0] == &document.Metadata.Tags[0]
	return next, shared
}


func SaveTitle(document Document, title string) Document {
	return UpdateTitle(document, title)
}


func main() {
	published := NewDocument()
	next := SaveTitle(published, "Launch notes")
	fmt.Printf("published=%q next=%q tags=%v\n", published.Title, next.Title, next.Metadata.Tags)
}

TypeScriptnode --experimental-strip-types document.ts

Gogo run document.go

Set ownership at the boundary

Make the writer’s responsibility explicit.

A component that edits a form should own a draft. A preview that only reads does not need to copy a large document on every render. An integration that may mutate data you publish needs either a runtime guard or an isolated copy, depending on whether rejection or continued editing is the desired behavior.

A state store has a different job: it needs a repeatable update rule. Structural sharing makes identity changes useful to subscribers, but it assumes writers use the update helpers rather than mutating an old snapshot directly. Freezing can complement that rule; it does not replace it.

Build UIs?See where this shows up in your components.

A UI draft is not the published state.

A form can use a mutable local draft for responsive editing, then submit or derive a replacement value. A read-only preview can keep the published reference. The framework’s reactivity mechanism may proxy or snapshot values, but that does not settle ownership for a collaborator or plugin.

Make a conditional recommendation

Choose the smallest guarantee that is real.

Start with a readonly view for a trusted read-only TypeScript boundary. Add runtime freezing when accidental writes must fail and the graph is small enough to pay for it. Copy when the caller needs an independent mutable draft. Use structural sharing when a versioned state tree needs cheap unchanged branches and all writers follow immutable update functions.

Trusted reader

Readonly view.

Keep the same identity and avoid work when the type boundary is enough.

Independent editor

Defensive copy.

Pay for copied ownership where a mutable draft needs to diverge.

Versioned tree

Structural sharing.

Replace changed branches, reuse the rest, and enforce the update discipline.

Decision practice

A state store publishes large nested snapshots after each edit.

Most branches are unchanged, subscribers compare identity to skip work, and no consumer should mutate a published snapshot. Which policy best fits the update path?

Leave yourself a useful note

Record what “immutable” means here.

“This state is immutable” leaves the writer guessing. Record whether the boundary is type-only, runtime-enforced, independently owned, or updated through shared branches.

Why
Published documents should not change through an accidental alias.
What
Readers see a readonly view; editors copy; versioned updates share unchanged branches.
Constraint
Plugins may be unchecked, drafts must be mutable, and subscribers use identity.
Fallback
Where freezing or deep copies cost too much, keep the readonly view and copy only at the boundary a writer crosses.
Reconsider when
The graph grows, a new writer crosses the boundary, or allocation and identity costs change.

A decision note to adapt to your own state boundary. Nothing here is saved to an account.

Explore more concepts & practices →