Back to engineering

Engineering

The optional parameter that was four hours wrong

The Bookatu engineering team10 min read

We booked the noon slot at a test shop in Perth and the confirmation email said 4:00pm. Nothing threw, nothing logged, and the email was well formatted and wrong. The cause was a question mark in a function signature, and the part worth reading is the shape of the fix rather than the bug.

The short version

  • We gave a test business two shops, one in Auckland and one in Perth, and booked the noon slot at the Perth one. The confirmation email said 4:00pm, because the time was formatted in the organisation's zone rather than the branch's.
  • Nothing failed. The email rendered and sent and looked entirely correct, and a client who saved it to their calendar would have turned up at a shop that had been shut for hours.
  • The cause was an optional timezone parameter. Optional is a claim that a sensible default exists, and that claim held for exactly as long as every business had one address.
  • The fix that rots is to visit every call site and pass the right value. It lives in people's heads, and it fails silently the first time somebody forgets.
  • The fix that holds is to make the parameter required, so the compiler produces the list of call sites for you. Two helpers we could not hand a timezone to turned out to have no callers left at all, and were deleted rather than defaulted.

We were building multi-location support, so the test business had two shops, one in Auckland and one in Perth. Book the noon slot at the Perth one and the confirmation email says 4:00pm. Everything else in that email was right. The right service, the right price, the right person, the right address by then, and a time that was four hours out.

Nothing threw. Nothing logged. There is no error state in a well formatted wrong answer, which is what makes this class of bug expensive. A client reads it and believes it, because there is nothing there to disbelieve. The next thing that happens is somebody standing outside a closed shop in the late afternoon, quite certain they had done everything right.

Correct until it wasn't

Our date helpers live in one file, and that file has said what it assumes at the top since the day it was written. It just stopped being read once it was true for long enough.

ts
/**
 * Timezone-aware date helpers for a single-location salon.
 *
 * We store absolute instants (UTC) in the DB and render/compute wall-clock
 * times in the salon timezone.
 */

export const SALON_TZ = "Pacific/Auckland";

export function fmtTime(date: Date, tz: string = SALON_TZ, locale: string = DEFAULT_LOCALE): string {
  return new Intl.DateTimeFormat(locale, {
    timeZone: tz,
    hour: "numeric",
    minute: "2-digit",
    hour12: true,
  }).format(date);
}

Read the signature and it tells you a story. The timezone is optional, so there must be a sensible default, so most callers presumably do not need to think about it. The product began as booking software for one salon in one city, and the constant is still named after that. The default had long since stopped firing in the email path, because every caller there passes the organisation's own timezone explicitly. Which is the correct value for a business with one address. Every business had one address.

An optional parameter is a claim that a default exists. The claim is only as true as the assumption underneath it, and nothing in the type system holds that assumption still.

So the bug shipped long before it happened. It sat there as an assumption with no owner, waiting for a second address in a different zone, which is a feature we then set out to build. Building it turned a correct line into a wrong one without anybody touching that line.

Two clocks, and which one the client stands under

There are two places a timezone decides something a client can feel. One is availability, which controls what slots the booking page offers. The other is the emails, which control what time the client writes down. Both had been reading the organisation's zone, and both had been right for years.

Availability came first, because a branch keeps its own week as well as its own clock and the two have to be resolved together.

ts
// A branch keeps its OWN clock and its OWN week. Reading the org's for a
// Perth site would offer Auckland hours in Auckland time, and take bookings
// while the branch is shut.
const branch =
  params.locationId != null ? await loadBranch(params.orgId, params.locationId) : null;
const tz = branch?.timezone || settings.timezone || SALON_TZ;
const weeklyHours =
  branch && Array.isArray(branch.hours) && branch.hours.length === DAYS_IN_WEEK
    ? (branch.hours as SalonSettings["hours"])
    : settings.hours;

Both fall back to the organisation on purpose. A newly created branch has an empty hours array, and reading that as closed would make a freshly added site silently unbookable. Falling back offers the hours the business used yesterday, which is the answer least likely to surprise anyone.

The email side is smaller. One function decides which clock an emailed booking is quoted in, and everything that quotes a client a time goes through it: the confirmation, the reminder, the confirm-or-cancel request, the owner's own copy, and the text-message version of the reminder.

ts
/**
 * WHICH CLOCK an emailed booking is quoted in.
 *
 * A booking at a branch is quoted on that branch's clock, because that is the
 * clock the client will be standing under.
 */
function bookingTz(branch: BookingBranch, s: { timezone: string }): string {
  return branch?.timezone || s.timezone;
}

const when = `${fmtDateLong(apt.startAt, bookingTz(branch, s), s.locale)} at ${fmtTime(apt.startAt, bookingTz(branch, s), s.locale)}`;

One line of logic under a comment several times its length, which is about the right ratio when the mechanism is obvious and the reason is not.

The fix that rots

The obvious next move is to go and find every other place a time is formatted and pass the branch's zone. That is a good afternoon's work and it would have closed every case we knew about. It is also the fix that rots.

Passing the right argument everywhere is a fix that lives in people's heads. It holds until whoever writes the next email template reaches for the same formatter and passes no zone, because the signature told them they did not have to. The moment that happens the failure is not an exception or a blank space on the page. It is a time, correctly formatted and wrong.

A fix that depends on remembering has a half-life. A fix that depends on the compiler does not.

Let the compiler write the list

Our mobile app had the same disease in a purer form, and it is the clearest place to show the technique. The API hands back UTC instants and every screen renders them on the business's wall clock. The helpers that do that conversion took the zone as an optional argument, and when it was missing they fell back to the phone's own zone.

One screen forgot to pass it. Nine lines earlier in the same file, another call had passed it correctly. So the calendar grid and the appointment record you opened from it disagreed about when the client was coming, and they disagreed by however far the owner happened to be from their own shop.

The fix was one character per signature.

ts
// Before: the zone is optional, and the shared helper underneath it falls
// back to whatever zone the device is set to.
export function minutesInZone(iso: string, timeZone?: string): number
export function clockInZone(iso: string, timeZone?: string): string
export function dateKeyInZone(date: Date, timeZone?: string): string
export function todayKeyInZone(timeZone?: string): string
export function weekdayInZone(date: Date, timeZone?: string): number

// After: there is no such thing as formatting a time without saying whose.
export function minutesInZone(iso: string, timeZone: string): number
export function clockInZone(iso: string, timeZone: string): string
export function dateKeyInZone(date: Date, timeZone: string): string
export function todayKeyInZone(timeZone: string): string
export function weekdayInZone(date: Date, timeZone: string): number

Deleting those question marks fixed nothing by itself. What it did was turn every remaining call site into a compile error, and that is the whole technique. You do not go hunting for the places that need attention and hope you found them all. You break the build and read the list, because the compiler knows every caller and will not get bored two thirds of the way down it.

The list is complete in a way a text search is not. A helper reached through a re-export, or handed to something else as a callback, is on the compiler's list and would not have been on yours. The comment we left above the block says why, so the next person tempted by a default knows what it costs.

ts
/*
 * So every helper below takes the timezone as a REQUIRED argument. It used to be
 * optional, falling back to the device, and one screen forgot to pass it: the
 * calendar and the booking it opened disagreed about when the client was coming.
 * An optional argument makes that a silent two-hour error; a required one makes
 * it a compile error.
 */

The three we could not hand a timezone to

Most of the compile errors were answered by threading a zone through. Three exports could not be, because there was no zone anywhere near them to require. They took an instant and rendered it on whatever clock the runtime happened to have, and there was nothing to hand them that would have made that right.

ts
// Deleted. Not "given a required parameter": deleted. There was no timezone in
// scope here, and its own doc comment had already admitted the fudge.
export function formatSlotTime(startAtIso: string): string {
  const date = new Date(startAtIso);
  if (Number.isNaN(date.getTime())) return startAtIso;
  return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
}

// Deleted too. No `timeZone` option means the device's zone, which is the same
// silent fallback wearing a different name.
export function formatTimeRange(startIso: string, endIso: string): string {
  const start = new Date(startIso);
  const startLabel = start.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
  // ...
}

Then we went looking for their callers and there were none. Both were exported, and nothing anywhere called either one. They had survived because nothing had ever forced anybody to look at them. An unsafe helper with no callers is not dead weight, it is a loaded default waiting for the first person in a hurry to find it.

The third had plenty of callers and did the most damage. It turned a Date into a YYYY-MM-DD key on the device's clock, and over time the pattern of wrapping a booking's start instant in it had become the idiom for asking which day a booking is on. A dozen call sites across seven screens had picked that up, and in every one of them the phone was answering a question only the business can answer. The function lost its export and became a private one with two remaining callers, both of which build their Date from a date key at local noon and therefore have no zone left to get wrong.

ts
/**
 * A YYYY-MM-DD key for a Date read on the DEVICE's clock.
 *
 * Deliberately not exported. It used to be, and it became the idiom for "which
 * day is this booking on" across a dozen screens, which is the device's answer,
 * not the salon's. `dateKeyInZone` is the one that takes an instant.
 */
function localDateKey(date: Date): string {

Deleting is the right answer more often than it feels like it is. A helper you cannot make safe is not a helper. Handing it a default so it keeps compiling is exactly how the original bug got written, and doing it twice in the same afternoon would have been hard to explain.

Where the old default lives now

One function still reads the runtime's own clock, and it is named for it. Nothing else does.

ts
/**
 * The IANA zone the phone itself is set to.
 *
 * The answer when there is genuinely nothing better: a business this device has
 * never loaded. It is the old silent default, kept in exactly one named place so
 * that using it is a decision rather than a forgotten argument.
 */
export function deviceTimeZone(): string {
  try {
    const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
    if (zone) return zone;
  } catch {
    /* A runtime without zone-aware Intl. UTC is wrong, but it never drifts. */
  }
  return "UTC";
}

Behaviourally this is close to what the optional parameter used to do. The difference is entirely in who chose it. Before, a screen got the device's clock by not typing anything. Now it gets the device's clock by importing a function whose name says what it is, from a file whose comment explains when that is acceptable. Same value, different failure mode, because a forgotten argument is invisible in review and an explicit call is not.

One more fallback survives a level down, inside the function that reads an instant in a zone: if a runtime's internationalisation data cannot resolve the zone it was handed at all, it falls back rather than throwing. That is a different failure from a missing argument, because somebody did supply a zone and the platform could not honour it. Worth keeping, and worth keeping inside the one function that has to deal with it.

Say the number in the test

A test that asserts a booking email contains a time will pass on the bug. The assertion has to name both clocks and pick one.

ts
/** 2026-06-15 04:00 UTC = 16:00 Auckland (NZST), 12:00 Perth (AWST). */
const START = new Date("2026-06-15T04:00:00Z");

it("a branch booking is emailed on the BRANCH's clock", async () => {
  // Sending a Perth client "4:00 pm" is not a cosmetic slip: they arrive four
  // hours late, and the email looks perfectly correct while they do it.
  await sendBookingConfirmation(await booking({ locationId: perthId }));
  const t = lastEmailText().toLowerCase();
  expect(t).toContain("12:00");
  expect(t).not.toContain("4:00 pm");
});

it("a booking with no branch keeps the org's clock", async () => {
  await sendBookingConfirmation(await booking({ locationId: null }));
  expect(lastEmailText().toLowerCase()).toContain("4:00");
});

The negative assertion earns its place. Naming the wrong answer is what makes this a test about this bug rather than a test that a formatter formats. And the second case matters as much as the first. Most businesses have one address, and the change had to be provably invisible to them.

We checked the tests by breaking the code rather than the other way round. These seven cover two fixes that shipped together, the clock and the address on the email. Revert the address one and a single test goes red. Revert the timezone one and two do. Put both back and all seven pass. A test suite that does not fail when you reintroduce the bug is decoration.

The technique, without the timezones

None of this is really about clocks. Five things generalise.

  • An optional parameter is a decision made once, by whoever wrote the signature, and applied silently to every caller who never read it. If the right value differs per caller, the parameter is not optional. It is required and under-specified.
  • The assumption that makes a default correct is usually a fact about your customers, not about your code, and it is nowhere in the type system. Write it in the doc comment at least, so the person who changes it has a chance of seeing what they are changing.
  • A default is safe when getting it wrong is loud, and dangerous when getting it wrong produces a plausible answer. Timezones, currencies, locales and units of measure all fail by looking correct, which is why they are the ones worth making mandatory.
  • When you tighten a signature, let the compiler enumerate the call sites. That list is complete where a text search is not, and every entry on it is a place somebody would otherwise have had to remember.
  • If a function cannot be given the thing it needs, do not give it a default to keep it compiling. Delete it, and send whatever called it to the one that asks the right question. If nothing called it, you have learned something else worth knowing.

What we keep coming back to is that this bug had a delivery date. It was written the day the formatter got a default and it went off the day a business needed a second address, and nothing in between could have caught it, because nothing in between was wrong. An optional parameter with a silent fallback is not a convenience. It is a scheduled failure, and the schedule is set by whoever next changes the assumption you never wrote down.

timezonestypescriptapi designbugsengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs