“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.
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.
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.
Readonly view
Expose a type-level promise that trusted TypeScript callers will not write through this reference.
Frozen value
Freeze the object graph so direct writes fail at runtime and replacement becomes the update path.
Defensive copy
Give an editor an independently owned mutable graph, copying the branches it may change.
Structural sharing
Return new values while reusing unchanged branches, keeping version identity useful to subscribers.
| Policy | Protects against | Produces | Main cost |
|---|---|---|---|
| Readonly view | Checked writes through one reference | Same object identity | Trust remains at runtime |
| Frozen value | Direct writes to the frozen graph | Replacement values | Freeze work and rejected edits |
| Defensive copy | Aliasing across an ownership boundary | Independent mutable graph | Allocation and copy depth |
| Structural sharing | Accidental mutation when updates are disciplined | New root, shared unchanged branches | Update complexity and contract discipline |
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.
Who owns the document?
Keep the document fixed. Change the boundary or the protection policy.
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.
Hold the behavior steady. Change the language.
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
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] } };
} 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
/** 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 };
} // 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
export function saveTitle(document: Document, title: string): Document {
return updateTitle(document, title);
} 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.
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());
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
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.
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.
Readonly view.
Keep the same identity and avoid work when the type boundary is enough.
Defensive copy.
Pay for copied ownership where a mutable draft needs to diverge.
Structural sharing.
Replace changed branches, reuse the rest, and enforce the update discipline.
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?
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 →