TLDR: If you're writing coercion glue so data can pass your own validator, the validator is the problem.

The Setup

We'd just shipped Zod (a TypeScript schema validation library) validation across every API route in my AI-powered cancer treatment facility finder — a tool that helps patients find cancer treatment facilities.

This was a GOOD thing.

Input validation at API boundaries is table stakes for anything patient-facing. We locked it all down in one focused sprint, and I felt good about it.

Then the session bugs started.

The Over-Tight Schema

When I wired up the schema for the chat endpoint, I typed sessionToken as z.string().uuid().

Seemed reasonable. Session tokens ARE UUIDs. I'd seen them in the database. I wrote what I saw.

But here's what I missed: a UUID validator doesn't just describe the format — it becomes a hard rejection rule at the boundary.

And the real world doesn't hand you a clean UUID every time.

Early in a session, before a token gets issued, the client sends null. Some resume paths sent an empty string. The Zod schema threw. The chat broke. Users hit errors on what should have been a graceful "new conversation" state.

What I Tried First (That Didn't Work)

I added a coercion shim on the client — converting null to undefined before the fetch.

That commit exists: fix(chat): coerce null sessionToken to undefined before sending.

And look, it worked. Technically.

But I'd just written glue code to make data survive my own validator. That's the smell. When you're coercing data to pass your validator, the validator is the bug.

The Fix That Actually Fixed It

One commit later: fix(validation): relax sessionToken from UUID to string.

z.string().uuid()z.string().optional().

The coercion shim came out. The broken session states went away.

And nothing downstream actually broke — because nothing downstream cared that the token was a UUID. It was treated as an opaque identifier. Pass it in, look it up, match it or don't. The shape mattered. The format never did.

Why This Matters

The lesson I keep re-learning: at a system boundary, validate only the constraints your code actually depends on.

A session token's job is to be an opaque string that matches a record in the database. It doesn't need to be a UUID. It doesn't need a specific length. It just needs to be a string.

When I wrote z.string().uuid(), I was encoding an observation about the data as a requirement for the data. Those are different things. Observations can change. Requirements are enforced.

The boundary is already hard enough — two systems, two contexts, data crossing the gap. Don't make it harder by rejecting valid states your own backend produces.

Validate what you depend on. Nothing more.

P.S. The irony: the over-tight schema was itself a security hardening commit. I tightened the wrong thing. Loosening it was the fix AND improved reliability.