|

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.