One Schema, Many Projections: Using Zod as the Canonical Model
One pattern I've been using more lately is defining a model once and then treating everything else as a derivation or projection of that model. This isn't necessarily a new idea, since we've been talking about a "single source of truth" forever, but I think it's become a lot more useful with TypeScript, shared packages, monorepos, code generation and now AI writing a much larger amount of application code.
The main idea is pretty simple. Define the model once in the richest representation you have, then generate or derive everything else from it. For TypeScript applications, I think Zod is a really good place to put that canonical definition because it gives you both runtime validation and static types from the same object.
The Problem With Defining the Same Model Over and Over
A pretty normal application ends up defining the same thing several different ways. You might start with a TypeScript interface:
interface Player {
name: string;
email: string;
active?: boolean;
}
Then you have something similar in your database:
const PlayerSchema = new Schema({
name: String,
email: String,
active: Boolean,
});
Then validation:
const playerValidator = z.object({
name: z.string().min(1),
email: z.string().email(),
active: z.boolean().optional(),
});
Then your frontend form has another definition, the API documentation describes the same fields again and maybe your tests have some factory that also knows what a valid Player is. None of these are necessarily wrong, but you now have several different things that are supposed to represent the same model.
Eventually somebody updates one and forgets another. Maybe the frontend says a field is optional while the API says it's required, or TypeScript says something is valid but your runtime validator rejects it. The problem isn't really writing the code, it's keeping all of these definitions synchronized.
Making Zod the Canonical Schema
A simplified Player schema could look like this:
import { z } from 'zod';
export const PlayerSchema = z.object({
id: z.string(),
name: z.string().min(1),
email: z.string().email(),
teamId: z.string(),
active: z.boolean().optional(),
});
export type Player = z.infer<typeof PlayerSchema>;
Right away the TypeScript interface goes away because the type is derived directly from the schema:
type Player = z.infer<typeof PlayerSchema>;
At the same time, you also have runtime validation:
PlayerSchema.parse(data);
That by itself is already useful, but I think the more interesting part is continuing this idea further. Instead of only generating the TypeScript type, the Zod schema becomes the canonical representation and everything else becomes a projection of it.
┌─ TypeScript types
│
├─ API validation
│
Zod canonical ──────┼─ Forms
schema │
+ metadata ├─ Database models
│
├─ OpenAPI
│
├─ Documentation
│
├─ Test fixtures
│
└─ AI schemas
Some of these projections are basically free and some require a little bit of code generation, but the important part is that they all start from the same place.
Sharing the Same Schema on the Frontend and Backend
This gets especially useful in a monorepo. You can put the canonical schema in a shared package and both the frontend and backend import it.
packages/
schemas/
player.ts
Then both sides can use:
import {
PlayerSchema,
type Player,
} from '@staty/schemas';
The API knows what a Player is, the frontend knows what a Player is and a form can use the exact same validation rules. Instead of keeping the frontend and backend "in sync", they are actually using the same object.
For example, React Hook Form can use the same schema directly:
useForm({
resolver: zodResolver(PlayerSchema),
});
Or if a form only uses part of a model, you can derive another schema from it:
const EditPlayerSchema = PlayerSchema.pick({
name: true,
email: true,
});
I like this a lot more than creating something like EditPlayerFormValidator, EditPlayerDTO and EditPlayerType separately and hoping they continue to match six months from now.
Parse at the API Boundary
Another part of this pattern is parsing input at the API boundary instead of trusting it. This is something TypeScript by itself doesn't really solve.
This:
const player = req.body as Player;
doesn't validate anything. It basically just tells TypeScript to trust you.
Instead:
const result = PlayerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(result.error);
}
const player = result.data;
Now you have runtime validation and typed output at the same time. Zod objects also strip unknown properties by default, which makes this useful as a payload whitelist before data ever reaches the database.
If somebody sends:
{
"name": "Taku",
"email": "[email protected]",
"admin": true
}
and admin isn't part of the schema, it doesn't become part of the parsed object. In Staty I use a similar idea when sanitizing model payloads before they reach the database, and I think this is one of the better places to enforce that kind of behavior.
Zod Can't Describe Everything
The obvious limitation is that Zod doesn't know everything your database needs to know. A teamId might reference a Team, an email might need a unique index, a field might need an index or MongoDB may own _id.
Those aren't really validation concerns, so I don't think they need to be forced into the schema itself. I think of the canonical representation more like:
Zod schema
+
metadata
For example:
export const playerMetadata = {
email: { unique: true },
teamId: { ref: 'Team', index: true },
};
Zod 4 has better support for metadata and registries now, which makes this general approach cleaner, but database-specific concepts still need some conventions of your own. The important part to me is that I still don't want to manually redefine the entire model in another place just because the database needs a few extra details.
Generating the Database Model
This is where it starts becoming more interesting. Instead of maintaining a PlayerSchema in Zod and then separately maintaining a PlayerMongooseSchema, you can derive the Mongoose representation from the canonical schema.
A very simplified converter could look something like this:
function zodToMongoose(field) {
if (field instanceof z.ZodString) return { type: String };
if (field instanceof z.ZodNumber) return { type: Number };
if (field instanceof z.ZodBoolean) return { type: Boolean };
return { type: Schema.Types.Mixed };
}
Then you can walk the Zod object:
function toMongooseSchema(schema) {
return Object.fromEntries(
Object.entries(schema.shape).map(([name, field]) => [
name,
zodToMongoose(field),
])
);
}
And create the model from the generated result:
const Player = model(
'Player',
new Schema(toMongooseSchema(PlayerSchema))
);
Obviously the real implementation has to understand optional values, arrays, nested objects, dates, refs, indexes and other things. But I don't think putting a 100-line converter into a blog post makes the concept any clearer.
The part I care about is the direction.
Zod
↓
Mongoose
Instead of:
Zod ← developer → Mongoose
The second version has two independently maintained definitions. The first one has a canonical definition and a projection.
Mongoose, Prisma and Drizzle Are Not Equal Here
Mongoose is probably the cleanest fit for this pattern because its schemas are runtime JavaScript objects. You can inspect the Zod schema and turn it directly into a Mongoose schema without having to generate an intermediate file.
For example:
teamId: z.string()
plus:
teamId: { ref: 'Team' }
could become:
teamId: {
type: Schema.Types.ObjectId,
ref: 'Team',
}
Prisma is a little different because Prisma expects its own schema DSL. So the flow is more like:
Zod
↓
generator
↓
schema.prisma
↓
Prisma
The generated output might look like:
model Player {
id String @id
name String
email String @unique
teamId String
active Boolean?
}
If I was doing this with Prisma, I would probably commit the generated schema.prisma and have CI make sure it hasn't drifted:
pnpm generate:prisma
git diff --exit-code
If somebody changes the canonical schema but forgets to regenerate Prisma, the build fails.
Drizzle sits somewhere in the middle because its tables are TypeScript runtime builders. A table might look like this:
const players = pgTable('players', {
name: text('name').notNull(),
email: text('email').notNull().unique(),
active: boolean('active'),
});
So you can write a converter that maps Zod fields into the appropriate Drizzle builders without necessarily generating a separate DSL file.
function zodToDrizzle(name, field) {
if (field instanceof z.ZodString) return text(name);
if (field instanceof z.ZodNumber) return integer(name);
if (field instanceof z.ZodBoolean) return boolean(name);
}
I think it's important to be honest about the ergonomics here. Mongoose is probably the easiest fit, Drizzle is a pretty good fit and Prisma works but requires more traditional code generation.
Also, this doesn't magically make your database interchangeable. Queries, relations, transactions, migrations, indexes and database-specific features are still going to leak into the application. What it does is remove one pretty large area of duplication, which is the basic definition of what your data looks like.
Canonical Doesn't Mean Every Layer Uses the Exact Same Schema
Another distinction I think is important is that a canonical schema doesn't mean every part of your application uses the entire schema. A database Player might contain id, created, updated and internalNotes, while a registration form only needs name, email and teamId.
That is where projections are useful.
const CreatePlayerSchema = PlayerSchema.omit({
id: true,
});
const EditPlayerSchema = PlayerSchema.pick({
name: true,
email: true,
});
const PublicPlayerSchema = PlayerSchema.omit({
internalNotes: true,
});
These are different schemas, but they are intentionally different and they all have a relationship back to the canonical model. That's a lot different than three developers independently creating three separate definitions that just happen to look similar.
Generating More Than Code
Once you have a canonical machine-readable schema, the generation doesn't really need to stop at database models. The same information can be used for API documentation, OpenAPI, test fixtures, environment variable validation and other generated artifacts.
If the schema says:
email: z.string().email()
your documentation generator already knows it's a string and that it must be an email. You can also generate valid fixtures for testing rather than having every test manually decide what a valid Player looks like.
const player = generateFixture(PlayerSchema);
I've also been using this same general idea for documentation in Staty. Once the schema and application metadata are machine-readable, that same source can generate developer documentation and even content used by an AI help assistant.
Canonical Schema
│
├── API docs
├── developer docs
└── AI retrieval corpus
I think that gets pretty interesting because when the actual application changes, the documentation used by the AI can change with it. The AI doesn't have to rely as much on some Markdown document somebody wrote months ago and forgot to update.
AI Makes This More Important, Not Less
This is probably the main reason I've been thinking about this pattern more lately. AI can generate a lot of code very quickly, which is obviously useful, but it can also create duplication very quickly.
An AI agent can easily create:
Player.ts
PlayerDTO.ts
PlayerRequest.ts
PlayerResponse.ts
PlayerForm.ts
PlayerValidator.ts
PlayerModel.ts
Each file can be perfectly reasonable by itself, but now you have seven representations of basically the same thing. The cost of writing all of these has dropped close to zero, but the cost of keeping them synchronized has not.
It also creates a context problem for AI. If an agent is trying to change a Player, it now has to figure out which of those seven files is authoritative and which ones are just copies or variations.
I would rather give the agent a much simpler rule:
This is the canonical schema. Do not redefine the model. Derive from it.
That's easier for developers to understand and it's also much easier to put into something like AGENTS.md for AI coding agents.
Schemas Are Starting to Become Inputs for AI Too
There's another side of this that didn't really exist in the same way before. Schemas aren't only useful for normal application code anymore.
LLMs increasingly use structured output and tool calling, which means you are already describing the shape of data you expect the model to return. The same schema that describes a Player to your frontend and backend can potentially also describe that Player to an AI model.
So instead of:
PlayerSchema
│
├── TypeScript
├── API
├── database
└── frontend
you increasingly have:
PlayerSchema
│
├── TypeScript
├── API
├── database
├── frontend
└── AI
That makes the investment in having a clean canonical schema a lot more valuable.
Be Careful With Defaults
One issue I ran into with this approach was defaults. Something as simple as this can look harmless:
active: z.boolean().default(false)
But if your database generator reads that and produces:
active: {
type: Boolean,
default: false,
}
you have now turned a validation default into persistence behavior.
We ran into a version of this with configuration inheritance where there were effectively three different states:
undefined → inherit from parent
false → explicitly disabled
true → explicitly enabled
Those three values had different meanings. Once .default(false) caused undefined to become false, the application could no longer tell the difference between something that had never been configured and something somebody explicitly disabled.
That broke inheritance behavior in a way that wasn't very obvious at first. Since then I've become a lot more conservative about putting defaults into the canonical schema.
My general rule now is that defaults should belong to the consumer unless the default is really universal.
If a form should initially show an unchecked checkbox:
const formDefaults = {
active: false,
};
that's a frontend concern.
If MongoDB should add a value when a document is created:
const databaseDefaults = {
active: false,
};
that's a database concern.
The canonical schema should describe what the data is and what is valid. It shouldn't accidentally make business decisions for every layer consuming it.
Why I Think This Matters More Now
Before AI, duplication had a pretty obvious cost because somebody had to manually sit there and write all of it. Now AI can generate an interface, validator, database model, API type, form schema, tests and documentation almost instantly.
I don't think that means architecture like this matters less. I think it matters more.
The problem is changing from:
How do we write all of this code?
to:
How do we make sure all of this code stays consistent?
You can always ask an AI agent to update five different definitions every time a model changes. I'd rather not have five independent definitions in the first place.
The pattern I've been settling on is pretty straightforward: define the model once, derive everything you reasonably can from it, validate data at the boundaries, keep consumer-specific behavior outside the canonical schema and use CI to make sure generated artifacts don't drift.
Zod works pretty well for this in TypeScript because it sits in an interesting middle ground. It's not your database, ORM, form library or API documentation, but it understands what the data looks like, how it should be validated and what its TypeScript type should be.
That makes it a pretty good canonical layer. Everything else can be a projection of that definition, and as more code gets written and maintained with AI, I think having a clear answer to "where is the real definition of this model?" becomes a lot more important.