Typsichere APIs mit Zod Contract erstellen
Im vorherigen Beitrag haben wir die Grundlagen von Zod und der Contract-First-API-Entwicklung untersucht. Jetzt ist es an der Zeit, die Theorie in die Praxis umzusetzen. Dieser Beitrag führt durch den Aufbau einer produktionsreifen typsicheren API mit Zod-Verträgen und deckt alles ab, vom Projekt-Setup bis zu Teststrategien.
Projekt-Setup
Beginnen wir mit einem sauberen TypeScript-Projekt, das für die API-Entwicklung konfiguriert ist:
npm init -y
npm install express zod
npm install -D typescript @types/express @types/node tsx
Erstellen Sie eine tsconfig.json mit strikter Typprüfung:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Vertragsschemas erstellen
Definieren Sie Ihre API-Verträge in einer dedizierten Vertragsdatei. Dies zentralisiert Ihre Schemas und macht sie leicht teilbar:
import { z } from 'zod'
export const CreateUserContract = {
request: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().optional()
}),
response: z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
age: z.number().optional(),
createdAt: z.string().datetime()
})
}
export const GetUserContract = {
params: z.object({
id: z.string().uuid()
}),
response: z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
age: z.number().optional(),
createdAt: z.string().datetime()
})
}
export const UpdateUserContract = {
params: z.object({
id: z.string().uuid()
}),
request: z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
age: z.number().int().positive().optional()
}),
response: z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
age: z.number().optional(),
updatedAt: z.string().datetime()
})
}
export type CreateUserRequest = z.infer<typeof CreateUserContract.request>
export type CreateUserResponse = z.infer<typeof CreateUserContract.response>
export type GetUserParams = z.infer<typeof GetUserContract.params>
export type GetUserResponse = z.infer<typeof GetUserContract.response>
export type UpdateUserParams = z.infer<typeof UpdateUserContract.params>
export type UpdateUserRequest = z.infer<typeof UpdateUserContract.request>
export type UpdateUserResponse = z.infer<typeof UpdateUserContract.response>
Request-Validierungs-Middleware
Erstellen Sie wiederverwendbare Middleware, die Anfragen gegen Ihre Verträge validiert:
import { Request, Response, NextFunction } from 'express'
import { z, ZodError } from 'zod'
export const validateRequest = (schema: z.ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.body = schema.parse(req.body)
next()
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors.map(err => ({
path: err.path.join('.'),
message: err.message
}))
})
}
next(error)
}
}
}
export const validateParams = (schema: z.ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.params = schema.parse(req.params)
next()
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
error: 'Invalid parameters',
details: error.errors.map(err => ({
path: err.path.join('.'),
message: err.message
}))
})
}
next(error)
}
}
}
Response-Validierung und Typinferenz
Validieren Sie Antworten, um sicherzustellen, dass Ihre API immer Daten zurückgibt, die dem Vertrag entsprechen:
import { Response } from 'express'
import { z } from 'zod'
export const sendValidated = <T>(
res: Response,
schema: z.ZodSchema<T>,
data: unknown,
statusCode: number = 200
) => {
try {
const validated = schema.parse(data)
return res.status(statusCode).json(validated)
} catch (error) {
console.error('Response validation failed:', error)
return res.status(500).json({
error: 'Internal server error',
message: 'Response validation failed'
})
}
}
Fehlerbehandlungsmuster
Implementieren Sie eine konsistente Fehlerbehandlung, die mit der Zod-Validierung funktioniert:
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational: boolean = true
) {
super(message)
Object.setPrototypeOf(this, AppError.prototype)
}
}
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message
})
}
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: err.errors
})
}
console.error('Unexpected error:', err)
return res.status(500).json({
error: 'Internal server error'
})
}
Praxisbeispiel: Benutzerverwaltungs-API
Alles zusammenfügen in einer vollständigen Benutzerverwaltungs-API:
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import {
CreateUserContract,
GetUserContract,
UpdateUserContract,
CreateUserRequest,
CreateUserResponse,
GetUserResponse,
UpdateUserRequest,
UpdateUserResponse
} from './contracts'
import { validateRequest, validateParams } from './middleware/validation'
import { sendValidated } from './utils/response'
import { errorHandler, AppError } from './middleware/error'
const app = express()
app.use(express.json())
interface User {
id: string
name: string
email: string
age?: number
createdAt: string
updatedAt?: string
}
const users: Map<string, User> = new Map()
app.post(
'/users',
validateRequest(CreateUserContract.request),
(req, res, next) => {
try {
const input = req.body as CreateUserRequest
const user: User = {
id: uuidv4(),
name: input.name,
email: input.email,
age: input.age,
createdAt: new Date().toISOString()
}
users.set(user.id, user)
const response: CreateUserResponse = {
id: user.id,
name: user.name,
email: user.email,
age: user.age,
createdAt: user.createdAt
}
sendValidated(res, CreateUserContract.response, response, 201)
} catch (error) {
next(error)
}
}
)
app.get(
'/users/:id',
validateParams(GetUserContract.params),
(req, res, next) => {
try {
const { id } = req.params
const user = users.get(id)
if (!user) {
throw new AppError(404, 'User not found')
}
const response: GetUserResponse = {
id: user.id,
name: user.name,
email: user.email,
age: user.age,
createdAt: user.createdAt
}
sendValidated(res, GetUserContract.response, response)
} catch (error) {
next(error)
}
}
)
app.patch(
'/users/:id',
validateParams(UpdateUserContract.params),
validateRequest(UpdateUserContract.request),
(req, res, next) => {
try {
const { id } = req.params
const updates = req.body as UpdateUserRequest
const user = users.get(id)
if (!user) {
throw new AppError(404, 'User not found')
}
const updatedUser: User = {
...user,
...updates,
updatedAt: new Date().toISOString()
}
users.set(id, updatedUser)
const response: UpdateUserResponse = {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
age: updatedUser.age,
updatedAt: updatedUser.updatedAt!
}
sendValidated(res, UpdateUserContract.response, response)
} catch (error) {
next(error)
}
}
)
app.use(errorHandler)
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Teststrategien
Testen Sie Ihre Verträge und API-Endpunkte gründlich:
import { describe, it, expect } from 'vitest'
import { CreateUserContract, GetUserContract } from './contracts'
describe('User Contracts', () => {
it('validates correct create user request', () => {
const validData = {
name: 'John Doe',
email: '[email protected]',
age: 30
}
const result = CreateUserContract.request.safeParse(validData)
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const invalidData = {
name: 'John Doe',
email: 'not-an-email',
age: 30
}
const result = CreateUserContract.request.safeParse(invalidData)
expect(result.success).toBe(false)
})
it('rejects negative age', () => {
const invalidData = {
name: 'John Doe',
email: '[email protected]',
age: -5
}
const result = CreateUserContract.request.safeParse(invalidData)
expect(result.success).toBe(false)
})
it('validates UUID in params', () => {
const validParams = {
id: '550e8400-e29b-41d4-a716-446655440000'
}
const result = GetUserContract.params.safeParse(validParams)
expect(result.success).toBe(true)
})
it('rejects invalid UUID', () => {
const invalidParams = {
id: 'not-a-uuid'
}
const result = GetUserContract.params.safeParse(invalidParams)
expect(result.success).toBe(false)
})
})
Integration mit Fastify
Zod-Verträge funktionieren nahtlos mit anderen Frameworks. Hier ist ein Fastify-Beispiel:
import Fastify from 'fastify'
import { CreateUserContract } from './contracts'
const fastify = Fastify()
fastify.post('/users', async (request, reply) => {
const result = CreateUserContract.request.safeParse(request.body)
if (!result.success) {
return reply.status(400).send({
error: 'Validation failed',
details: result.error.errors
})
}
const input = result.data
// ... create user logic
return reply.status(201).send({
id: '...',
name: input.name,
email: input.email,
createdAt: new Date().toISOString()
})
})
Best Practices und Tipps
Verträge zentralisieren: Bewahren Sie alle Schemas in einem dedizierten Vertragsverzeichnis auf. Dies macht sie leicht auffindbar, wartbar und mit Frontend-Teams teilbar.
Früh validieren: Verwenden Sie Middleware, um Anfragen so früh wie möglich im Request-Lebenszyklus zu validieren. Dies verhindert, dass ungültige Daten Ihre Geschäftslogik erreichen.
Antworten validieren: Überspringen Sie nicht die Response-Validierung. Sie fängt Fehler ab, bei denen Ihre Implementierung nicht mit dem Vertrag übereinstimmt.
Typinferenz verwenden: Verwenden Sie immer z.infer, um TypeScript-Typen aus Schemas abzuleiten. Dies stellt sicher, dass Typen und Validierung synchron bleiben.
Schemas komponieren: Erstellen Sie komplexe Schemas aus einfacheren. Dies fördert Wiederverwendbarkeit und Konsistenz.
Fehler elegant behandeln: Bieten Sie klare, umsetzbare Fehlermeldungen. Das Fehlerformat von Zod ist detailliert – verwenden Sie es, um API-Konsumenten zu helfen, ihre Anfragen zu korrigieren.
Verträge testen: Schreiben Sie Unit-Tests für Ihre Schemas. Überprüfen Sie, dass sie gültige Daten akzeptieren und ungültige Daten wie erwartet ablehnen.
Verträge versionieren: Wenn Sie Breaking Changes vornehmen, versionieren Sie Ihre API-Endpunkte und bewahren Sie wo möglich Rückwärtskompatibilität.
Fazit
APIs mit Zod zu bauen hat verändert, wie ich arbeite. Die Kombi aus Laufzeitvalidierung und Kompilierzeit-Typen fängt so viele Bugs ab, bevor sie in Produktion gehen.
Die Muster hier - zentralisierte Verträge, Validierungs-Middleware, Response-Validierung, Testing - sie skalieren. Fang klein an, füge mehr hinzu, wenn du es brauchst.
Als Nächstes: MCP-Server-Architektur und wie man Server baut, die tatsächlich mit deinen Anforderungen wachsen.
Keep pushing forward and savor every step of your coding journey.
