September 21, 2026
Achieving End-to-End Type Safety: React to Node to Postgres

Modern full-stack TypeScript applications can eliminate an entire class of bugs by making the database schema the source of truth and propagating its contracts through the application.
The important distinction is this:
TypeScript protects your code at compile time. Zod protects your system at runtime.
Used together with PostgreSQL, Drizzle ORM, Node.js, and React, they create a contract pipeline where changes to the underlying data model propagate through the application instead of silently breaking at runtime.
1. Introduction & The Problem
Consider a fairly ordinary application.
You have:
- PostgreSQL storing users.
- Node.js/Express exposing
/api/users. - React consuming that API.
- TypeScript everywhere.
The original database schema contains:
sqlCREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, age INTEGER NOT NULL );
The backend might return:
json{ "id": 42, "name": "Ali", "age": 22 }
And the frontend assumes:
typescripttype User = { id: number; name: string; age: number; };
Everything works.
Then somebody changes the database:
sqlALTER TABLE users ALTER COLUMN age TYPE TEXT;
Now the API may return:
json{ "id": 42, "name": "Ali", "age": "22" }
But the React application still believes:
typescriptuser.age + 1
means:
text23
Instead, JavaScript can produce:
text"221"
Or perhaps the application does:
typescriptuser.age.toFixed(0);
and crashes because toFixed doesn't exist on a string.
The dangerous part isn't that TypeScript failed.
TypeScript was never given the opportunity to see the database change.
The database, backend, and frontend each had independent representations of the same concept.

A stronger architecture establishes a chain:
textPostgreSQL │ ▼ Drizzle schema │ ▼ TypeScript types + Zod schemas │ ▼ Node.js API │ ▼ JSON over HTTP │ ▼ React
The stack we'll use is:
textPostgreSQL ↓ Drizzle ORM ↓ drizzle-zod / Zod ↓ Node.js + Express ↓ Shared TypeScript/Zod contracts ↓ React + react-hook-form
There is one important caveat:
TypeScript types do not cross an HTTP boundary.
A TypeScript type disappears after compilation.
Therefore, an architecture that relies only on TypeScript is not actually end-to-end type-safe. Runtime validation is required at the boundary.
2. Step 1: The Database Layer (Drizzle ORM + Postgres)
The database should be treated as the ultimate source of truth for persisted data.
For this example, we'll use PostgreSQL with Drizzle ORM.
Why Drizzle?
Both Drizzle and Prisma provide excellent TypeScript experiences, but they take somewhat different approaches.
Prisma generally presents a higher-level ORM abstraction around your database.
Drizzle is deliberately much closer to SQL.
For example:
typescriptconst users = await db .select() .from(usersTable) .where(eq(usersTable.id, userId));
The TypeScript types are derived directly from the schema definition.
That means the database model and TypeScript representation don't have to be maintained separately.
Define the schema
A Drizzle PostgreSQL schema might look like this:
typescriptimport { pgTable, serial, varchar, integer, timestamp, } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: serial("id").primaryKey(), name: varchar("name", { length: 100, }).notNull(), email: varchar("email", { length: 255, }).notNull().unique(), age: integer("age").notNull(), createdAt: timestamp("created_at") .defaultNow() .notNull(), });
Notice that important constraints are represented directly in the schema:
typescriptvarchar("name", { length: 100, })
means the database column has a maximum length of 100 characters.
Likewise:
typescriptinteger("age")
means Drizzle understands that this column is represented as a JavaScript number.
And:
typescript.notNull()
communicates nullability.
The schema is therefore doing more than describing tables.
It is becoming the foundation for the application's type system.
Inferring TypeScript types
Drizzle can infer the type returned when selecting a user:
typescriptimport type { InferSelectModel, InferInsertModel, } from "drizzle-orm"; export type User = InferSelectModel<typeof users>; export type NewUser = InferInsertModel<typeof users>;
Conceptually:
typescripttype User = { id: number; name: string; email: string; age: number; createdAt: Date; };
And the insert type is different:
typescripttype NewUser = { name: string; email: string; age: number; createdAt?: Date; };
The distinction is important.
The database generates one shape when reading data and accepts another shape when inserting data.
This is the first major benefit of the architecture:
textPostgres schema ↓ Drizzle schema ↓ TypeScript inference
There is no manually duplicated:
typescripttype User = ...
floating around somewhere else that can silently become stale.
3. Step 2: The Validation Layer (Zod + Node.js)
At this point we have compile-time safety.
But there is still a problem.
Suppose a client sends:
json{ "name": 123, "email": "not-an-email", "age": "twenty-two" }
TypeScript cannot protect the server from this.
Why?
Because the request originated outside the TypeScript program.
HTTP gives us runtime values.
The server receives something like:
typescriptunknown
conceptually.
We need runtime validation.
This is where Zod enters the architecture.
Static types vs runtime validation
TypeScript:
typescripttype CreateUser = { name: string; email: string; age: number; };
protects code like:
typescriptconst user: CreateUser = { name: "Ali", email: "ali@example.com", age: 22, };
But it does nothing to validate:
typescriptconst body = req.body;
because req.body came from the network.
Zod solves that boundary problem:
typescriptconst result = schema.safeParse(req.body);
Now the application can establish a runtime guarantee.
Generate Zod schemas from Drizzle
The drizzle-zod package can generate Zod schemas from Drizzle table definitions.
For example:
typescriptimport { createInsertSchema } from "drizzle-zod"; import { users } from "./schema"; export const insertUserSchema = createInsertSchema(users);
This gives us a Zod schema based on the database schema.
We can then use:
typescriptinsertUserSchema.parse(req.body);
or, preferably for API handling:
typescriptconst result = insertUserSchema.safeParse(req.body);
This means we aren't maintaining two completely independent definitions:
textDrizzle schema + Manual Zod schema
Instead:
textDrizzle schema ↓ drizzle-zod ↓ Zod schema
Customizing validation
Database constraints and API validation aren't always identical.
For example, PostgreSQL may allow a string while the API requires a particular semantic format.
Zod can refine the generated schema:
typescriptimport { createInsertSchema } from "drizzle-zod"; import { users } from "./schema"; export const createUserSchema = createInsertSchema(users, { email: (schema) => schema.email("Invalid email address"), age: (schema) => schema.int().min(13).max(120), });
Now we have two categories of constraints:
textDatabase constraints ↓ Data integrity Zod/API constraints ↓ Input validation
They overlap, but they aren't necessarily identical.
Node.js controller
A simple Express controller can now look like this:
typescriptimport type { Request, Response } from "express"; import { db } from "../db"; import { users } from "../db/schema"; import { createUserSchema } from "../schemas/user"; export async function createUser( req: Request, res: Response ) { const result = createUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: "Invalid request body", issues: result.error.flatten(), }); } const user = await db .insert(users) .values(result.data) .returning(); return res.status(201).json(user[0]); }
The important part is:
typescriptconst result = createUserSchema.safeParse(req.body);
Before validation:
textreq.body │ ▼ unknown / untrusted
After successful validation:
typescriptresult.data
has a statically known type.
Conceptually:
textHTTP JSON │ ▼ req.body │ │ runtime validation ▼ Zod │ ▼ validated CreateUser │ ▼ Drizzle │ ▼ PostgreSQL

This is the critical boundary.
TypeScript tells us what our program believes. Zod checks whether reality agrees.
4. Step 3: The Frontend Layer (React + Type Sharing)
Now we need to get the same contract into React.
There are several ways to do this.
One common architecture is a monorepo:
textmy-app/ ├── apps/ │ ├── web/ │ │ └── React │ │ │ └── api/ │ └── Node.js │ └── packages/ └── contracts/ ├── user.ts └── index.ts
The shared package can contain contracts that are safe to consume from both applications.
For example:
typescript// packages/contracts/user.ts import { z } from "zod"; export const createUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email().max(255), age: z.number().int().min(13).max(120), }); export type CreateUser = z.infer< typeof createUserSchema >;
The important part is:
typescriptexport type CreateUser = z.infer<typeof createUserSchema>;
We don't manually define:
typescripttype CreateUser = { name: string; email: string; age: number; };
The Zod schema is the runtime source of truth.
The TypeScript type is derived from it.
textZod schema │ ├── Runtime validation │ └── TypeScript inference
React + react-hook-form
React can consume the exact same schema.
Install:
bashnpm install react-hook-form zod @hookform/resolvers
Then:
tsximport { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { createUserSchema, type CreateUser, } from "@my-app/contracts"; export function CreateUserForm() { const { register, handleSubmit, formState: { errors }, } = useForm<CreateUser>({ resolver: zodResolver(createUserSchema), }); const onSubmit = async (data: CreateUser) => { await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), }); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("name")} placeholder="Name" /> {errors.name && ( <p>{errors.name.message}</p> )} <input {...register("email")} placeholder="Email" /> {errors.email && ( <p>{errors.email.message}</p> )} <input {...register("age", { valueAsNumber: true, })} type="number" placeholder="Age" /> {errors.age && ( <p>{errors.age.message}</p> )} <button type="submit"> Create User </button> </form> ); }
The interesting part is not the React component itself.
It is the contract:
typescriptcreateUserSchema
The same validation rules can be used at the frontend and backend.
For example:
typescriptname: z.string().min(1).max(100)
means the frontend knows:
textname must be a string name cannot be empty name cannot exceed 100 characters
The backend knows the same thing.
The TypeScript compiler also knows:
typescriptdata.name // string data.email // string data.age // number
We have therefore created three layers of protection:
text┌───────────────────┐ │ PostgreSQL │ │ Database integrity│ └─────────┬─────────┘ │ ┌─────────▼─────────┐ │ Zod │ │ Runtime validation│ └─────────┬─────────┘ │ ┌─────────▼─────────┐ │ TypeScript │ │ Compile-time │ │ guarantees │ └───────────────────┘
5. Step 4: The Cascading Update (The "Aha!" Moment)
This is where the architecture becomes particularly useful.
Imagine the product manager says:
Replace
agewithbirthdate.
Previously:
typescriptage: integer("age").notNull(),
We change the Drizzle schema:
typescriptbirthdate: date("birthdate", { mode: "string", }).notNull(),
Our schema now becomes:
typescriptexport const users = pgTable("users", { id: serial("id").primaryKey(), name: varchar("name", { length: 100, }).notNull(), email: varchar("email", { length: 255, }).notNull().unique(), birthdate: date("birthdate", { mode: "string", }).notNull(), createdAt: timestamp("created_at") .defaultNow() .notNull(), });
The inferred type changes automatically.
Before:
typescripttype User = { id: number; name: string; email: string; age: number; createdAt: Date; };
After:
typescripttype User = { id: number; name: string; email: string; birthdate: string; createdAt: Date; };
Now suppose the backend still contains:
typescriptconst user = await db .insert(users) .values({ name: req.body.name, email: req.body.email, age: req.body.age, });
TypeScript can immediately flag:
textObject literal may only specify known properties, and 'age' does not exist...
The backend code cannot silently continue using the old field.
Now imagine the frontend contains:
tsx<p> Age: {user.age} </p>
The TypeScript compiler also knows that:
typescriptuser.age
no longer exists.
The compiler becomes a change detector.

But there is an important nuance
The compiler can only detect references that are actually connected to the changed type.
Consider this:
typescriptconst response = await fetch("/api/users"); const data = await response.json();
By default:
typescriptdata
does not magically become a User.
fetch() does not know your API's schema.
This is why genuine end-to-end type safety requires more than exporting database types.
You need a typed contract at the network boundary.
For example, the client can validate API responses:
typescriptconst userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), birthdate: z.string(), }); const response = await fetch("/api/users"); const json: unknown = await response.json(); const user = userSchema.parse(json);
Now the frontend isn't simply saying:
typescriptconst user = json as User;
which would merely silence the compiler.
Instead, it is actually checking the runtime response.
That distinction is fundamental:
typescript// NOT runtime safety const user = json as User;
versus:
typescript// Runtime safety const user = userSchema.parse(json);
The first tells TypeScript:
"Trust me."
The second tells the application:
"Prove it."
The Complete Type Flow
At this point, the complete architecture looks like this:
textDATABASE │ │ SQL schema ▼ ┌──────────────┐ │ PostgreSQL │ └──────┬───────┘ │ ▼ ┌──────────────┐ │ Drizzle │ │ Schema │ └──────┬───────┘ │ ┌────────┴────────┐ ▼ ▼ TypeScript types Zod schema │ │ │ │ ▼ ▼ Compile-time Runtime validation │ │ └────────┬────────┘ ▼ ┌──────────────┐ │ Node / Hono │ │ API │ └──────┬───────┘ │ │ HTTP / JSON ▼ ┌──────────────┐ │ Zod │ │ validation │ └──────┬───────┘ │ ▼ ┌──────────────┐ │ React │ │ + RHF │ └──────────────┘
There are two different safety mechanisms operating throughout this system.
Compile-time safety
TypeScript catches:
textWrong property names Wrong primitive types Missing properties Invalid function arguments Incorrect return types Broken refactors
Runtime safety
Zod catches:
textMalformed HTTP payloads Unexpected API responses Invalid user input Third-party data Untrusted JSON
PostgreSQL then provides the final persistence-level constraints:
textNOT NULL UNIQUE FOREIGN KEY CHECK VARCHAR length INTEGER / DATE / TIMESTAMP types
These mechanisms complement each other rather than replacing one another.
6. Conclusion & Gotchas
The goal of end-to-end type safety isn't to make TypeScript somehow "understand the entire Internet."
It is to eliminate unnecessary duplication and make important boundaries explicit.
A robust architecture looks roughly like:
textPostgres ↓ Drizzle ↓ Zod ↓ Node.js ↓ HTTP ↓ Zod ↓ React
And the responsibility of each layer is different:
| Layer | Responsibility |
|---|---|
| PostgreSQL | Persistent data integrity |
| Drizzle | Type-safe database access |
| Zod | Runtime validation |
| TypeScript | Compile-time correctness |
| Node.js | API/business logic |
| React | UI and user interaction |
| react-hook-form | Form state and validation integration |
The real payoff appears during change.
If:
typescriptage: number
becomes:
typescriptbirthdate: string
you don't want five developers manually searching for every occurrence of age.
You want the compiler to tell you where the old contract is still being used.
That is the "aha!" moment.
A schema change becomes a compiler-visible event that propagates through the application.
Gotcha #1: A Monorepo Is Not Free
Sharing contracts between:
textapps/web apps/api packages/contracts
is powerful, but introduces tooling complexity.
You need to configure things such as:
textpnpm workspaces TypeScript project references package exports build ordering module resolution CI builds
A simple project can therefore become unnecessarily complicated if you introduce a monorepo before you actually need one.
For a small application, a shared package can be enough.
For a large application, a monorepo can provide significant organizational benefits.
Gotcha #2: strict: true Matters
None of this works particularly well if TypeScript is operating with loose settings.
Your tsconfig.json should at minimum enable:
json{ "compilerOptions": { "strict": true } }
Without strict type checking, many useful guarantees become weaker.
In particular, be careful with:
typescriptany
If an API response becomes:
typescriptany
you have effectively punched a hole through your type system.
Prefer:
typescriptunknown
at untrusted boundaries:
typescriptconst body: unknown = req.body;
Then narrow it using Zod.
Gotcha #3: Type Sharing Does Not Automatically Mean Runtime Safety
This is perhaps the most important gotcha.
This:
typescripttype User = { id: number; age: number; };
does not validate an HTTP response.
Neither does:
typescriptconst user = response as User;
Type assertions are compile-time instructions only.
If the server sends:
json{ "id": 42, "age": "twenty-two" }
then:
typescriptconst user = response as User;
doesn't transform or validate anything.
The value remains:
typescript{ id: 42, age: "twenty-two" }
Runtime validation requires an actual validator:
typescriptconst user = userSchema.parse(response);
This is why a genuinely robust full-stack TypeScript architecture needs both:
textTypeScript + Runtime validation
rather than treating TypeScript types as a replacement for validation.
Final Architecture
The resulting philosophy is simple:
text┌─────────────────┐ │ PostgreSQL │ │ Source of Truth │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ Drizzle │ │ Schema + Types │ └────────┬────────┘ │ ┌─────────┴─────────┐ ▼ ▼ TypeScript Zod Compile-time Runtime safety safety │ │ └─────────┬─────────┘ ▼ ┌─────────────────┐ │ Node / Hono │ │ API │ └────────┬────────┘ │ HTTP / JSON │ ▼ ┌─────────────────┐ │ Zod │ │ Runtime Check │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ React │ │ + React Hook │ │ Form │ └─────────────────┘
The key principle is not "use TypeScript everywhere."
It is:
Have one authoritative data model, derive types from it, validate untrusted runtime data, and make contract changes visible to the compiler.
That is what turns type safety from a collection of isolated TypeScript annotations into an actual full-stack engineering strategy.