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
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.
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;
} // 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.
Two stays, one check-out day.
2026-09-102026-09-18
[2026-09-10, 2026-09-14)[2026-09-14, 2026-09-18)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.
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
containsandoverlapsboth 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
DateRangeis 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
overlapssits 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.
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.
Holds drafts
Half typed, reversed, or empty are all fine here.
Takes DateRange
Availability, calendar, price, and email.
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.
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.
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.
| Where you’ve seen it | The pair | The question every caller answers alone |
|---|---|---|
| A hotel stay | check-in, check-out | Is the check-out night booked? |
| A report filter | from, to | Does “to September 30” include the 30th? |
| A price filter | min, max | Is a £50 item in “£0–£50”? |
| Money | amount, currency | Can 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.
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.
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.
| The change | A plain pair | DateRange |
|---|---|---|
| The check-out day must be free for the next guest | Find every comparison and make them agree. | It already is: one rule in contains and overlaps. |
| An old client sends a reversed pair | The helpers return answers that mean nothing, and nothing complains. | Construction refuses it where it arrives. |
| The confirmation must not change while editing | Copy the pair by hand wherever it’s passed. | Nothing can change a range; edits make new ones. |
| A date field is half typed | Fits. | 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
- Copying, identity, and equality explains what
===, copies, and shared references actually do underneath. - Parse, don’t validate is how a
draft becomes a value:
parseRangereturns one or an error. - Branded and opaque types keep single look-alike values apart; value objects add rules and operations.
- Discriminated unions model values that can be one of several shapes, like an open or a closed range.
- Making illegal states unrepresentable is the same instinct that refused the reversed pair, applied to whole models.