Skip to content
Commitwise
Menu
Programming

Value types vs reference types

Know when assigning a variable copies the data and when it shares it, with structs, classes, data classes, records and objects in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You open a “preview” screen that lets the user try a bigger font. They tap Cancel. And yet the whole app now uses the bigger font. Nobody saved anything. The preview just changed the same object the rest of the app was using.

Whether b = a makes a copy or a second name for the same thing is one of the most important questions in programming, and every language answers it a little differently.

New to some of these? Open what you need, or skip ahead.

Basics:What are variables and types?

A variable is a named box that holds a value: let age = 30 (Swift), val age = 30 (Kotlin), int age = 30; (Java), const age = 30 (TypeScript). The type says what kind of value fits in the box: Int / int for whole numbers, Double for decimals, String for text, Bool / Boolean for true or false.

  • let (Swift), val (Kotlin) and final (Java) mean the variable can’t be pointed at another value later. var means it can.
  • Swift, Kotlin and TypeScript can often infer the type from the value, so you don’t have to write it. Java needs it written (or var for local variables). TypeScript’s const is like let / val, and its let is like var.
Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, Swift’s let and structs, and TypeScript’s readonly are the usual tools.

Basics:What is boxing (int vs Integer)?

Java has fast primitive types like int, double and boolean, which aren’t objects. Collections and generics can only hold objects, so Java wraps primitives in objects: int → Integer, double → Double. That wrapping is called boxing; reading the number back out is unboxing.

Two things to know: an Integer can be null (and unboxing null crashes), and comparing two Integers with == compares the objects, not the numbers. Kotlin hides most of this (its Int becomes int or Integer as needed), and Swift has no boxing at all for its number types.

Part Not started

Copy or share?

Think of a document you want a colleague to review.

  • You can print a copy and hand it over. They can scribble all over it; your original is untouched. That’s a value type.
  • You can share a link to the online document. There’s still one document, now with two people who can edit it. Whatever they change, you see. That’s a reference type.

In code, a variable either holds the data itself (the printed copy) or a reference to data that lives somewhere else (the link). Assigning or passing it to a function copies whatever the variable holds: the data, or the link.

A value typeValue typeA type whose values are copied on assignment, so each variable has its own independent data.Open in glossary → has copy semantics: assignment, parameter passing and capture produce an independent value, and identity is meaningless (two equal values are interchangeable). A reference typeReference typeA type whose variables hold a reference to a shared object, so assignment shares the object instead of copying it.Open in glossary → has identity: variables hold references, and mutation through one is visible through all.

Swift makes value types first-class (struct, enum, tuples, and the standard collections, which use copy-on-writeCopy-on-writeSharing data between copies until one of them is changed, and only then making a real copy.Open in glossary →). On the JVM, the only value types are the eight primitives: Kotlin data class and Java record are reference types with value-based equals, which is not the same thing. The Detailed version covers where that difference bites: shallow copies, accidental quadratic copying, and value types across threads.

struct UserValue { var name: String }
final class UserObject { var name: String; init(name: String) { self.name = name } }

var a = UserValue(name: "Ana")
var b = a                 // copies the data
b.name = "Marko"
print(a.name)             // Ana

let c = UserObject(name: "Ana")
let d = c                 // copies the reference
d.name = "Marko"
print(c.name)             // Marko
data class User(var name: String)

val a = User("Ana")
val b = a                 // copies the reference: data classes are still objects
b.name = "Marko"
println(a.name)           // Marko

val c = User("Ana")
val d = c.copy()          // an explicit copy
d.name = "Marko"
println(c.name)           // Ana
class User {
    String name;
    User(String name) { this.name = name; }
}

User a = new User("Ana");
User b = a;               // copies the reference
b.name = "Marko";
System.out.println(a.name); // Marko

int x = 1;
int y = x;                // primitives are values
y = 2;
System.out.println(x);    // 1
interface User { name: string }

const a: User = { name: "Ana" };
const b = a;              // copies the reference
b.name = "Marko";
console.log(a.name);      // Marko

const c: User = { name: "Ana" };
const d = { ...c };       // an explicit (shallow) copy
d.name = "Marko";
console.log(c.name);      // Ana

let x = 1;
let y = x;                // numbers, strings and booleans are values
y = 2;
console.log(x);           // 1

The rule of thumb for each language:

  • Swift: struct and enum are values, class is a reference. Most Swift types you use every day are structs: String, Int, Array, Dictionary, and SwiftUI views.
  • Kotlin: everything is a reference, except numbers, Char and Boolean, which behave like values. A data class is still a reference; it just gives you copy() to make a new one.
  • Java: the eight primitives (int, double, boolean…) are values. Everything else, including String, arrays and records, is a reference.
  • TypeScript: primitives (number, string, boolean, bigint…) are values. Objects, arrays, Maps and class instances are references; { ...obj } and [...arr] make a copy.

Swift note

In Swift, the choice is yours: model data as a struct by default, and reach for a class when you need shared identity (one object that several parts of the app observe or change), inheritance, or Objective-C interop.

Java note

Java’s String is a reference type, but it’s immutable: no method changes it, so sharing it is safe. Immutability is how JVM code gets value-like behavior from reference types.

TypeScript note

TypeScript has no way to declare your own value types: every object is shared by reference. Value-like behavior comes from treating objects as immutable (readonly properties) and creating a new object for every change.

Deep dives

Swift

Deep diveCopy-on-write: why big Swift values stay cheapInterview favorite

Array, Dictionary, Set and String are structs, but their elements live in a heap buffer. Copying the struct copies only a reference to that buffer, so let b = a is O(1). The real copy happens on the first mutation of a shared buffer: the mutating method checks isKnownUniquelyReferenced and duplicates the storage only if someone else still points to it.

That’s why passing arrays around is cheap, and why a mutation inside a loop can suddenly cost O(n) if another variable still holds the old array. Your own structs that wrap a large class get the same behavior only if you implement the uniqueness check yourself.

Kotlin

Deep diveValue classes: a wrapper without the allocationInterview favorite

@JvmInline value class UserId(val value: String) gives you a distinct type (you can’t pass a UserId where an OrderId is expected) that compiles down to the wrapped String in most places: no extra object, no extra allocation.

It’s boxed when used where the JVM needs an object: as a generic type argument, as a nullable type, or through an interface. Equality is by value, and === isn’t allowed on value classes. Separately, Project Valhalla is working on true value types for the JVM itself, which would remove much of that boxing.

Java

Deep diveWhy 127 == 127 but 128 != 128Interview favorite

Autoboxing uses Integer.valueOf, which returns cached objects for values from -128 to 127. So Integer a = 127, b = 127; gives two references to the same object and a == b is true, while with 128 each valueOf creates a new object and == is false.

Interviewers love it because code that compares boxed numbers with == passes every test with small ids and fails in production. Compare boxed values with equals (or Objects.equals), and keep == for primitives and identity checks.

TypeScript

Deep divestructuredClone vs JSON round-tripsInterview favorite

JSON.parse(JSON.stringify(x)) was the classic deep copy, and it’s lossy: Dates become strings, undefined properties and functions disappear, Map and Set become {}, and cycles throw.

structuredClone(x) (in browsers and Node 17+) copies Date, Map, Set, typed arrays and cyclic references correctly. It still can’t copy functions, and class instances come back as plain objects without their prototype, so methods are gone. For immutable updates of plain data, spread copies ({ ...a, b: { ...a.b } }) are usually enough and faster.

SwiftKotlinJavaTypeScript
Value typesstruct, enum, tuples, String, collections (copy-on-write)primitives only (Int, Double, Boolean, Char…)the 8 primitivesprimitives only (number, string, boolean, bigint…)
Value-like by immutabilitylet structdata class with val + read-only collections, @JvmInline value classrecord + List.copyOfreadonly / as const objects, replaced on change

Swift

Value semantics are about copying and identity, not memory layout: structs can live on the heap (in an existential, captured by an escaping closure) and still behave as values. Choose a class when you need shared identity, inheritance or Objective-C interop.

Kotlin, Java

On the JVM the only value types are the primitives: data class and record are reference types with value-based equals, which is not the same thing. Value-like behavior comes from immutability: nobody can change a shared object.

TypeScript

Only primitives are values. Objects, arrays, Maps and class instances are always shared by reference, so value-like behavior comes from immutability by convention: readonly types and creating a new object on every change.

Check this part

  1. var b = a, where a is a struct. You change b.name. What is a.name?val b = a, where a is a data class with a var name. You change b.name. What is a.name?User b = a; (a class). You change b.name. What is a.name?const b = a, where a is an object. You change b.name. What is a.name?

  2. Which of these is a value type in Swift?Which of these behaves like a value in Kotlin?Which of these is a value in Java?Which of these is a value in TypeScript?

  3. What are Swift's String, Array and Dictionary?Is a Kotlin data class a value type?Is a Java record a value type?Does TypeScript have value types besides primitives?

  4. When should a Swift model be a class rather than a struct?How does Kotlin code get value-like behavior from reference types?How does Java code get value-like behavior from reference types?How does TypeScript code get value-like behavior from objects?

A value type copies its data on assignment; a reference type copies a link to the same object.

Part Not started

Sharing by accident

Back to the font preview. Here’s a simplified version of the code. Which line is the problem?

Spot the bugA preview that isn't one

Tap the line with the bug.

final class Settings {
    var fontSize = 16
}

func previewLarge(_ settings: Settings) -> Settings {
    let draft = settings
    draft.fontSize = 22
    return draft
}
class Settings(var fontSize: Int = 16)

fun previewLarge(settings: Settings): Settings {
    val draft = settings
    draft.fontSize = 22
    return draft
}
class Settings {
    int fontSize = 16;
}

static Settings previewLarge(Settings settings) {
    Settings draft = settings;
    draft.fontSize = 22;
    return draft;
}
interface Settings {
  fontSize: number;
}

function previewLarge(settings: Settings): Settings {
  const draft = settings;
  draft.fontSize = 22;
  return draft;
}

Practice: undo with snapshots

Values make undo easy: keep a list of past states, and since each one is an independent copy, editing the current state can’t change history. Here’s a drawing app’s undo, step by step:

  1. The canvas state is a value: a list of shapes and a color.
  2. Before every change, push the current state onto a history list.
  3. Undo pops the last state and makes it current again.
struct Canvas {
    var shapes: [String] = []
    var color = "black"
}

struct Editor {
    private(set) var canvas = Canvas()
    private var history: [Canvas] = []

    mutating func apply(_ change: (inout Canvas) -> Void) {
        history.append(canvas)          // a copy of the current state
        change(&canvas)
    }

    mutating func undo() {
        if let previous = history.popLast() { canvas = previous }
    }
}

var editor = Editor()
editor.apply { $0.shapes.append("circle") }
editor.apply { $0.color = "red" }
editor.undo()
print(editor.canvas.color)              // black
data class Canvas(val shapes: List<String> = emptyList(), val color: String = "black")

class Editor {
    var canvas = Canvas()
        private set
    private val history = ArrayDeque<Canvas>()

    fun apply(change: (Canvas) -> Canvas) {
        history.addLast(canvas)         // safe to keep: Canvas can't change
        canvas = change(canvas)
    }

    fun undo() {
        history.removeLastOrNull()?.let { canvas = it }
    }
}

val editor = Editor()
editor.apply { it.copy(shapes = it.shapes + "circle") }
editor.apply { it.copy(color = "red") }
editor.undo()
println(editor.canvas.color)            // black
record Canvas(List<String> shapes, String color) {
    Canvas { shapes = List.copyOf(shapes); }            // an unmodifiable copy
    Canvas withShape(String s) {
        var all = new ArrayList<>(shapes); all.add(s);
        return new Canvas(all, color);
    }
    Canvas withColor(String c) { return new Canvas(shapes, c); }
}

class Editor {
    private Canvas canvas = new Canvas(List.of(), "black");
    private final Deque<Canvas> history = new ArrayDeque<>();

    void apply(UnaryOperator<Canvas> change) {
        history.push(canvas);           // safe to keep: Canvas can't change
        canvas = change.apply(canvas);
    }

    void undo() {
        if (!history.isEmpty()) canvas = history.pop();
    }

    Canvas canvas() { return canvas; }
}
interface Canvas {
  readonly shapes: readonly string[];
  readonly color: string;
}

class Editor {
  canvas: Canvas = { shapes: [], color: "black" };
  private history: Canvas[] = [];

  apply(change: (c: Canvas) => Canvas) {
    this.history.push(this.canvas);   // safe to keep: nobody changes a Canvas
    this.canvas = change(this.canvas);
  }

  undo() {
    const previous = this.history.pop();
    if (previous) this.canvas = previous;
  }
}

const editor = new Editor();
editor.apply((c) => ({ ...c, shapes: [...c.shapes, "circle"] }));
editor.apply((c) => ({ ...c, color: "red" }));
editor.undo();
console.log(editor.canvas.color);     // black

Now try the same idea with a mutable canvas, and it falls apart:

Spot the bugUndo that undoes nothing

Tap the line with the bug.

final class Canvas {
    var color = "black"
}

var history: [Canvas] = []
let canvas = Canvas()

history.append(canvas)
canvas.color = "red"

print(history.last!.color)
class Canvas(var color: String = "black")

val history = ArrayDeque<Canvas>()
val canvas = Canvas()

history.addLast(canvas)
canvas.color = "red"

println(history.last().color)
class Canvas { String color = "black"; }

Deque<Canvas> history = new ArrayDeque<>();
Canvas canvas = new Canvas();

history.push(canvas);
canvas.color = "red";

System.out.println(history.peek().color);
class Canvas {
  color = "black";
}

const history: Canvas[] = [];
const canvas = new Canvas();

history.push(canvas);
canvas.color = "red";

console.log(history.at(-1)!.color);

Exerciseeasy

Change a user in a list

An app keeps its users in a list. Write deactivate(id) that sets isActive to false for the user with that id, inside the list. Use a value type in Swift, an immutable data class / record in Kotlin and Java, and readonly properties in TypeScript.

struct User: Identifiable {
    let id: Int
    var name: String
    var isActive = true
}

var users = [User(id: 1, name: "Ana"), User(id: 2, name: "Marko")]
data class User(val id: Int, val name: String, val isActive: Boolean = true)

var users = listOf(User(1, "Ana"), User(2, "Marko"))
record User(int id, String name, boolean isActive) {}

List<User> users = new ArrayList<>(List.of(new User(1, "Ana", true), new User(2, "Marko", true)));
interface User {
  readonly id: number;
  readonly name: string;
  readonly isActive: boolean;
}

let users: User[] = [
  { id: 1, name: "Ana", isActive: true },
  { id: 2, name: "Marko", isActive: true },
];
›

A common first try finds the user, changes it, and stops. With values, you changed a copy. How does the list get the new version?

›

Change the element through the list: by index in Swift (users[i].isActive = false), or build the updated user and put it back (map in Kotlin and TypeScript, replaceAll or set in Java).

SolutionTry it yourself firstTime O(n)
func deactivate(_ id: Int) {
    if let i = users.firstIndex(where: { $0.id == id }) {
        users[i].isActive = false       // changes the element stored in the array
    }
}

// The trap: this changes a copy and throws it away.
// if var user = users.first(where: { $0.id == id }) { user.isActive = false }
fun deactivate(id: Int) {
    users = users.map { if (it.id == id) it.copy(isActive = false) else it }
}

// The trap: copy() returns a new user, and nobody uses it.
// users.first { it.id == id }.copy(isActive = false)
void deactivate(int id) {
    users.replaceAll(u -> u.id() == id ? new User(u.id(), u.name(), false) : u);
}

// The trap: the new record is created and thrown away.
// new User(user.id(), user.name(), false);
function deactivate(id: number) {
  users = users.map((u) => (u.id === id ? { ...u, isActive: false } : u));
}

// The trap: the spread creates a new user, and nobody stores it.
// const changed = { ...users.find((u) => u.id === id)!, isActive: false };

This is the price of values: you can’t change “the user” somewhere and expect everyone to see it. You change the owner of the data (the list) and everyone who reads it from there sees the new version. In return, nothing can change behind your back, which is exactly what made the font preview bug impossible.

SwiftKotlinJavaTypeScript
Copy with a changevar c = v; c.x = 1v.copy(x = 1)new R(…) / withX(…){ ...v, x: 1 }
Change an element of a listusers[i].isActive = falseusers = users.map { … }users.replaceAll(…)users = users.map(…)
  • Shared mutable state breaks undo, previews and caches: a saved “snapshot” of a mutable object is the live object. Use values or immutable objects for state you keep.
  • Values change through their owner: changing a copy (if var user = …, an unused copy(), an unused spread) changes nothing.

Check this part

  1. Settings is a class. let draft = settings, then draft.fontSize = 22. What changes?Settings is a class with a var fontSize. val draft = settings, then draft.fontSize = 22. What changes?Settings draft = settings;, then draft.fontSize = 22;. What changes?const draft = settings, then draft.fontSize = 22. What changes?

  2. What's the cleanest fix for the font preview?

  3. An undo history saves the current state object before each change, and the state is a mutable class. What does undo restore?

  4. if var user = users.first(where: { $0.id == id }) { user.isActive = false }. Does the array change?users.first { it.id == id }.copy(isActive = false). Does the list change?new User(user.id(), user.name(), false); for the matching user. Does the list change?const updated = { ...users.find((u) => u.id === id)!, isActive: false }. Does the array change?

Assigning an object doesn't copy it. Make data a value or immutable, and change it through its owner.

Part Not started

Same value, or same object?

Copying isn’t the only place the two kinds of types differ. Comparing them does too. Two separately created things with the same contents:

  • are equal if you compare their data,
  • are not the same object, if they’re reference types.

Each language spells these two questions differently, and mixing them up is a classic bug:

QuestionSwiftKotlinJavaTypeScript
Same data?== (with Equatable)== (calls equals)a.equals(b)compare the fields yourself
Same object?=== (classes only)===a == b===

Predict the output

What does the comparison print?

struct Point: Equatable {
    let x: Int
    let y: Int
}

let a = Point(x: 1, y: 2)
let b = Point(x: 1, y: 2)
print(a == b)
data class Point(val x: Int, val y: Int)

val a = Point(1, 2)
val b = Point(1, 2)
println(a == b)
record Point(int x, int y) {}

Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println(a == b);
interface Point {
  readonly x: number;
  readonly y: number;
}

const a: Point = { x: 1, y: 2 };
const b: Point = { x: 1, y: 2 };
console.log(a === b);
SwiftKotlinJavaTypeScript
Same data?== (with Equatable)== (calls equals)a.equals(b)no built-in: compare fields or use a deep-equal helper
Same object?=== (classes only)===a == b===

Swift, Kotlin, Java

Hash-based collections use the same equality: two equal structs, data classes or records are one element of a Set and one key in a map.

TypeScript

Set and Map compare objects by identity: two objects with the same contents are two elements. For keys made of data, use strings or numbers (`${x},${y}`).

Check this part

  1. Point is an Equatable struct. Point(x: 1, y: 2) == Point(x: 1, y: 2) is…Point is a data class. Point(1, 2) == Point(1, 2) is…Point is a record. new Point(1, 2) == new Point(1, 2) is…({ x: 1, y: 2 }) === ({ x: 1, y: 2 }) is…

  2. Which operator asks "is this the same object?"

  3. Two equal Hashable structs go into a Set. How many elements does it have?Two equal data class instances go into a HashSet. How many elements does it have?Two equal records go into a HashSet. How many elements does it have?Two objects { x: 1 } (created separately) go into a Set. How many elements does it have?

Values compare by contents; for objects, ask separately whether two variables hold the same object.

Part Not started

let and val don’t freeze objects

let (Swift), val (Kotlin), final (Java) and const (TypeScript) mean “this variable can’t be pointed at something else”. What that protects depends on the type:

struct Box { var count = 0 }
final class Counter { var count = 0 }

let box = Box()
// box.count = 1          // error: box is a let constant, and the struct is the value itself

let counter = Counter()
counter.count = 1         // fine: the reference is constant, the object isn't
class Counter(var count: Int = 0)

val counter = Counter()
counter.count = 1         // fine: val fixes the reference, not the object
// counter = Counter()    // error: can't point val at a new object

val names = mutableListOf("Ana")
names.add("Marko")        // fine, for the same reason
final class Counter { int count; }

final Counter counter = new Counter();
counter.count = 1;        // fine: final fixes the reference, not the object
// counter = new Counter(); // error

final List<String> names = new ArrayList<>(List.of("Ana"));
names.add("Marko");       // fine, for the same reason
const counter = { count: 0 };
counter.count = 1;        // fine: const fixes the binding, not the object
// counter = { count: 2 };  // error: can't reassign a const

const names = ["Ana"];
names.push("Marko");      // fine, for the same reason

const frozen: readonly string[] = ["Ana"];
// frozen.push("Marko");  // error: a readonly array (checked by the compiler only)

In Swift, a let struct is fully frozen, all the way down through its stored properties. On the JVM, val and final only freeze the reference. To get an object nobody can change, its properties must be read-only too: val properties in a data class, a record in Java, readonly properties in TypeScript, and read-only collections inside them. (TypeScript’s readonly is checked by the compiler only; Object.freeze also stops changes at runtime, one level deep.)

Swift

A let struct is frozen all the way down through its stored properties; a let class reference only fixes the reference.

Kotlin

val fixes the reference. List is a read-only view: whoever holds the underlying MutableList can still change it, so don’t keep the caller’s mutable instance.

Java

final fixes the reference. List.of / List.copyOf are unmodifiable (and throw on add); Collections.unmodifiableList is only a view of a list someone else may change.

TypeScript

const fixes the binding. readonly, ReadonlyArray and as const are compile-time only; Object.freeze blocks changes at runtime, but only one level deep.

Check this part

  1. let box = Box(), where Box is a struct with var count. Then box.count = 1.val counter = Counter(), a class with var count. Then counter.count = 1.final Counter counter = new Counter();. Then counter.count = 1;.const counter = { count: 0 }. Then counter.count = 1.

  2. What does let freeze for a class instance?What does val freeze?What does final freeze?What does const freeze?

  3. let items = [1, 2]. Can you call items.append(3)?val view: List<String> = backing, where backing is a MutableList. Can view still change?final List<String> names = new ArrayList<>(). Can you call names.add("x")?const names: readonly string[] = load(). What stops names.push("x")?

let, val, final and const freeze the variable, not the object it points to.

Part Not started

Copies are shallow

A copy copies one level. If the thing you copy contains a reference to another object, the copy gets the same reference, and both share that inner object:

final class Style { var fontSize = 14 }
struct Document { var title: String; let style: Style }

var draft = original                   // copies the struct…
draft.style.fontSize = 20              // …but style is a class: shared
print(original.style.fontSize)         // 20
class Style(var fontSize: Int = 14)
data class Document(val title: String, val style: Style)

val draft = original.copy(title = "Draft")   // copies the outer object…
draft.style.fontSize = 20                    // …but style is shared
println(original.style.fontSize)             // 20
class Style { int fontSize = 14; }
record Document(String title, Style style) {}

Document draft = new Document("Draft", original.style());   // a new record…
draft.style().fontSize = 20;                                 // …same Style
System.out.println(original.style().fontSize);               // 20
const draft = { ...original, title: "Draft" };   // copies the outer object…
draft.style.fontSize = 20;                       // …but style is shared
console.log(original.style.fontSize);            // 20

The fix is the same idea as before, one level down: make the nested data a value or immutable too, so sharing it is harmless.

Every copy mechanism here is shallow: a struct holding a class, data class.copy(), a new record with the same fields, a spread. “Value semantics” therefore hold only as deep as every stored property is itself a value or immutable. The other side of copying is cost: copies that look O(1) per line add up, which is where the Detailed version starts.

Copy-on-write: values without the copying

Copying a 10,000-element array on every assignment would be ruinous, so Swift’s collections share storage until someone writes: assignment copies a reference to the buffer, and a mutation first checks whether the buffer is uniquely referenced, copying it only if it isn’t. You get value semantics with reference-type performance in the common case.

You can give your own types the same behavior with isKnownUniquelyReferenced:

struct Document {
    private final class Storage {
        var text: String
        init(text: String) { self.text = text }
    }

    private var storage: Storage

    init(text: String) { storage = Storage(text: text) }

    var text: String {
        get { storage.text }
        set {
            if !isKnownUniquelyReferenced(&storage) {
                storage = Storage(text: newValue)   // someone else shares it: copy on write
            } else {
                storage.text = newValue
            }
        }
    }
}

Two consequences matter in practice. Value semantics in Swift are not a statement about memory layout: structs can live on the heap (inside an existential any P larger than three words, captured by an escaping closure, or as a stored property of a class), and classes can be stack-promoted by the optimizer. And CoW only stays cheap while buffers stay uniquely referenced: a stray copy (a local var holding the same array, a value captured in a closure) turns every append into a full copy, which is the puzzle below.

Value semantics on the JVM

The JVM has no user-defined value types yet, so Kotlin and Java approximate value semantics with immutability:

  • data class and record give value-based equals/hashCode/toString and (Kotlin) copy, but they’re still references with identity, nullable, and heap-allocated (escape analysis can scalar-replace short-lived ones).
  • They’re shallow. A record Team(List<String> members) is only as immutable as the list it was given. Make defensive copies in the compact constructor (members = List.copyOf(members)), and in Kotlin expose List, not MutableList, and don’t keep the caller’s mutable instance.
  • Kotlin @JvmInline value class wraps a single value with no allocation in the common case, for type-safe IDs and units (value class UserId(val raw: String)). It’s boxed when used as a nullable type, a generic type argument or an interface, and === isn’t allowed on it.
  • Project Valhalla (JEP 401, value classes and objects) is bringing identity-free classes to Java, available as a preview in early-access builds, not yet in a released JDK.

Value semantics in TypeScript

  • Only primitives are values. Everything else is shared, and readonly, Readonly<T> and as const are compile-time promises. Object.freeze adds a runtime check, but only one level deep (and silently ignores writes outside strict mode).
  • Immutable updates with spread are O(size of the object or array) per change. That’s fine for UI state; for large structures updated often, use structural sharing (Immer’s produce, or persistent collections).
  • Frameworks depend on it. React and most state libraries detect changes by reference (===): mutating an object in place and setting it again renders nothing, because “it’s the same object”.
  • Deep copies: structuredClone(value) copies plain data, Maps, Sets and dates, but not functions or class prototypes. A JSON round trip loses even more (dates become strings, undefined disappears).

Values and concurrency

Values are the easiest thing to share between threads: a copy can’t be changed by anyone else. In Swift 6, structs and enums whose stored properties are all Sendable are implicitly Sendable (for non-public types), and the compiler rejects sending a non-Sendable class across isolation boundaries. On the JVM, deeply immutable objects (final fields, no escaping this, immutable contents) are safely published to other threads without locks, which is why record + List.copyOf is a good default for messages, events and UI state. In JavaScript, a worker can’t share ordinary objects at all: postMessage sends a structured-clone copy, and only SharedArrayBuffer is truly shared.

Production puzzles

Shallow copies

Predict the output

What gets printed?

final class Style { var fontSize = 14 }

struct Document {
    var title: String
    let style: Style
}

let original = Document(title: "Notes", style: Style())
var draft = original
draft.title = "Draft"
draft.style.fontSize = 20
print(original.style.fontSize)
class Style(var fontSize: Int = 14)

data class Document(val title: String, val style: Style)

val original = Document("Notes", Style())
val draft = original.copy(title = "Draft")
draft.style.fontSize = 20
println(original.style.fontSize)
class Style { int fontSize = 14; }

record Document(String title, Style style) {}

Document original = new Document("Notes", new Style());
Document draft = new Document("Draft", original.style());
draft.style().fontSize = 20;
System.out.println(original.style().fontSize);
interface Style { fontSize: number }
interface Document { title: string; readonly style: Style }

const original: Document = { title: "Notes", style: { fontSize: 14 } };
const draft = { ...original, title: "Draft" };
draft.style.fontSize = 20;
console.log(original.style.fontSize);

Accidentally quadratic

Spot the bugAn import that slows down with every event

Tap the line with the bug.

func record(_ events: [Event], into store: EventStore) {
    for event in events {
        var log = store.log
        log.append(event)
        store.log = log
    }
}
fun record(events: List<Event>, store: EventStore) {
    for (event in events) {
        store.log = store.log + event
    }
}
void record(List<Event> events, EventStore store) {
    for (Event event : events) {
        store.setLog(Stream.concat(store.log().stream(), Stream.of(event)).toList());
    }
}
function record(events: Event[], store: EventStore) {
  for (const event of events) {
    store.log = [...store.log, event];
  }
}

Exercisemedium

A Money type

Design a Money type for a checkout used on iOS, Android and the Java backend. It must:

  • never mix currencies by accident,
  • add, subtract and multiply by a quantity without rounding errors,
  • split a total into n parts that add up exactly to the total (10.00 / 3),
  • be safe to use as a map key and to share between threads.

Write it in your main language and list the decisions you’d defend in a review.

›

Double (and TypeScript’s number) can’t represent 0.10 exactly. Store an integer number of minor units (cents), with the currency’s number of decimals, or use a decimal type, and know its equality rules.

›

BigDecimal("1.0").equals(BigDecimal("1.00")) is false in Java, because equals compares the scale too. If you use BigDecimal, normalize the scale in the constructor or compare with compareTo.

SolutionTry it yourself first
struct Money: Hashable, Sendable {
    let minorUnits: Int64          // 1050 = 10.50 EUR
    let currency: String           // ISO 4217 code, e.g. "EUR"

    static func + (a: Money, b: Money) -> Money {
        precondition(a.currency == b.currency, "Currency mismatch: \(a.currency) vs \(b.currency)")
        return Money(minorUnits: a.minorUnits + b.minorUnits, currency: a.currency)
    }

    func times(_ quantity: Int) -> Money {
        Money(minorUnits: minorUnits * Int64(quantity), currency: currency)
    }

    /// Splits into parts that differ by at most one minor unit and add up exactly.
    func split(into parts: Int) -> [Money] {
        let base = minorUnits / Int64(parts)
        let remainder = Int(minorUnits % Int64(parts))
        return (0..<parts).map { i in
            Money(minorUnits: base + (i < remainder ? 1 : 0), currency: currency)
        }
    }
}
@JvmInline
value class Currency(val code: String)

data class Money(val minorUnits: Long, val currency: Currency) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" }
        return Money(Math.addExact(minorUnits, other.minorUnits), currency)
    }

    operator fun times(quantity: Int) = Money(Math.multiplyExact(minorUnits, quantity.toLong()), currency)

    fun split(parts: Int): List<Money> {
        val base = minorUnits / parts
        val remainder = (minorUnits % parts).toInt()
        return List(parts) { i -> Money(base + if (i < remainder) 1 else 0, currency) }
    }
}
public record Money(long minorUnits, Currency currency) {
    public Money {
        Objects.requireNonNull(currency);
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency);
        }
        return new Money(Math.addExact(minorUnits, other.minorUnits), currency);
    }

    public Money times(int quantity) {
        return new Money(Math.multiplyExact(minorUnits, quantity), currency);
    }

    public List<Money> split(int parts) {
        long base = minorUnits / parts;
        long remainder = minorUnits % parts;
        return IntStream.range(0, parts)
            .mapToObj(i -> new Money(base + (i < remainder ? 1 : 0), currency))
            .toList();
    }
}
class Money {
  private constructor(readonly minorUnits: bigint, readonly currency: string) {}

  static of(minorUnits: bigint, currency: string): Money {
    return new Money(minorUnits, currency);
  }

  plus(other: Money): Money {
    if (other.currency !== this.currency) {
      throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
    }
    return new Money(this.minorUnits + other.minorUnits, this.currency);
  }

  times(quantity: number): Money {
    return new Money(this.minorUnits * BigInt(quantity), this.currency);
  }

  split(parts: number): Money[] {
    const n = BigInt(parts);
    const base = this.minorUnits / n;                  // bigint division truncates
    const remainder = Number(this.minorUnits % n);
    return Array.from({ length: parts }, (_, i) => new Money(base + (i < remainder ? 1n : 0n), this.currency));
  }

  equals(other: Money): boolean {
    return this.minorUnits === other.minorUnits && this.currency === other.currency;
  }

  get key(): string {                                  // for Map keys: objects compare by identity
    return `${this.currency}:${this.minorUnits}`;
  }
}

Decisions to defend:

  • Integer minor units, not Double: exact addition and comparison, cheap, and identical on every platform. The currency defines the exponent (JPY has 0 decimals, KWD has 3), so formatting and parsing go through the currency, never “divide by 100”.
  • Immutable value type: safe as a map key, safe to share across threads (Sendable, deeply immutable record), and every operation returns a new value. In TypeScript, a Money object is a map key only by identity, so key maps by money.key (a string), and use bigint (or integers within Number.MAX_SAFE_INTEGER) for the minor units.
  • Overflow is an error (addExact, Swift’s trapping arithmetic), not a silent wrap to a negative price.
  • Mixed currencies fail loudly. Conversion is a separate, explicit operation with a rate and a timestamp, because it’s a business event, not arithmetic.
  • Split distributes the remainder: 10.00 / 3 is 3.34 + 3.33 + 3.33. Allocation by ratios (taxes, revenue share) works the same way with the remainder going to the largest fractions.
  • Wire format: send {"amount": "10.50", "currency": "EUR"} (string decimal) or minor units with the currency; never a JSON float.

All copies here are shallow; value semantics hold only as deep as every stored property is a value or immutable.

Swift

  • Copy-on-write keeps collections cheap while their buffer is uniquely referenced. A stray second reference (var log = store.log; log.append(x); store.log = log) makes every append copy everything: O(n²) in a loop.
  • Non-public structs and enums whose stored properties are all Sendable are implicitly Sendable in Swift 6.

Kotlin

  • copy() is shallow; keep nested types immutable. store.log = store.log + event in a loop copies the whole list each time: O(n²). Batch, or use kotlinx.collections.immutable.
  • @JvmInline value class wraps one value without allocation, but is boxed as a nullable type, a generic type argument or through an interface.

Java

  • Records are shallowly immutable: copy collections in the compact constructor (members = List.copyOf(members)).
  • Building a new list per added element in a loop is O(n²). Batch, or use persistent collections (Vavr).
  • Deeply immutable objects (final fields, immutable contents) are safely published to other threads without locks. Project Valhalla (JEP 401) will add value classes; not in a released JDK yet.

TypeScript

  • Spread is shallow; structuredClone copies deeply (plain data only). Object.freeze is shallow too.
  • log = [...log, event] in a loop is O(n²); batch with concat, or use structural sharing (Immer).
  • React and state libraries compare by reference: mutate in place and nothing re-renders.

Check this part

  1. A struct holds a class instance. You copy the struct and change the class instance through the copy. Does the original see it?A data class holds a mutable object. You copy() it and change that object through the copy. Does the original see it?A record holds a mutable object. You create a new record with the same object and change it. Does the original see it?An object holds another object. You copy it with { ...original } and change the inner object. Does the original see it?

  2. What makes nested data safe to share after a copy?

  3. In a loop: var log = store.log; log.append(event); store.log = log. What does importing n events cost?In a loop: store.log = store.log + event. What does importing n events cost?In a loop, a new list with all old events plus one is built and stored. What does importing n events cost?In a loop: store.log = [...store.log, event]. What does importing n events cost?

  4. When is a non-public struct implicitly Sendable in Swift 6?When is a @JvmInline value class boxed?record Team(List<String> members); the caller keeps changing the list it passed in. What does the team see?Object.freeze(settings), then settings.style.fontSize = 20. What happens?

Copies are shallow: nested objects are shared. Keep nested data immutable too, and watch what copies cost.

Key takeaways

  • A value type copies its data on assignment; a reference type copies a link to the same object.
  • Swift: struct/enum are values, class is a reference. Kotlin, Java and TypeScript: everything except primitives is a reference.
  • let/val/final/const freeze the variable, not the object it points to.
  • Compare data with == in Swift and Kotlin, and equals in Java; Java’s == and TypeScript’s === on objects compare identity.
  • Prefer values (Swift) and immutable data class/record/readonly types (Kotlin, Java, TypeScript) for data you pass around.
  • Value semantics is about copy and identity, not memory layout; Swift collections get it cheaply through copy-on-write.
  • Keep CoW buffers uniquely referenced: stray copies turn appends into O(n) copies and loops into O(n²).
  • On the JVM, value semantics come from deep immutability: data class/record are shallow, so copy defensively.
  • Values and deeply immutable objects are the cheapest things to share across threads (Sendable, safe publication).

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.