← Concepts & practices
Concept Data modeling and type design

Value objects

Let the value carry its rules.

You already pass pairs around: a check-in and a check-out, a price and its currency, a from and a to. Every piece of code that uses the pair decides what it means. Let’s follow a booking’s dates from two plain strings that two helpers read differently to a value that answers the same way everywhere.

TypeScriptGo One date range, two implementations.

01 / The idea

Two date strings are a fair start.

You’re building the dates part of a booking app. A stay has a check-in and a check-out, and the form edits them as two strings. A plain pair suits a form that’s half filled while someone decides.

Read the plain pairTypeScript · the version this lesson starts from
range.ts
export type RangeDraft = { start: string; end: string };

// The calendar grid's reading of the pair: both ends are booked nights.
export function draftShowsDay(range: RangeDraft, day: string): boolean {
	return range.start <= day && day <= range.end;
}

// The availability check's reading: the end is the day the guest leaves.
export function draftsConflict(a: RangeDraft, b: RangeDraft): boolean {
	return a.start < b.end && b.start < a.end;
}

// Useful while editing: the pair may be half typed, or reversed.
export const unfinished: RangeDraft = { start: '2026-09-18', end: '2026-09-12' };

Go’s version has the same pair and the same two helpers. Both languages meet again at DateRange in section 02.

Then the pair reaches more code. The calendar grid asks whether a day is booked and reads the pair as start <= day && day <= end. The availability check asks whether two stays clash and reads it as a.start < b.end && b.start < a.end. Both are reasonable. For a stay ending on the 14th and another starting on the 14th, the grid shows the 14th booked twice, and the availability check says the stays don’t clash.

A value object is a small type defined by what it holds, not by which instance it is. It checks its rules when it’s made, compares by value, and doesn’t change afterwards. Martin Fowler’s short essay puts the last part plainly: “value objects should be immutable.”

If you’ve built a date-range picker, you’ve met this ambiguity as an option. date-fns’s areIntervalsOverlapping documents that “adjacent intervals do not count as overlapping unless inclusive is set to true.” Every component that doesn’t call the same function decides for itself. Section 05 puts the rule in one type and builds a booking form around it.

02 / See the shape

Check once, compare by value, never change.

The basic form is construction and equality. In the wild adds the operations, each using the same rule: the start is included and the end isn’t. At the call site runs six observations the rest of the lesson follows.

Both languages print the same six lines and pass the same 32 shared cases, from leap centuries to touching stays.

Construction and equality. A DateRange is checked once when it’s made, and two ranges are equal when their dates are.

TypeScriptReading
range.ts
export class DateRange {
	readonly #start: string;
	readonly #end: string;

	// Checked once, here: real dates, in order.
	constructor(start: string, end: string) {
		const error = rangeError(start, end);
		if (error) throw new RangeError(error);
		this.#start = start;
		this.#end = end;
		Object.freeze(this);
	}
	get start(): string {
		return this.#start;
	}
	get end(): string {
		return this.#end;
	}

	// Equal means the same two dates, however each range was made.
	equals(other: DateRange): boolean {
		return this.#start === other.#start && this.#end === other.#end;
	}
GoAlongside
range.go
// Unexported fields: other packages can't set them, so a real DateRange comes only from New.
// Any package can still write the zero DateRange{}, and every method refuses it.
type DateRange struct{ start, end string }

// Checked once, here: real dates, in order.
func New(start, end string) (DateRange, error) {
	if err := rangeError(start, end); err != nil {
		return DateRange{}, err
	}
	return DateRange{start: start, end: end}, nil
}
func (r DateRange) Start() string { return r.start }
func (r DateRange) End() string   { return r.end }

// The zero value is uninitialized, not a valid empty date range.
func (r DateRange) check() error {
	if r.start == "" {
		return errors.New("Uninitialized range.")
	}
	return nil
}

// Equal means the same two dates, however each range was made.
func (r DateRange) Equal(other DateRange) (bool, error) {
	if err := r.check(); err != nil {
		return false, err
	}
	if err := other.check(); err != nil {
		return false, err
	}
	return r.start == other.start && r.end == other.end, nil
}
Reading the TypeScriptPrivate fields, freeze, and equals

The dates live in #private fields with no setter. The constructor ends with Object.freeze(this), which stops changes to the object’s own properties but, as MDN notes, doesn’t reach private fields. The missing setter is what keeps the end fixed; section 04 shows why that matters.

equals compares the two dates. === and Array.includes still compare objects. Once both dates are canonical YYYY-MM-DD, text order is date order, so plain string comparisons are safe after the check.

Reading the GoA package boundary and a zero value

DateRange has unexported fields and value receivers, so other packages get one from New and can’t change it. main.go imports the ranges package, which keeps that boundary real in the tests.

DateRange{} still exists, and every method refuses it with Uninitialized range. Go’s == compares both fields, which agrees with Equal for real ranges and also calls two zero ranges equal.

03 / Follow the stays

Watch two stays share a check-out day.

Five steps, every answer from the code you just read. The bars sit on a day axis: a filled dot where a stay starts, an open one where it ends. Before each step, guess whether the 14th is booked.

In Try it, change the dates and ask both designs the same questions.

Value objects

Two stays, one check-out day.

2026-09-102026-09-18

09-14
A
[2026-09-10, 2026-09-14)
B
[2026-09-14, 2026-09-18)
Plain pair helpers

A 2026-09-10 to 2026-09-14 (draft); B 2026-09-14 to 2026-09-18 (draft). Day 2026-09-14. draftShowsDay(a, '2026-09-14') gives true; draftShowsDay(b, '2026-09-14') gives true; draftsConflict(a, b) gives false. The grid books Sep 14 in both stays; the availability check says they don’t clash. Same strings, two readings.

01/ 05
Ask two plain-pair helpers about Sep 14

Two readings of one pair.

The grid books Sep 14 in both stays. The availability check says they don’t clash. Both read the same strings.

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

Read this scene

The grid books Sep 14 in both stays. The availability check says they don’t clash. Both read the same strings.

A 2026-09-10 to 2026-09-14 (draft); B 2026-09-14 to 2026-09-18 (draft). Day 2026-09-14. draftShowsDay(a, '2026-09-14') gives true; draftShowsDay(b, '2026-09-14') gives true; draftsConflict(a, b) gives false. The grid books Sep 14 in both stays; the availability check says they don’t clash. Same strings, two readings.

Watch restarts when you return. Step through keeps your selected step. Try it starts from the touching stays each time you open it.

What the value 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.

One rule for every caller
contains and overlaps both leave the end out. The grid and the availability check can’t disagree about the 14th any more.
Invalid ranges can’t exist
The reversed pair from step 2 fails construction. Every DateRange is two real dates in order.
Equal means the same dates
a.equals(same) is true for two separately built ranges. A fresh object isn’t a date change.
Safe to share
There’s no setter. The editor and the confirmation panel can hold the same range, and new dates mean a new range.
The rules live with the data
overlaps sits next to the two fields it reads. Callers ask the range instead of writing the comparison again.

The review words are value semantics, for comparing by contents, and immutability, for the missing setter. Section 08 covers what they cost.

04 / Try a decision

A setter makes a shared range change underneath you.

The date editor makes a new range on every keystroke. Someone profiles the page, sees the allocations, and adds setEnd(end), which writes #end after the same checks. The confirmation panel was handed the range when the guest first picked dates.

The guest is still deciding. What does the confirmation panel show?

The editor and the panel were given the same range. The editor calls stay.setEnd('2026-09-16'). The constructor still ends with Object.freeze(this).

05 / Give it a real job

Drafts in the form, values everywhere else.

In the real app, the form’s inputs hold strings. Once both dates parse, the app has a DateRange, and the availability check, the calendar, the price, and the confirmation email all take that. Storage and the API carry two date strings, and they’re parsed back into a range on the way in.

Form

Holds drafts

Half typed, reversed, or empty are all fine here.

Booking code

Takes DateRange

Availability, calendar, price, and email.

Storage and API

Carry two strings

Parsed back into a range when they come in.

A range knows nothing about rooms, guests, or other bookings. Whether a room is free is a question for code that has the bookings, and that code asks each range the same overlaps question.

The example leaves out times of day, time zones, prices, and saving bookings. None of those change where the rule lives.

Build UIs?Every calendar that highlights a stay decides whether the last day counts, and one day a booking form makes you keep drafts and chosen dates apart.

Where it already is in your components

Date pickers, week views, and availability badges all take a start and an end. Each one that compares them with its own < or <= makes a decision about the check-out day, and they don’t always make the same one. That’s why date-fns turns it into an option.

Handing components a DateRange moves the decision out of them. A week view asks stay.contains(day) and highlights whatever the range says.

When you have to own it

Now it’s the booking form. The two date inputs give YYYY-MM-DD text, and either can be empty or earlier than the other while the guest is choosing. Those are drafts. Once both parse, the form has a range and checks it against existing bookings with clashesWith, which leaves the check-out day free for the next guest.

“Use these dates” stores the chosen range. Editing the inputs afterwards builds new ranges, and the confirmation line keeps showing the chosen one until the guest picks again. Drafts beside values, one rule, and replacement instead of editing: the whole lesson in a form.

booking.ts
import type { DateRange } from '../ranges/range';

export type Booking = Readonly<{ id: string; guest: string; range: DateRange }>;

// One rule for every clash, because it's the range's rule: the check-out day is free
// for the next guest. No component writes its own comparison.
export function clashesWith(range: DateRange, bookings: readonly Booking[]): Booking[] {
	return bookings.filter((booking) => booking.range.overlaps(range));
}

A week view that asks the range whether each day is booked, instead of comparing dates itself. The check-out day isn’t highlighted, in React or Svelte.

ReactAlready in your code
StayWeek.tsx
import type { DateRange } from '../ranges/range';

// The version most calendars start with: each component reads the pair its own way.
//   const booked = start <= day && day <= end;         // the grid: check-out night booked
//   const clash = a.start < b.end && b.start < a.end;   // availability: check-out day free

export function StayWeek({ stay, days }: { stay: DateRange; days: string[] }) {
	return (
		<ol className="week">
			{days.map((day) => {
				// One rule, the range's own: the check-out day isn't a booked night.
				const result = stay.contains(day);
				const booked = result.ok && result.value;
				return (
					<li
						key={day}
						className={booked ? 'booked' : undefined}
						aria-label={booked ? `${day}, booked` : day}
					>
						{Number(day.slice(8))}
					</li>
				);
			})}
		</ol>
	);
}

06 / Recognize it elsewhere

Anywhere a few values only mean something together.

You’ve met all of these as loose pairs. Each one is a value object waiting to happen.

Familiar pairs and the question each caller answers alone
Where you’ve seen itThe pairThe question every caller answers alone
A hotel staycheck-in, check-outIs the check-out night booked?
A report filterfrom, toDoes “to September 30” include the 30th?
A price filtermin, maxIs a £50 item in “£0–£50”?
Moneyamount, currencyCan these two amounts be added?

A pair used once, in one function, is fine as it is. It becomes a value object when more than one piece of code needs the same answer.

07 / Already in your toolbox

Your tools already model values this way.

Three places to look. For each one, find how equality and changes work.

Temporal · PlainDate

A calendar date with no time or time zone. equals() compares by value, and with() returns a new PlainDate instead of changing this one. MDN marks Temporal as limited availability, so check browser support before relying on it.

Read the PlainDate reference ↗

date-fns · areIntervalsOverlapping

Adjacent intervals don’t overlap unless you pass inclusive: true, and the source switches between < and <=. It’s this lesson’s opening ambiguity, made a parameter.

Look at the function ↗

Martin Fowler · Value Object

The short essay that names the idea: objects equal because their properties are, and the aliasing bug that makes him insist they stay immutable.

Read the essay ↗
A useful counterexample: a bookingWhen identity matters more than contents

Two guests can book the same dates, and they’re still two bookings. A booking keeps its number when the guest changes their dates. It’s an entity: known by identity, with contents that change over time.

Entities often hold value objects. The booking has an ID and a DateRange; changing its dates replaces the range and keeps the booking.

08 / The parts to watch

A value keeps its rules. The language doesn’t always know about them.

These are the places you still have to meet it halfway.

=== and Set don’t use equals

a === same is false, [a].includes(same) is false, and a Set of ranges keeps both. Call equals, or key collections by toString().

It won’t serialize itself

JSON.stringify(range) gives {}, because private fields aren’t enumerable. Send the two dates explicitly and rebuild the range through parseRange when they come back.

Drafts still need a home

A date input that’s empty or earlier than the other is normal while someone types. Don’t force it into a DateRange early; keep the draft and build the value when both dates make sense.

Go’s zero value and ==

DateRange{} compiles, so every method checks for it. == happens to match Equal for this two-string struct; add a field and that stops being automatic.

Two strings used once don’t need a class

A pair read by one function in one place is clear as it is. The type starts paying when a second reader needs the same answer.

09 / Make the call

What would you have to change tomorrow?

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

How a change affects a plain pair and a DateRange
The changeA plain pairDateRange
The check-out day must be free for the next guestFind every comparison and make them agree.It already is: one rule in contains and overlaps.
An old client sends a reversed pairThe helpers return answers that mean nothing, and nothing complains.Construction refuses it where it arrives.
The confirmation must not change while editingCopy the pair by hand wherever it’s passed.Nothing can change a range; edits make new ones.
A date field is half typedFits.Too early. Keep a draft until both dates parse.

Reach for a value object when a few values mean something only together, and more than one piece of code relies on that meaning. The grid and the availability check disagreeing about the 14th is the moment.

Keep the plain pair when it lives in one function or one form. If nothing else reads it, there’s no second opinion to prevent.

The question I’d leave beside the code is: what should this value guarantee wherever it goes?

10 / Take the idea with you

Explain the stay without saying “value object.”

“A stay is its check-in and check-out, checked together once, compared by its dates, and replaced rather than edited.” In a review, the words are value semantics and immutable.

Before moving on, jot down why the grid and the availability check disagreed, what the setter broke in section 04, and one pair in your own code, a from and a to or an amount and a currency, that more than one component reads.

Connections to follow nextRelated lessons

Take the range into your editor. Add nights() and a price that depends on it, and see which callers still do date arithmetic of their own.

Back to Concepts & practices →