A TypeScript type is a promise. And like any promise, it only holds while someone is around to keep it.
That someone is the compiler. It watches your code, checks every shape, and yells the second something does not line up. It is very good at its job. But it has one hard limit nobody tells you about on day one. It stops at the edge of your process.
The moment your data leaves the building, the compiler is gone. Over the network. Off a queue. Out of a webhook. Back from the database. Your type is still sitting there in the code, looking confident. But now it is just a sticky note that says "trust me." Nothing is checking it anymore.
I learned this the annoying way.
The bug that had no bug
We had two services talking to each other. One sent a payment amount. The other received it. Both sides shared the same type:
interface PaymentEvent {
amount: number;
currency: string;
}
Clean. Typed on both ends. The compiler was happy everywhere I looked.
Then one day the numbers in a report were slightly off. Not crashing-off. Just wrong enough to notice. No error. No stack trace. Nothing red anywhere.
Took us a while to find it. The sending service had started putting amount out as a string. "1000" instead of 1000. Somewhere upstream a value got serialized, went through a queue, and the quotes crept in.
On the receiving side, TypeScript still believed amount was a number. Because we told it so. It never looks at the wire. So "1000" * 100 did something silly, and the type sat there the whole time swearing everything was fine.
The type did not lie on purpose. It just was not there when it mattered.
Where your types are actually guessing
Here is the thing that clicked for me. Inside one process, types are real. The compiler saw the value get made and it saw it get used. It has the whole story.
But the second data crosses a boundary, your type is a guess. A hopeful one. These are the spots where it is guessing:
- Anything you
JSON.parse - A response from another service or a third-party API
- A message off a queue or an event bus
- A webhook you did not send
- A row from the database
-
process.env(every one of those is a string, even the ones you treat as numbers)
In all of those, you hand TypeScript a blob of data and say "this is a PaymentEvent, take my word for it." And it does. That is the whole problem. It takes your word.
The fix is boring, and that is why it works
You stop trusting the label and you check the actual shape at the door. Every time data comes in from outside, you parse it before you use it.
We use Zod for this. You write the shape once as a schema. You get two things back from it: a real runtime check, and the type.
import { z } from "zod";
const PaymentEvent = z.object({
amount: z.number(),
currency: z.string(),
});
type PaymentEvent = z.infer<typeof PaymentEvent>;
Then at the boundary:
const event = PaymentEvent.parse(incoming);
If amount shows up as "1000", this throws right there, at the door, with a clear message. Not three services later in a wrong report. The bad data never gets in.
And look at the last line of the schema. The type comes from the check. You do not write the type by hand and hope the data matches it. The check is the source of truth, and the type just follows along. They cannot drift apart, because they are the same thing.
The rule we live by now
Trust types inside your own process. That is what they are for, and they are great at it.
The moment data comes from somewhere else, treat the type as a wish until you have checked it. Parse at every boundary. Build the type from the parser, not the other way round.
A type tells you what you meant. A parse tells you what you got. On a payments system, the gap between those two is real money.
So next time a value crosses a wire and lands in a nice typed variable, ask one question: who actually checked this? If the answer is "the compiler," the compiler already went home.
Top comments (1)
This is the contract boundary people underestimate. Types are useful inside the process, but once data crosses the network, you need runtime validation, versioning, and failure behavior that assumes the shape can lie.