When Type Systems Feel Short

Written by

At

noidilin

Fri Mar 13 2026

Blog Post Image

A date range picker bug looked small at first.

A user selected the same start and end date, something like 02-20 -> 02-20, and expected to see every record from that day. The UI looked correct. The picker returned valid JavaScript Date objects. The frontend sent valid ISO strings. The backend accepted them.

Everything was technically valid.

And still, records from later on 02-20 were missing.

That was the uncomfortable part of the bug for me. My first instinct was to look at the component: maybe the date picker was wrong, maybe the form value was wrong, maybe something was off in the display. But after tracing the value across the system, the picker was not the real problem.

The problem was that a valid Date value crossed a boundary while carrying the wrong meaning.

What I wanted to understand was:

  • Why did a same-day filter exclude records from that same day?
  • What did the UI value mean compared with the API value?
  • Where should timezone and business-day semantics be encoded?
  • Why did the type system not protect us from this bug?

What I learned was that frontend bugs are often not caused by invalid values. They are caused by valid values whose meaning changes between layers.

The mental model I want to keep is:

UI state stores intent
transport formats store protocol
boundary mappers preserve meaning

In this case, the missing boundary was between a user-selected calendar date range and API-ready ISO instants.

What the user meant

When the user selected:

02-20 -> 02-20

They were not thinking about timestamps.

They meant:

show me records from the full business day of February 20

For this admin app, there was also a business rule hiding behind that sentence: calendar dates should be interpreted in Asia/Tokyo.

So the user's intent was more precisely:

from 2026-02-20 00:00:00.000 Asia/Tokyo
to   2026-02-20 23:59:59.999 Asia/Tokyo

But that meaning was not represented explicitly in the value we sent to the API.

The frontend had Date objects from the picker and called toISOString() on them. That created valid ISO timestamps, but it did not encode the rule that the end date should mean the end of the Tokyo calendar day.

So some endpoints interpreted the end value as an exact instant near the start of the day. Records later on that date fell outside the filter.

What the API received

The API did not receive “the full Tokyo business day.”

It received exact ISO instants.

That is a different contract.

The UI model was:

selected calendar date range

The API contract was:

inclusive ISO timestamp boundaries

Those two models are related, but they are not interchangeable.

That was the core mistake. I treated Date -> ISO string as a harmless conversion because both sides were valid types. But the conversion skipped the business rule that gave the date range its meaning.

This was not just a timezone bug. It was a boundary bug.

The fix: make the boundary explicit

The fix was to introduce a small translation layer in src/lib/date-range.ts.

That layer had two jobs:

  • serializeDateRange() converts picker dates into inclusive Tokyo day boundaries for the API
  • parseDateRange() converts stored ISO values back into picker-friendly Date objects while preserving the Tokyo calendar day

The important part was not the amount of code. The important part was the ownership.

Instead of letting the form or request code casually call toISOString(), the boundary layer became the place where the meaning is made explicit:

picker date range -> Tokyo business-day range -> API ISO timestamps

And in the other direction:

stored ISO timestamps -> Tokyo calendar days -> picker values

That round trip matters. If the UI cannot reconstruct the same user intent after saving, loading, and parsing the data, then the boundary is leaking meaning.

Why the type system did not save us

This bug made the title of the post feel real to me: sometimes the type system feels short.

Not useless. Just short of the actual problem.

A JavaScript Date can represent an instant. It can also be used by UI code to represent a selected calendar day. Those are very different concepts, but the type name is the same.

An ISO string is valid transport data. But it does not say whether it represents:

  • an exact event time
  • the start of a business day
  • the end of a business day
  • a UTC boundary
  • a Tokyo calendar boundary

The type can say “this is a string” or “this is a Date.” It cannot automatically say “this value still means what the user meant when they clicked February 20.”

That is the gap where the bug lived.

The frontend lesson

The useful shift for me was this:

The UI model is not the API model.

The UI should capture user intent. The API contract should capture what the backend expects. If those two shapes are different, passing values through directly is not simplicity. It is an unowned boundary.

Before this bug, I might have looked at the flow and thought:

  • picker returns Date
  • request needs ISO string
  • call toISOString()
  • done

Now I want to ask different questions:

  • Is this Date an instant or a calendar day?
  • Which timezone defines that calendar day?
  • Is the end boundary inclusive?
  • Does the backend interpret the value the same way the user does?
  • Can the UI round-trip the value and recover the same intent?

That feels like a more mature frontend habit. Not because it adds complexity everywhere, but because it notices where meaning can be lost.

A checklist for next time

When I run into similar boundary bugs, I want to trace the value through the whole system:

  1. What does the user think they selected or entered?
  2. What exact value does the UI store?
  3. What exact value is sent over the network?
  4. What does the backend think that value means?
  5. When the data comes back, can the UI reconstruct the same user intent?

If the answer to the last question is no, the round trip is broken.

That usually means the boundary layer is missing, too thin, or mixing transport details with domain meaning.

What I want to carry forward

The concrete bug was a same-day date range filter. The reusable lesson was about meaning at frontend boundaries.

My refined model is:

  • establish the canonical business meaning first
  • keep UI models focused on user intent
  • keep API contracts focused on transport requirements
  • add explicit serializer/parser helpers at the boundary
  • test the full round trip, not just the individual values

The mistake was not using Date. The mistake was assuming a technically valid conversion also preserved the user's intent.

That is what I want to remember: types are necessary, but they are not the whole design. A value can pass every type check and still mean the wrong thing after it crosses a boundary.

The unresolved question I want to keep exploring is how far to push the type system here. Should concepts like TokyoCalendarDate, InclusiveDateRange, or ApiInstant become explicit types in the codebase, or is a well-owned boundary mapper enough for most frontend work?