Type-Safe Express API
Build a type-safe Express API — typed request handlers, middleware, validation with Zod, and structured error handling.
55 min•By Priygop Team•Last updated: Feb 2026
Typed API Routes
- Typed Handler: type TypedHandler<P, ResBody, ReqBody> = (req: Request<P, ResBody, ReqBody>, res: Response<ApiResponse<ResBody>>) => Promise<void> — generic handler type enforcing request/response shapes
- Controller Pattern: const getUser: TypedHandler<{id: string}, User, never> = async (req, res) => { const user = await userService.findById(Number(req.params.id)); if (!user) { res.status(404).json({ success: false, error: 'User not found' }); return; } res.json({ success: true, data: user }); }
- Zod Validation: const CreateUserSchema = z.object({ email: z.string().email(), name: z.string().min(2).max(100), password: z.string().min(8) }); type CreateUserInput = z.infer<typeof CreateUserSchema> — schema defines both runtime validation and TypeScript type
- Validation Middleware: function validate<T>(schema: z.Schema<T>) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body); if (!result.success) { res.status(400).json({ success: false, error: result.error.message }); return; } req.body = result.data; next(); }; }
- Error Classes: class AppError extends Error { constructor(public message: string, public statusCode: number, public code: string) { super(message); } } class NotFoundError extends AppError { constructor(resource: string) { super(`${resource} not found`, 404, 'NOT_FOUND'); } } — typed error hierarchy
- Error Middleware: app.use((err: Error, req: Request, res: Response, next: NextFunction) => { if (err instanceof AppError) { res.status(err.statusCode).json({ success: false, error: err.message }); } else { res.status(500).json({ success: false, error: 'Internal server error' }); } }) — centralized error handling
Service Layer & Database
- Service Interface: interface UserService { findAll(params: PaginationParams): Promise<PaginatedResult<User>>; findById(id: number): Promise<User | null>; create(data: CreateUserDTO): Promise<User>; update(id: number, data: UpdateUserDTO): Promise<User>; delete(id: number): Promise<void>; } — contract for business logic
- Generic Repository: interface Repository<T, CreateDTO, UpdateDTO> { findAll(): Promise<T[]>; findById(id: number): Promise<T | null>; create(data: CreateDTO): Promise<T>; update(id: number, data: UpdateDTO): Promise<T>; delete(id: number): Promise<void>; } — reusable for any entity
- Type-Safe Query Builder: const users = await db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1); — Drizzle ORM provides full TypeScript inference from schema. Result type matches table definition
- JWT Typing: interface JwtPayload { userId: number; role: 'admin' | 'user'; iat: number; exp: number; } const payload = jwt.verify(token, secret) as JwtPayload — typed payload after verification
- Auth Middleware: declare global { namespace Express { interface Request { user?: JwtPayload; } } } — extend Express Request type to include authenticated user. Middleware sets req.user after JWT verification
- Environment Validation: const envSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), PORT: z.string().regex(/^\d+$/) }); const env = envSchema.parse(process.env) — validate environment variables at startup, crash fast if misconfigured