← Dev log
EduQate·data integrity·2026-07-14

A number that lied in every response

Prisma Decimal serialised as {"s":1,"e":4,"d":[15000]} because a global NestJS response interceptor rebuilt objects with Object.entries and stripped the prototype.

It said it worked

Green, 200, saved, “already handled” — and wrong. No error anywhere to go on.

I was fixing the program-fees endpoint rejecting every field, which turned out to be an import type on the DTO stripping the runtime metadata NestJS's whitelist validation needs. Fixed that, then actually read the response body instead of checking the status code, and the fee amount wasn't a number — it was {"s":1,"e":4,"d":[15000]}, decimal.js's internal representation leaking through.

The global response interceptor exists to stringify BigInts, which can't be JSON-serialized. To do that generically it rebuilds any object-shaped value via Object.entries(), which strips the prototype off anything else object-shaped passing through — including Prisma Decimal. Decimal has a working toJSON()/toNumber(), but only while its prototype survives. So every Decimal in every response was wrong: fees, payments, installments.

Nothing threw, every endpoint kept returning 200. Invisible unless you read a real response body — a typecheck can't catch it, a "does the request succeed" test can't catch it, and reading the calling code won't either, because the corruption happens after the handler, inside shared middleware nobody rechecks once it's done.

Fixed with an explicit instanceof Prisma.Decimal branch before the generic walk. Generic code that tears down and rebuilds arbitrary objects to solve one narrow problem will eventually meet a type it wasn't written for, and when it sits in a global position the blast radius is the whole API at once.