Type-Safe APIs with TypeScript in 2026
Type-Safe APIs with TypeScript in 2026
Type safety across the entire stack — from database schema to API response to frontend component — eliminates an entire class of bugs. No more "undefined is not a function" from a field that was renamed on the backend. TypeScript makes this possible when the right tools connect each layer.
TL;DR
- The type-safe stack: Drizzle or Prisma → Zod → tRPC or Hono → React Query or tRPC client
- Zod is the load-bearing layer: one schema definition gives you runtime validation, TypeScript types, and OpenAPI generation
- For public APIs, generate OpenAPI from Zod schemas and use orval or hey-api for typed client generation — not tRPC
- Drizzle wins for new projects in 2026: lighter bundle, SQL-like syntax, no Rust binary to compile, better edge runtime support
- Typed error responses via discriminated unions prevent "catch(e: any)" patterns that hide bugs in production
- Never use
z.parse()in hot paths where you expect failures — usez.safeParse()to avoid exception overhead
The Type-Safe Stack
Database Schema (Prisma/Drizzle)
→ Type-safe ORM queries
API Layer (tRPC/Hono/Express + Zod)
→ Type-safe request validation + response types
Client (React Query + generated types)
→ Type-safe API calls with autocomplete
Layer 1: Database → TypeScript
Prisma
Prisma generates TypeScript types from your database schema:
model User {
id String @id @default(uuid())
name String
email String @unique
}
Generates User type and type-safe query methods. prisma.user.findUnique({ where: { id } }) returns User | null with full autocomplete.
Drizzle ORM
Drizzle defines schemas in TypeScript directly:
const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
});
Types are inferred from the schema definition. Queries are type-safe with SQL-like syntax.
Layer 2: API Validation (Zod)
Zod validates runtime data and infers TypeScript types:
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(120).optional(),
});
type CreateUser = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; age?: number }
One schema serves as: runtime validation, TypeScript type, and API documentation.
Layer 3: Type-Safe API
tRPC — Zero-API-Layer Approach
// Server
const appRouter = router({
user: router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(({ input }) => {
return prisma.user.findUnique({ where: { id: input.id } });
}),
create: publicProcedure
.input(CreateUserSchema)
.mutation(({ input }) => {
return prisma.user.create({ data: input });
}),
}),
});
// Client — full type inference, zero code generation
const user = await trpc.user.getById.query({ id: "abc" });
// user is typed as User | null
Hono + Zod — REST with Types
const app = new Hono();
const route = app.post('/users',
zValidator('json', CreateUserSchema),
async (c) => {
const data = c.req.valid('json'); // Typed as CreateUser
const user = await prisma.user.create({ data });
return c.json(user);
}
);
// Generate OpenAPI spec from routes
// Generate client types from OpenAPI
Express + Zod — Traditional REST
Use Zod middleware to validate request bodies, query parameters, and path parameters. Export Zod schemas to generate OpenAPI specs.
Layer 4: Client Types
tRPC Client (automatic)
Types flow from server to client automatically. No code generation needed.
OpenAPI → TypeScript (generated)
For REST APIs, generate TypeScript types from your OpenAPI spec:
npx openapi-typescript api.yaml -o ./types/api.ts
Then use with a typed fetch wrapper or generated client.
React Query + Types
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => trpc.user.getById.query({ id }),
});
// data is typed as User | null | undefined
The Full Pipeline
| Layer | Tool | Types |
|---|---|---|
| Database | Prisma / Drizzle | Generated from schema |
| Validation | Zod | Inferred from validators |
| API | tRPC / Hono | Inferred from procedures/routes |
| Client | tRPC client / openapi-typescript | Automatic / generated |
| UI | React + TypeScript | Flows from API types |
Benefits
- Rename a field on the server → TypeScript errors show everywhere it's used on the client
- Add a required field → Compilation fails until all callers provide it
- Change a type → No runtime "undefined" errors in production
- Autocomplete → Developers explore the API from their editor
When Not to Use tRPC
- Public APIs — External developers need REST/GraphQL, not tRPC
- Multi-language backends — tRPC is TypeScript-only
- Existing REST API — Migration cost may not justify benefits
- Large distributed teams — API contracts may need to be more explicit
For public APIs, use Zod + OpenAPI generation to get type safety internally while exposing a standard REST interface externally.
Zod in Depth
Zod's basic z.string() and z.object() are just the surface. The library has powerful features that most developers underuse.
Coercion
Coercion automatically converts input types before validation. Useful for query parameters (which arrive as strings) and form data:
const SearchSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
active: z.coerce.boolean().optional(),
});
// Query: ?page=2&limit=50&active=true
// All strings → coerced to correct types
const params = SearchSchema.parse(req.query);
// { page: 2, limit: 50, active: true }
Without z.coerce, "2" fails a z.number() check. With it, the coercion happens transparently.
Transforms
Transforms run after validation to shape data:
const UserCreateSchema = z.object({
name: z.string().trim(), // trim whitespace
email: z.string().email().toLowerCase(), // normalize email
password: z.string().min(8).transform(async (pw) => bcrypt.hash(pw, 12)),
birthDate: z.string().pipe(z.coerce.date()), // string → Date
});
Transforms integrate into the validation pipeline — the output type changes when transforms run. z.infer<typeof UserCreateSchema> reflects the transformed output, not the raw input.
Discriminated Unions for API Payloads
Discriminated unions are the right model for polymorphic API payloads — requests that take different shapes depending on a type field:
const PaymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
card_number: z.string(),
expiry: z.string(),
cvv: z.string(),
}),
z.object({
method: z.literal('bank_transfer'),
account_number: z.string(),
routing_number: z.string(),
}),
z.object({
method: z.literal('crypto'),
wallet_address: z.string(),
currency: z.enum(['BTC', 'ETH', 'USDC']),
}),
]);
type Payment = z.infer<typeof PaymentSchema>;
// TypeScript knows: if method === 'card', card_number exists
Zod optimizes discriminatedUnion by checking the discriminant field first before trying each variant — much faster than z.union() for large schemas.
parse vs safeParse
z.parse() throws on validation failure. z.safeParse() returns a result object. For request validation in API handlers, always use safeParse:
// ❌ parse throws — uncaught in async handlers
app.post('/users', async (req, res) => {
const data = CreateUserSchema.parse(req.body); // throws if invalid
});
// ✅ safeParse returns { success: true, data } or { success: false, error }
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'validation_error',
issues: result.error.issues,
});
}
const user = await db.users.create(result.data);
return res.json(user);
});
z.parseAsync() and z.safeParseAsync() are the async variants for schemas with async transforms (like the bcrypt example above).
OpenAPI Generation from TypeScript
For public APIs, you need an OpenAPI spec. Writing YAML by hand is error-prone and gets stale. Generate it from your Zod schemas instead.
Zod to OpenAPI
The @asteasolutions/zod-to-openapi library (also known as zod-openapi) lets you register Zod schemas as OpenAPI components and generate a complete spec:
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
const registry = new OpenAPIRegistry();
const UserSchema = registry.register(
'User',
z.object({
id: z.string().uuid().openapi({ example: 'usr_abc123' }),
name: z.string().openapi({ example: 'Alice' }),
email: z.string().email().openapi({ example: 'alice@example.com' }),
})
);
registry.registerPath({
method: 'post',
path: '/users',
summary: 'Create a new user',
request: {
body: {
content: { 'application/json': { schema: CreateUserSchema } },
},
},
responses: {
201: {
description: 'User created',
content: { 'application/json': { schema: UserSchema } },
},
400: { description: 'Validation error' },
},
});
const generator = new OpenApiGeneratorV31(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.1.0',
info: { title: 'My API', version: '1.0.0' },
});
Hono + Zod OpenAPI
Hono has a first-class OpenAPI integration:
import { OpenAPIHono, createRoute } from '@hono/zod-openapi';
const app = new OpenAPIHono();
const createUserRoute = createRoute({
method: 'post',
path: '/users',
request: {
body: { content: { 'application/json': { schema: CreateUserSchema } } },
},
responses: {
201: { content: { 'application/json': { schema: UserSchema } }, description: 'Created' },
},
});
app.openapi(createUserRoute, async (c) => {
const data = c.req.valid('json'); // Typed from CreateUserSchema
const user = await db.users.create(data);
return c.json(user, 201);
});
// Serve Swagger UI
app.doc('/doc', { openapi: '3.1.0', info: { title: 'API', version: '1' } });
Typed Clients from Spec
Once you have an OpenAPI spec, generate typed clients with orval or hey-api:
# orval
npx orval --config orval.config.ts
# hey-api
npx @hey-api/openapi-ts --input api.json --output src/client
Generated clients include typed request/response interfaces, React Query hooks, and fetch functions — keeping the frontend in sync with the backend without manual type maintenance.
Error Typing
Untyped errors are the dark matter of TypeScript APIs. The catch (e: any) pattern puts you back in JavaScript land.
Discriminated Union Error Types
Define all possible error types as a discriminated union:
type ApiError =
| { code: 'validation_error'; issues: ZodIssue[] }
| { code: 'not_found'; resource: string; id: string }
| { code: 'unauthorized'; reason: string }
| { code: 'rate_limited'; retry_after: number }
| { code: 'internal_error'; request_id: string };
TypeScript can now exhaustively check that your error handlers cover all cases.
The Result Pattern
Rather than throwing errors, return a result type:
type Result<T, E = ApiError> =
| { ok: true; value: T }
| { ok: false; error: E };
async function createUser(data: CreateUser): Promise<Result<User>> {
const existing = await db.users.findByEmail(data.email);
if (existing) {
return {
ok: false,
error: { code: 'validation_error', issues: [{ path: ['email'], message: 'Email already exists' }] },
};
}
const user = await db.users.create(data);
return { ok: true, value: user };
}
// Caller handles both cases explicitly
const result = await createUser(input);
if (!result.ok) {
if (result.error.code === 'validation_error') {
return res.status(400).json(result.error);
}
}
return res.status(201).json(result.value);
Never-throw APIs are predictable and composable. The caller is forced to handle errors because they can't be accidentally swallowed by uncaught exception handlers.
For more on error response design, see API error handling and status codes.
Testing Type-Safe APIs
Testing tRPC Procedures
tRPC procedures can be tested directly without HTTP:
import { createCallerFactory } from '@trpc/server';
import { appRouter } from './router';
const createCaller = createCallerFactory(appRouter);
describe('user.create', () => {
it('creates a user with valid input', async () => {
const caller = createCaller({ session: mockSession });
const user = await caller.user.create({
name: 'Alice',
email: 'alice@example.com',
});
expect(user.id).toBeDefined();
expect(user.email).toBe('alice@example.com');
});
it('throws on duplicate email', async () => {
await db.users.create({ name: 'Alice', email: 'alice@example.com' });
const caller = createCaller({ session: mockSession });
await expect(
caller.user.create({ name: 'Alice2', email: 'alice@example.com' })
).rejects.toThrow();
});
});
Calling procedures directly (bypassing HTTP) makes tests fast and removes the need for a test server.
Testing Zod Schemas
Validate your Zod schemas have the right behavior:
describe('CreateUserSchema', () => {
it('accepts valid input', () => {
const result = CreateUserSchema.safeParse({ name: 'Alice', email: 'alice@example.com' });
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = CreateUserSchema.safeParse({ name: 'Alice', email: 'not-an-email' });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['email']);
});
it('rejects name longer than 100 chars', () => {
const result = CreateUserSchema.safeParse({ name: 'A'.repeat(101), email: 'a@b.com' });
expect(result.success).toBe(false);
});
});
OpenAPI Spec Snapshot Testing
Prevent accidental breaking changes to your OpenAPI spec with snapshot tests:
it('OpenAPI spec matches snapshot', async () => {
const spec = generateOpenAPISpec(); // Your spec generation function
expect(JSON.stringify(spec, null, 2)).toMatchSnapshot();
});
When you change the spec intentionally, update the snapshot explicitly. This catches unintended API contract changes during code review.
For full API testing patterns including integration tests, see API testing strategies for 2026.
Drizzle vs Prisma in 2026
The choice between Drizzle and Prisma is the most debated topic in the TypeScript backend ecosystem. Both are excellent; the right choice depends on your constraints.
Drizzle: Lighter, Faster, SQL-First
Drizzle defines your schema in TypeScript files:
import { pgTable, text, uuid, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
Queries use a SQL-like API that maps closely to the generated SQL:
const users = await db
.select()
.from(usersTable)
.where(eq(usersTable.email, email))
.limit(1);
Drizzle advantages:
- No Rust binary to compile (unlike Prisma's query engine)
- Works in edge runtimes (Cloudflare Workers, Vercel Edge) without restrictions
- Bundle size: ~50KB vs Prisma's ~2MB
- Pure SQL queries — easier to optimize, easier to debug
- Schema migrations via
drizzle-kitare fast and predictable
Prisma: Richer Tooling, Better DX for Complex Domains
Prisma's schema language is more expressive for complex relations:
model Post {
id String @id @default(uuid())
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
comments Comment[]
}
Relation queries are first-class:
const post = await prisma.post.findUnique({
where: { id },
include: { author: true, tags: true },
});
Prisma advantages:
- Richer migration history and diff tooling (
prisma migrate) - Prisma Studio — visual database browser
- Better support for complex relations (many-to-many, self-referential)
- More mature ecosystem (more Stack Overflow answers, more examples)
- Prisma Accelerate for built-in connection pooling and caching
Which to Pick
Choose Drizzle if:
- You're deploying to edge runtimes (Cloudflare Workers, Vercel Edge Functions)
- Bundle size matters (serverless functions billed by cold start)
- Your team is comfortable with SQL
- You're starting a new project in 2026
Choose Prisma if:
- You have a complex domain model with many relations
- Your team prefers declarative schema syntax
- You need Prisma Migrate's migration history tooling
- You're migrating an existing Prisma codebase
Both ORMs provide excellent TypeScript types. The type safety story is equivalent. The decision is primarily about bundle size, edge compatibility, and schema syntax preference.
Runtime Validation: The Gap Between TypeScript and Production
TypeScript's type system is a compile-time guarantee — it cannot catch data that doesn't match your expected types at runtime. An external API response, a user-submitted form, or a database row with an unexpected null can all pass TypeScript's type checks if you're using type assertions or as casts, then fail at runtime in production.
The solution is runtime validation with a library like Zod, Valibot, or ArkType. These libraries let you define a schema once and use it to both validate data at runtime and infer a TypeScript type from that schema — eliminating the possibility of your runtime validator and your TypeScript types disagreeing. The pattern: define your schema with Zod, use z.infer<typeof mySchema> to derive the TypeScript type, and call mySchema.parse(data) whenever data enters your system from an untrusted source — API responses, form submissions, environment variables, database rows from dynamic queries.
For tRPC users, Zod is already built into the router definition — each procedure's input and output types are Zod schemas, so runtime validation and type inference happen automatically. For REST APIs built with Hono, Fastify, or Express, integrating Zod requires explicit parse calls at your route handlers. The overhead is worth it: runtime type errors in production are harder to debug than compile-time TypeScript errors, and they tend to surface in edge cases that didn't appear in testing. Treat the type system and runtime validation as complementary, not interchangeable — TypeScript catches mistakes during development; runtime validation catches malformed data in production.
For a broader look at type-safe API patterns, see API documentation with OpenAPI vs AsyncAPI for documenting the APIs you build.