Here is a small thing we found by reading our own code carefully, and the decision it led to. It is a good example of a lesson that matters more than it looks: validating the same input twice is not twice the safety. It is one gate and one piece of drift waiting to happen.
What we found
Our API validates request bodies in two places. First, every route declares a Fastify JSON Schema on its schema.body. Fastify checks the incoming request against that schema and, if it is malformed, returns a 400 before the handler runs at all. Second, several handlers also start with a Zod check:
const parsed = CreateParticipantSchema.safeParse(request.body);
if (!parsed.success) return reply.badRequest(parsed.error.message);
Read those two facts together and something falls out: by the time that Zod line runs, Fastify has already rejected anything malformed. The if (!parsed.success) branch is unreachable. It is dead code, a guard for a case that can no longer happen. We are validating the same body twice, and the second validation never fires.
We did not guess this. We grepped for every safeParse call site, confirmed each route also had a Fastify schema.body covering the same fields, and wrote it down so it would not be rediscovered from scratch.
Why it happened, and why it is not free
It happened the way most redundancy happens: two reasonable habits met in the middle. Fastify's schema is the framework's own way to validate, and it is declared where the route is declared. Zod is the popular TypeScript way to validate, and it is easy to reach for out of habit. Neither is wrong on its own. Together they describe the same input twice.
And two descriptions of one thing is not safety, it is a drift risk. The day someone adds a field to the Fastify schema but not the Zod one (or the reverse), the two disagree, and now the truth about "what is a valid participant" depends on which validator you read. Redundant validation quietly turns one clear rule into two rules that can fall out of sync.
The decision
We kept one runtime gate and gave each tool the job it is actually good at:
- Fastify JSON Schema stays the runtime validator. It sits at the framework boundary, runs before the handler, generates our OpenAPI document, and drives the response serializer. It earns its place.
- Zod stays for type inference, not runtime checking. Its real value here is
z.infer<typeof CreateParticipantSchema>, which gives the handler a TypeScript type for the input from a single source. That is worth keeping. - The redundant
safeParsebranches go. One caveat before deleting: confirm the check is not enforcing something the Fastify schema cannot express, a cross field rule, say. If it is, move that specific check rather than dropping it.
So the shape we are moving to is: one validator at runtime, one source of types, no overlap.
The lesson
The instinct that "more validation is safer" is wrong often enough to be worth unlearning. Two validators for one input are not a belt and braces; they are two belts that can be buckled to different notches. The discipline is:
- One source of truth per concern. One thing decides what a valid request is.
- Know why each layer exists. We did not remove Zod because Zod is bad; we removed the duplicate runtime check because Fastify already does it, and we kept the type inference because nothing else does it. Reach for a tool for the job it is best at, not out of habit.
- Find redundancy by reading, then write it down. This was not caught by a test failing; it was caught by reading two facts and noticing they could not both matter. When you find dead code, record it so the next person does not have to rediscover it.
Deleting code you understand is one of the most satisfying things you will do as an engineer. This is a small, safe, well understood deletion, which is exactly why it makes a good first issue.


