Build a Type-Safe REST API with Node.js, TypeScript and Zod

End-to-end type safety from request body to database. Here’s the architecture that makes bugs disappear before deployment.
PUBLISH METADATA
- Tags: TypeScript, Node.js, JavaScript, Web Development, API
- Description: Build a fully type-safe REST API using Node.js, TypeScript, Zod for validation, and Prisma for database — where types flow from HTTP request all the way to the database query with zero unsafe any types.
- Suggested publish date: Week 4 — Thursday
- Suggested cover image: Type safety diagram: Request → Zod Schema → TypeScript types → Database. Blue/purple gradient. Clean architecture visual.
Introduction
Most Node.js APIs have a type safety gap: TypeScript types at the service layer, but untyped req.body at the API boundary. Something enters as any, gets cast, and bugs slip through.
This guide builds an API where types flow end-to-end: from the HTTP request body, through validation, into business logic, and down to the database query. No any. No type assertions. No runtime surprises.
The Stack
- Node.js + Express — HTTP server
- TypeScript — type safety at compile time
- Zod — runtime validation + type inference at API boundary
- Prisma — type-safe database queries (types auto-generated from schema)
Project Setup
npm init -y
npm install express zod @prisma/client
npm install --save-dev typescript ts-node-dev @types/express @types/node prisma
npx tsc --init
npx prisma init
Prisma Schema
// prisma/schema.prisma
model Post {
id String @id @default(cuid())
title String
body String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[]
}npx prisma generate # Generates TypeScript types automatically

// src/schemas/post.schema.ts
import { z } from 'zod'
export const CreatePostSchema = z.object({
title: z.string().min(3).max(200),
body: z.string().min(10).max(50000),
published: z.boolean().optional().default(false),
})export const UpdatePostSchema = z.object({
title: z.string().min(3).max(200).optional(),
body: z.string().min(10).max(50000).optional(),
published: z.boolean().optional(),
}).refine(data => Object.keys(data).length > 0, {
message: 'At least one field must be provided'
})export const PostQuerySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).default('10'),
published: z.enum(['true', 'false']).transform(v => v === 'true').optional(),
})// Types inferred from schemas — no duplication
export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>
export type PostQuery = z.infer<typeof PostQuerySchema>
Validation Middleware
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { ZodSchema, ZodError } from 'zod'
type ValidateTarget = 'body' | 'query' | 'params'
export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = await schema.parseAsync(req[target])
req[target] = parsed // Replace with validated + transformed data
next()
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}))
})
}
next(err)
}
}
}Post Service (Fully Typed)
// src/services/post.service.ts
import { PrismaClient, Post } from '@prisma/client'
import type { CreatePostInput, UpdatePostInput, PostQuery } from '../schemas/post.schema'
export class PostService {
constructor(private readonly db: PrismaClient) {} async findMany(query: PostQuery): Promise<{ posts: Post[]; total: number }> {
const { page, limit, published } = query
const skip = (page - 1) * limitconst [posts, total] = await this.db.$transaction([
this.db.post.findMany({
where: published !== undefined ? { published } : undefined,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
}),
this.db.post.count({
where: published !== undefined ? { published } : undefined,
}),
])
return { posts, total }
} async create(data: CreatePostInput, authorId: string): Promise<Post> {
return this.db.post.create({
data: { ...data, authorId },
})
} async update(id: string, data: UpdatePostInput, authorId: string): Promise<Post | null> {
// Verify ownership
const post = await this.db.post.findUnique({ where: { id } })
if (!post || post.authorId !== authorId) return null return this.db.post.update({ where: { id }, data })
} async delete(id: string, authorId: string): Promise<boolean> {
const post = await this.db.post.findUnique({ where: { id } })
if (!post || post.authorId !== authorId) return false await this.db.post.delete({ where: { id } })
return true
}
}
Route Handler (Fully Typed, No any)
// src/routes/posts.ts
import { Router, Request, Response } from 'express'
import { authMiddleware } from '../middleware/auth'
import { validate } from '../middleware/validate'
import { PostService } from '../services/post.service'
import {
CreatePostSchema,
UpdatePostSchema,
PostQuerySchema,
CreatePostInput,
UpdatePostInput,
PostQuery,
} from '../schemas/post.schema'
export function createPostRouter(postService: PostService) {
const router = Router()router.get(
'/',
validate(PostQuerySchema, 'query'),
async (req: Request, res: Response) => {
const query = req.query as unknown as PostQuery
const result = await postService.findMany(query)
res.json(result)
}
)
router.post(
'/',
authMiddleware,
validate(CreatePostSchema),
async (req: Request, res: Response) => {
const data = req.body as CreatePostInput
const post = await postService.create(data, req.user!.id)
res.status(201).json({ post })
}
)
router.patch(
'/:id',
authMiddleware,
validate(UpdatePostSchema),
async (req: Request, res: Response) => {
const data = req.body as UpdatePostInput
const post = await postService.update(req.params.id, data, req.user!.id)
if (!post) return res.status(404).json({ error: 'Post not found or not authorized' })
res.json({ post })
}
)
router.delete(
'/:id',
authMiddleware,
async (req: Request, res: Response) => {
const deleted = await postService.delete(req.params.id, req.user!.id)
if (!deleted) return res.status(404).json({ error: 'Post not found or not authorized' })
res.status(204).send()
}
)
return router
}
The Type Flow

No gaps. Every layer is typed.
Conclusion
End-to-end type safety isn’t just about TypeScript — it’s about closing the gap between where data enters (HTTP request) and where it goes (database). Zod + Prisma + TypeScript closes that gap completely.
Once you build one API this way, you’ll never want to go back to untyped req.body.
#TypeScript #Nodejs #JavaScript #API #Zod #Prisma #WebDevelopment #Backend #SoftwareEngineering
I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/
