Logo of my Personal Website
Category: Opinion
Tags:
Zod - Typescript - Full-stack - Security - UX/UI

From 'Double-Work' to Single Source of Truth: Why I switched to Schema-First Development

There was a time in my career where I had two sources of truth. I changed that by using Schema-First Development.

Image is Loading
Hey, I am Stefan!
Stefan Lüllmann

Image is Loading
From 'Double-Work' to Single Source of Truth: Why I switched to Schema-First Development
Picture:From 'Double-Work' to Single Source of Truth: Why I switched to Schema-First Development

I used to view schema validation as a necessary evil. In fact, prior to adopting my current workflow, I often viewed libraries like Zod as unnecessary overhead, essentially "double work" alongside TypeScript interfaces.

My logic was simple: If I have already typed this data structure in TypeScript, why do I need to type it again in a validation schema? For a long time, I maintained two separate files: a TypeScript interface for the compiler, and a Zod Schema for the runtime. As any experienced developer knows, maintaining two sources of truth is a recipe for disaster. It invites bugs, creates code drift and doubles the maintenance burden.

It wasn't until I began architecting my latest project, a "Nebenkostenabrechnung" (Utility Bill) tool, that I realized my approach needed to change.

The Complexity Reality Check

If you are familiar with German bureaucracy, you know that "simple" data structures simply do not exist in this domain.

While planning the architecture for this tool, I was confronted with highly complex requirements:

  • Nested Arrays of meter readings,
  • Data ranges that strictly cannot overlap,
  • and conditional fields that appear only on specific rental contract types.

I realized that for this level of complexity, TypeScript interfaces were insufficient. They are excellent for developer ergonomics, but they are essentially just "suggestions" that vanish at runtime. When dealing with strict financial data, I didn't just need a suggestion; I needed a contract.

I knew Zod belonged in the Tech-Stack, but I still held onto the fear of redundancy. I initially built the first prototype the old way: manually syncing interfaces and schemas. It felt cluttered and inefficient.

The z.infer Epiphany

Frustrated by the redundancy, I took a step back to re-evaluate the documentation. That was when I fully embraced the power of Inference.

I realized I had been approaching the problem backwards. I was treating the static Type as the primary source of truth and the Schema as a secondary utility. By flipping this relationship - making the Zod Schema the only definition and using z.infer to derive the TypeScript type - the friction disappeared.

Here is what the pattern looks like in practice:

typescript
1import { z } from "zod";
2
3// 1. The Schema is the Single Source of Truth
4export const landlordSchema = z.object({
5name: z
6  .string()
7  .min(1, "Name of the landlord is required")
8  .max(100, "Name is too long"),
9street: z
10  .string()
11  .min(1, "Street of the landlord is required")
12  .max(100, "Street must not exceed 100 characters"),
13zip: z
14  .string()
15  .min(5, "ZIP code must have at least 5 characters")
16  .max(10, "ZIP code must not exceed 10 characters"),
17city: z
18  .string()
19  .min(1, "City of the landlord is required")
20  .max(100, "City must not exceed 100 characters"),
21email: z.email("Invalid E-Mail").optional().or(z.literal("")),
22phone: z.string().optional(),
23iban: z
24  .string()
25  .min(15, "IBAN too short")
26  .max(34, "IBAN too long")
27  .optional()
28  .or(z.literal("")),
29bankName: z.string().max(100).optional(),
30bic: z.string().max(11, "BIC too long").optional(),
31});
32
33// 2. The Type is derived automatically
34// No manual interface writing required.
35export type LandLordData = z.infer<typeof landlordSchema>;

This shift created a true Single Source of Truth. Now, when I update a validation rule regarding a meter reading, the TypeScript updates automatically across the entire Full-Stack Application. The "double work" I feared was actually a result of my own architectural choices, not the tools themselves.

Schemas as Living Documentation

Beyond the immediate efficiency gains, there was a secondary benefit I hadn't anticipated: Living Documentation.

In a complex domain like utility billing, a standard interface like amount: number tells a developer very little about the business logic. However, a verbose Zod Schema tells a complete story. It defines min/max values, regex patterns for contract IDs, and custom error messages.

Take a look at this Schema. This code doesn't just define the "Shape"; it defines the "Rules":

typescript
1const MeterReadingSchema = z.object({
2  value: z.number().positive("Meter reading must be positive"),
3  readingDate: z.date(),
4  previousValue: z.number().optional(),
5})
6
7// The business logic lives inside the validation
8.refine((data) => !data.previousValue || data.value >= data.previousValue, {
9  message: "New reading cannot be lower than the previous reading",
10  path: ["value"],
11});

If a new developer joins the project (or if I return to this code in six months), the Schema doesn't just define the shape of the data; it expains the rules of the business. It reduces the need for external documentation because the code documents itself.

Conclusion

This journey from "hating schemas" to relying on them highlighted an important lesson in Software Engineering: often, the pain points we feel are not caused by the tools, but by how we utilize them.

Adopting a Schema-first workflow was the "Aha!" moment for this project. It transformed validation from a redundant chore into a powerful architectural pillar.

If you are interested in the Technical Implementation of this pattern, I recommend reading my Technical deep dive, The Full-Stack Zod Playbook, where I break down the code structure step-by-step.

Thank you for reading. Happy coding! Stefan