Ali Raza

September 21, 2026

Achieving End-to-End Type Safety: React to Node to Postgres

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:

sql
CREATE 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:

typescript
type User = { id: number; name: string; age: number; };

Everything works.

Then somebody changes the database:

sql
ALTER 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:

typescript
user.age + 1

means:

text
23

Instead, JavaScript can produce:

text
"221"

Or perhaps the application does:

typescript
user.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:

text
PostgreSQL │ ▼ Drizzle schema │ ▼ TypeScript types + Zod schemas │ ▼ Node.js API │ ▼ JSON over HTTP │ ▼ React

The stack we'll use is:

text
PostgreSQL ↓ 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:

typescript
const 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:

typescript
import { 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:

typescript
varchar("name", { length: 100, })

means the database column has a maximum length of 100 characters.

Likewise:

typescript
integer("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:

typescript
import type { InferSelectModel, InferInsertModel, } from "drizzle-orm"; export type User = InferSelectModel<typeof users>; export type NewUser = InferInsertModel<typeof users>;

Conceptually:

typescript
type User = { id: number; name: string; email: string; age: number; createdAt: Date; };

And the insert type is different:

typescript
type 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:

text
Postgres schema ↓ Drizzle schema ↓ TypeScript inference

There is no manually duplicated:

typescript
type 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:

typescript
unknown

conceptually.

We need runtime validation.

This is where Zod enters the architecture.

Static types vs runtime validation

TypeScript:

typescript
type CreateUser = { name: string; email: string; age: number; };

protects code like:

typescript
const user: CreateUser = { name: "Ali", email: "ali@example.com", age: 22, };

But it does nothing to validate:

typescript
const body = req.body;

because req.body came from the network.

Zod solves that boundary problem:

typescript
const 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:

typescript
import { 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:

typescript
insertUserSchema.parse(req.body);

or, preferably for API handling:

typescript
const result = insertUserSchema.safeParse(req.body);

This means we aren't maintaining two completely independent definitions:

text
Drizzle schema + Manual Zod schema

Instead:

text
Drizzle 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:

typescript
import { 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:

text
Database 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:

typescript
import 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:

typescript
const result = createUserSchema.safeParse(req.body);

Before validation:

text
req.body │ ▼ unknown / untrusted

After successful validation:

typescript
result.data

has a statically known type.

Conceptually:

text
HTTP 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:

text
my-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:

typescript
export type CreateUser = z.infer<typeof createUserSchema>;

We don't manually define:

typescript
type CreateUser = { name: string; email: string; age: number; };

The Zod schema is the runtime source of truth.

The TypeScript type is derived from it.

text
Zod schema │ ├── Runtime validation │ └── TypeScript inference

React + react-hook-form

React can consume the exact same schema.

Install:

bash
npm install react-hook-form zod @hookform/resolvers

Then:

tsx
import { 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:

typescript
createUserSchema

The same validation rules can be used at the frontend and backend.

For example:

typescript
name: z.string().min(1).max(100)

means the frontend knows:

text
name must be a string name cannot be empty name cannot exceed 100 characters

The backend knows the same thing.

The TypeScript compiler also knows:

typescript
data.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 age with birthdate.

Previously:

typescript
age: integer("age").notNull(),

We change the Drizzle schema:

typescript
birthdate: date("birthdate", { mode: "string", }).notNull(),

Our schema now becomes:

typescript
export 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:

typescript
type User = { id: number; name: string; email: string; age: number; createdAt: Date; };

After:

typescript
type User = { id: number; name: string; email: string; birthdate: string; createdAt: Date; };

Now suppose the backend still contains:

typescript
const user = await db .insert(users) .values({ name: req.body.name, email: req.body.email, age: req.body.age, });

TypeScript can immediately flag:

text
Object 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:

typescript
user.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:

typescript
const response = await fetch("/api/users"); const data = await response.json();

By default:

typescript
data

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:

typescript
const 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:

typescript
const 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:

text
DATABASE │ │ 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:

text
Wrong property names Wrong primitive types Missing properties Invalid function arguments Incorrect return types Broken refactors

Runtime safety

Zod catches:

text
Malformed HTTP payloads Unexpected API responses Invalid user input Third-party data Untrusted JSON

PostgreSQL then provides the final persistence-level constraints:

text
NOT 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:

text
Postgres ↓ Drizzle ↓ Zod ↓ Node.js ↓ HTTP ↓ Zod ↓ React

And the responsibility of each layer is different:

LayerResponsibility
PostgreSQLPersistent data integrity
DrizzleType-safe database access
ZodRuntime validation
TypeScriptCompile-time correctness
Node.jsAPI/business logic
ReactUI and user interaction
react-hook-formForm state and validation integration

The real payoff appears during change.

If:

typescript
age: number

becomes:

typescript
birthdate: 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:

text
apps/web apps/api packages/contracts

is powerful, but introduces tooling complexity.

You need to configure things such as:

text
pnpm 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:

typescript
any

If an API response becomes:

typescript
any

you have effectively punched a hole through your type system.

Prefer:

typescript
unknown

at untrusted boundaries:

typescript
const 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:

typescript
type User = { id: number; age: number; };

does not validate an HTTP response.

Neither does:

typescript
const user = response as User;

Type assertions are compile-time instructions only.

If the server sends:

json
{ "id": 42, "age": "twenty-two" }

then:

typescript
const 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:

typescript
const user = userSchema.parse(response);

This is why a genuinely robust full-stack TypeScript architecture needs both:

text
TypeScript + 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.