End-to-End Type Safety in Full-Stack TypeScript: From Database to UI Without Runtime Surprises
TypeScript doesn't prevent runtime type errors by itself. Learn how to close the gaps across the full stack using Drizzle, Hono RPC, Zod, and shared schemas so your types survive all the way from the database to your React components.
TypeScript gives you a type checker, not a runtime guarantee. That distinction trips up most teams who adopt full-stack TypeScript with confidence and then hit their first production incident from a shape mismatch between the API response and what the UI expected.
The problem is not TypeScript itself. The problem is that TypeScript only checks what you told it to check. The moment data crosses a trust boundary: a database query, an HTTP response, a form submission, an environment variable load, it leaves the type system unless you explicitly reconnect it. And most applications have at least four of those boundaries.
This article covers how to close each of those gaps in a practical Drizzle + Hono + React stack.
Why Runtime Type Errors Still Happen in TypeScript Projects
Consider the common pattern of manually writing types alongside your database schema:
// Manually maintained type
type User = {
id: number;
email: string;
createdAt: Date;
};
// Database returns this
const user = await db.query("SELECT id, email, created_at FROM users WHERE id = ?", [id]);
// user is typed as any[] or unknown, then cast
const typedUser = user[0] as User;
The cast is the failure point. The database column might be created_at (snake_case) while your type says createdAt. The column type might be a string serialization of a date rather than a Date object. Neither error surfaces until runtime.
The same gap appears at the HTTP boundary. If you write your API response type manually and your route handler evolves independently, the types drift. If you fetch data in a React component and annotate the response with a hand-written interface, you have a type assertion, not a type guarantee.
Closing the gap requires treating one artifact as the authoritative source of truth and deriving all downstream types from it, rather than writing types at every layer independently.
The Database Layer: Drizzle as Source of Truth
Drizzle is built around the idea that your schema definition is the authoritative source. You define your tables once, and the library infers TypeScript types directly from those definitions.
// schema.ts
import { pgTable, serial, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// Drizzle infers these from the schema above
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
User now has exactly the shape that the database returns. If you add a column to the schema, the type updates automatically. If you rename a column, TypeScript surfaces every usage that needs updating.
The key behavioral difference from an ORM like Prisma here is that there is no code generation step. The types are inferred at compile time directly from your schema object. This means your schema file is importable by both your server code and any shared types package without a build step dependency.
// Using inferred types in queries
import { db } from "./db";
import { users, type User } from "./schema";
import { eq } from "drizzle-orm";
async function getUserById(id: number): Promise<User | undefined> {
const result = await db.select().from(users).where(eq(users.id, id));
return result[0];
}
// The return type is correct because Drizzle tracks the selected columns
// If you do a partial select, TypeScript knows about it:
async function getUserEmail(id: number): Promise<{ email: string } | undefined> {
const result = await db
.select({ email: users.email })
.from(users)
.where(eq(users.id, id));
return result[0];
}
Where this breaks down: Drizzle inferred types represent what the query returns, not what the business logic wants to expose. A User type might include internal fields like passwordHash that should never reach the client. You still need to be deliberate about what you expose at the API boundary.
The API Layer: Hono RPC for Typed Client-Server Communication
Once you have accurate types from the database layer, the next gap is the HTTP boundary between your server and your client. The naive approach is to annotate fetch calls with an interface that mirrors what you expect the server to return. That annotation is a lie that TypeScript believes.
Hono’s RPC mode solves this by allowing you to export the type of your router and import it in the client. The client then gets full type information about every route, its input types, and its response types, without a code generation step.
// server/routes/users.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { getUserById, createUser } from "../services/users";
const userRoutes = new Hono()
.get("/:id", async (c) => {
const id = Number(c.req.param("id"));
const user = await getUserById(id);
if (!user) return c.json({ error: "Not found" }, 404);
// Return only safe fields
return c.json({
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
});
})
.post(
"/",
zValidator("json", z.object({
email: z.string().email(),
name: z.string().min(1),
})),
async (c) => {
const body = c.req.valid("json");
const user = await createUser(body);
return c.json({ id: user.id, email: user.email, name: user.name }, 201);
}
);
export type UserRoutes = typeof userRoutes;
export default userRoutes;
// server/index.ts
import { Hono } from "hono";
import userRoutes from "./routes/users";
const app = new Hono().route("/users", userRoutes);
export type AppType = typeof app;
export default app;
// client/api.ts
import { hc } from "hono/client";
import type { AppType } from "../server";
// The client type is inferred from the server router type
export const client = hc<AppType>("http://localhost:3000");
// Usage in a React component:
async function fetchUser(id: number) {
const res = await client.users[":id"].$get({ param: { id: String(id) } });
if (!res.ok) throw new Error("Failed to fetch user");
// Type is inferred: { id: number; email: string; name: string; createdAt: string }
const data = await res.json();
return data;
}
The Hono client knows the exact response shape for each route because the type flows from the route handler’s return type. If you change the server response shape, TypeScript will error at every client callsite that depends on the old shape.
One tradeoff to be aware of: dates. Notice the toISOString() call above. JSON serialization converts Date objects to strings. The inferred client type will correctly show createdAt as string, not Date. You need to handle that conversion explicitly on the client side or create a serialization layer. This is not a Hono limitation. It is a JSON boundary reality that every approach has to deal with.
Shared Validation: Zod Schemas Across Server and Client
Validation logic duplicated between client and server is a maintenance hazard. A Zod schema serves double duty as a runtime validator and a TypeScript type source.
The key is locating the schema in a shared module that both the server route handler and the client-side form can import.
// shared/schemas/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email("Must be a valid email address"),
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = createUserSchema.partial().omit({ password: true });
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
The server uses the schema for validation with zValidator. The client uses the same schema with React Hook Form:
// client/components/CreateUserForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUserSchema, type CreateUserInput } from "../../shared/schemas/user";
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
});
const onSubmit = async (data: CreateUserInput) => {
const res = await client.users.$post({ json: data });
// Handle response
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Create Account</button>
</form>
);
}
A change to the createUserSchema now propagates to both the server validation and the client form in one edit. The error messages are consistent. The field constraints are consistent.
The tradeoff: the shared schema lives in a module that both the backend and frontend bundles import. If you are using a monorepo, this is a non-issue. In a split-repo setup, you need a shared package (e.g., @yourapp/schemas). The setup cost is worth it once you have more than two or three forms.
Runtime Validation at System Boundaries
Even with Drizzle and Hono, you have boundaries where data enters your system from sources you do not control: external API responses, webhook payloads, environment variables, and user-uploaded file metadata.
At each of these boundaries, parsing beats casting.
// Never do this
const webhookPayload = req.body as StripeWebhookEvent;
// Do this instead
import { z } from "zod";
const stripeWebhookSchema = z.object({
type: z.string(),
data: z.object({
object: z.object({
id: z.string(),
amount: z.number(),
currency: z.string(),
status: z.enum(["succeeded", "pending", "failed"]),
}),
}),
});
function parseWebhookPayload(raw: unknown) {
const result = stripeWebhookSchema.safeParse(raw);
if (!result.success) {
// Log the validation error with the actual payload for debugging
console.error("Webhook parse failed", { errors: result.error.flatten(), raw });
throw new Error("Invalid webhook payload");
}
return result.data;
}
The safeParse pattern is important for external data: it gives you the validation errors without throwing, so you can log the actual problematic payload before rejecting it. parse (which throws on failure) is better for internal assertions where you expect valid data and want to surface bugs immediately.
Environment variables deserve the same treatment:
// env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = envSchema.parse(process.env);
This runs at startup. If DATABASE_URL is missing or malformed, the process fails immediately with a clear error instead of later when you make the first database call. The z.coerce.number() handles the fact that environment variables are always strings.
Code Generation vs. Inference: When Each Approach Wins
There are two distinct approaches to end-to-end type safety, and the choice between them has real operational tradeoffs.
Inference-based (Drizzle + Hono): Types are derived at compile time from runtime objects. No build step required. The schema file is the source of truth. Changes propagate instantly.
Code generation (Prisma, GraphQL codegen, OpenAPI generators): A tool reads a schema file or API spec and emits TypeScript type files. Those files are checked in or generated as part of the build. The schema file is the source of truth, but the generated output is a separate artifact.
| Dimension | Inference (Drizzle + Hono) | Code Generation (Prisma, OpenAPI) |
|---|---|---|
| Build step required | No | Yes |
| Schema evolution latency | Immediate | After codegen run |
| IDE support | Good | Excellent (generated types are explicit) |
| Debugging experience | Can be opaque | Generated types are readable files |
| Monorepo compatibility | Simple | Requires codegen in CI pipeline |
| External API integration | Not applicable | Strong fit (generate from spec) |
| Team onboarding | Lower overhead | Higher overhead, better discoverability |
For internal APIs you own end-to-end, inference is almost always the right choice. The zero-build-step feedback loop is faster, and there is no generated file to get out of sync.
For external APIs where you consume an OpenAPI or GraphQL spec from another team or third-party service, code generation wins. You generate types from the spec, check in the output, and treat spec drift as a review concern. The generated types document exactly what you are consuming.
Production Considerations
Type narrowing in response handlers. Hono’s inferred response types assume the happy path. If your route can return a 404 or a 500, those response shapes are also part of the type. On the client, always check res.ok before calling res.json(). Pattern-matching on the status code gives you type-narrowed branches.
async function getUser(id: number) {
const res = await client.users[":id"].$get({ param: { id: String(id) } });
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Unexpected error: ${res.status}`);
}
return res.json();
}
Zod performance at scale. Zod schemas execute at runtime on every request. For hot paths (webhook receivers, high-volume API endpoints), profile parse time. For large nested schemas, z.lazy() defers evaluation and z.preprocess() can normalize input before validation. Switching to a faster alternative like Valibot is an option if parse time becomes measurable.
Schema versioning. Once your schema is shared between the client and server and potentially across teams, breaking changes become a coordination problem. For Zod schemas in a shared package, treat a removed required field as a breaking change that requires a major version bump. Additive changes (new optional fields) are non-breaking. Document this in your schema package’s changelog.
Drizzle migrations. Inferred types are only as correct as your actual database schema. If a migration fails to run in production, your types will not match the actual columns. Track migration state explicitly and alert on failed migrations. Drizzle’s migration tooling generates SQL that you run explicitly, which makes it auditable.
Observability on parse failures. Every safeParse call that returns success: false on an external boundary is a signal worth tracking. Log the Zod error path (not the full payload, which may contain PII) and count these failures per source. A sudden spike in webhook parse failures usually means an upstream schema change you did not know about.
Putting It Together
The full chain looks like this:
- Drizzle schema defines table structure.
$inferSelectand$inferInsertgive you accurate database types. - Service functions take and return those inferred types, handling field exclusions explicitly.
- Hono route handlers use
zValidatorwith shared Zod schemas for input validation. Response shapes are explicit and narrow. AppTypeis exported from the server and imported by the Hono client on the frontend.- React components use the Hono client for data fetching. Response types come from the server export, not from manual annotations.
- React Hook Form uses the same Zod schemas with
zodResolverfor client-side form validation. - External data (webhooks, environment variables, third-party API responses) is parsed with
safeParseat the entry point and never cast.
Each layer passes a typed value to the next. No cast, no assertion, no as keyword anywhere on the happy path. When a schema changes, TypeScript surfaces exactly which downstream usages need updating.
That is the practical definition of end-to-end type safety: not a feature of a single tool, but a discipline of connecting the right tools so the type system can actually do its job across all of your trust boundaries.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.