TypeScript killed Mr. Values, in the insert statement, with the overload mismatch.
Wrong. It was the schema, in the validator, missing two parentheses, three lines away from the scene of the crime.
Here’s how the interrogation actually went. I’m adding an item to a cart in a small Hono API, and the moment I try to insert it, TypeScript throws this at me:
No overload matches this call.
Overload 1 of 2, gave the following error.
Type 'unknown' is not assignable to type 'number | SQL<unknown> | Placeholder<string, any>'.
Overload 2 of 2, gave the following error.
Object literal may only specify known properties, and 'cartId' does not exist in type...
All of it pointing at .values({ cartId: cart.id, productId, quantity }). So that’s where I started asking questions.
I hover over cart. Clean alibi, exactly the shape it should be. I hover over productId, right there in the .values() call. Also clean, at least from that angle. My exact words: “Still looks okay…” Two suspects cleared, zero leads, and the error message still insisting the crime happened right here, at this exact address.
It wasn’t until I backed all the way up to where productId actually got pulled out of the request, const { sessionId, productId, quantity } = result.data, that hovering over it finally showed something wrong: const productId: unknown. Unknown. Not a number, not even a suspiciously-shaped number. My reaction: “Something odd… should it not have pulled up line 21?”
That led straight to the actual scene of the crime, the Zod schema that had defined this field from the start:
productId: z.number().int().positive,
No parentheses after .positive. That one missing pair doesn’t call the validator. It hands Zod the bare function itself and walks off whistling, and the whole schema’s inferred type quietly stops meaning anything from that point forward. No red squiggly anywhere near the actual mistake. The compiler didn’t say a word until three call-frames later, at the one spot where the now-unknown type finally collided with something demanding a real number. That’s the address it gave me. Not the crime scene. Just where the body turned up.
Once I found it and typed the two missing characters back: “All squigglies are gone.” One keystroke, case closed.
Here’s the part that actually stuck with me longer than the joke did. This is the exact lesson from week one of CalPoly’s Intro to Programming, thirty years ago now: trace a bug back to where a variable’s value actually came from, not where it finally caused trouble. Some lessons just wait three decades for you to need them again.
What Got Built While All That Was Happening
The actual app underneath this bug hunt is a small product catalog and shopping cart API, built on Hono running on Node, written in TypeScript, backed by Postgres, with Drizzle as the ORM and Zod handling every bit of request validation, including the schema that started this whole investigation. Tests run through Vitest, six of them, against a real running server and a real database, no mocks anywhere. Package management is pnpm.
The core behavior is simple on purpose: add a product to a cart, and the app either finds your existing cart by session ID or spins up a new one on the spot. Call it again with the same session ID and a different product, and it correctly reuses the same cart instead of creating a second one. Pull the cart back, and every line item comes back joined with its live product name, price, and currency, not just a bag of foreign key IDs waiting to be looked up separately.
A few decisions underneath that simple surface are worth calling out on their own.
Money lives as integers, never floats. Every price is stored as priceCents, a plain integer, alongside an explicit currency column. Floating-point binary storage can’t represent most decimal fractions exactly, so 19.99 doesn’t round-trip cleanly through a float the way 1999 always does as an integer. Formatting to a display string like "$19.99" is left entirely to whatever eventually renders the page, not the API. The API only ever hands back raw numbers.
Foreign keys got tested on purpose, not just trusted. Try to add a cart item pointing at a product ID that doesn’t exist, and Postgres itself refuses the write with a real constraint violation. The first time that happened, it surfaced as a completely unhandled 500, a raw stack trace with a Postgres error code buried inside it, exactly the wrong thing to hand a client. The constraint was doing its job. The API around it wasn’t. The fix was an explicit existence check before the insert even gets attempted, so a bad productId now comes back as a clean 404 with an actual readable message, while the database constraint stays right where it was, as the real safety net underneath the friendlier error.
Deletes got the same deliberate attention. Deleting a cart cascades down and deletes its cart items, since a cart item makes no sense floating around without its cart. Deleting a product does not cascade to anything referencing it, on purpose, since silently vanishing a product out from under someone’s cart history is a worse failure mode than just blocking the delete outright.
The migration bookkeeping bit back once, hard. After changing a foreign key’s delete behavior mid-build, I deleted an already-generated but unapplied migration file by hand, meaning to regenerate it cleanly. Drizzle doesn’t actually diff against the live database when it generates a new migration. It diffs against its own internal snapshot history sitting in drizzle/meta/, completely separate from whatever .sql files happen to exist on disk. Deleting the file without also touching its snapshot entry left phantom state behind, and the next generate came back as a confusing ALTER TABLE ... DROP CONSTRAINT instead of the clean CREATE TABLE it should have been. My reaction in the moment: “Something feels very wrong.” It was. Fixing it meant manually deleting orphaned snapshot files and hand-editing a journal file to strip out the phantom entries, just to get Drizzle’s bookkeeping back in sync with what Postgres actually had.
Even installing the thing came with a plot twist. Fresh scaffold, and the generator’s own dependency install step failed silently. Running the install manually surfaced the real message: pnpm, as of version 10, blocks a package’s install-time build scripts by default now, as a supply-chain safety measure, and esbuild needed explicit approval to run its own postinstall step. Not a broken install. Not an old version needing an update. A brand new security gate that every project hits exactly once, right at the start, when you’re least prepared for a security lecture.
None of these were huge. All of them were the kind of thing that eats twenty minutes if you don’t already know the shape of the trap.
The actual headline, buried under all of it: built this whole thing, start to finish, tests passing, in under two days. And genuinely had fun doing it, parentheses-related crime scenes included.