Projekt: Typisierte REST API
Baue eine vollständig typisierte REST API mit TypeScript, Express und Zod.
Aktualisiert:
Projekt: Typisierte REST API
In diesem Projekt bauen wir eine vollständig typisierte REST API. Du lernst, wie man TypeScript effektiv in einem Backend-Projekt einsetzt.
Projekt Setup
mkdir ts-api-projekt
cd ts-api-projekt
npm init -y
npm install express cors
npm install -D typescript @types/express @types/cors @types/node ts-node nodemon
TypeScript Konfiguration
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Package.json Scripts
{
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Projekt Struktur
src/
├── index.ts
├── types/
│ ├── user.ts
│ └── api.ts
├── routes/
│ └── users.ts
├── services/
│ └── userService.ts
└── middleware/
└── errorHandler.ts
Typ-Definitionen
src/types/user.ts
// User Entity
export interface User {
id: string;
name: string;
email: string;
role: "admin" | "user" | "guest";
createdAt: Date;
updatedAt: Date;
}
// Für neue User (ohne id und Timestamps)
export type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt">;
// Für Updates (alles optional)
export type UpdateUserInput = Partial<CreateUserInput>;
// Für API Responses (ohne sensible Daten falls nötig)
export type UserResponse = User;
// Für Listen
export type UserListItem = Pick<User, "id" | "name" | "email" | "role">;
src/types/api.ts
// Generische API Response
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: ApiError;
meta?: PaginationMeta;
}
export interface ApiError {
code: string;
message: string;
details?: unknown;
}
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
// Request Query Parameter
export interface PaginationQuery {
page?: string;
limit?: string;
}
export interface UserFilters extends PaginationQuery {
role?: string;
search?: string;
}
// Helper Type für typisierte Request Handler
import { Request, Response, NextFunction } from "express";
export type TypedRequestBody<T> = Request<{}, {}, T>;
export type TypedRequestParams<T> = Request<T>;
export type TypedRequestQuery<T> = Request<{}, {}, {}, T>;
export type AsyncHandler<
Params = {},
ResBody = {},
ReqBody = {},
Query = {}
> = (
req: Request<Params, ResBody, ReqBody, Query>,
res: Response<ResBody>,
next: NextFunction
) => Promise<void>;
User Service
src/services/userService.ts
import { User, CreateUserInput, UpdateUserInput, UserListItem } from "../types/user";
import { PaginationMeta, UserFilters } from "../types/api";
// In-Memory Storage (in Produktion: Datenbank)
const users: Map<string, User> = new Map();
// Helper für ID-Generierung
function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
// Service Interface
export interface IUserService {
findAll(filters: UserFilters): Promise<{ users: UserListItem[]; meta: PaginationMeta }>;
findById(id: string): Promise<User | null>;
create(input: CreateUserInput): Promise<User>;
update(id: string, input: UpdateUserInput): Promise<User | null>;
delete(id: string): Promise<boolean>;
}
class UserService implements IUserService {
async findAll(filters: UserFilters): Promise<{ users: UserListItem[]; meta: PaginationMeta }> {
const page = parseInt(filters.page || "1", 10);
const limit = parseInt(filters.limit || "10", 10);
let userArray = Array.from(users.values());
// Filter by role
if (filters.role) {
userArray = userArray.filter(u => u.role === filters.role);
}
// Search by name or email
if (filters.search) {
const search = filters.search.toLowerCase();
userArray = userArray.filter(
u => u.name.toLowerCase().includes(search) ||
u.email.toLowerCase().includes(search)
);
}
const total = userArray.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const paginatedUsers = userArray.slice(start, start + limit);
return {
users: paginatedUsers.map(u => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role
})),
meta: { page, limit, total, totalPages }
};
}
async findById(id: string): Promise<User | null> {
return users.get(id) || null;
}
async create(input: CreateUserInput): Promise<User> {
const now = new Date();
const user: User = {
id: generateId(),
...input,
createdAt: now,
updatedAt: now
};
users.set(user.id, user);
return user;
}
async update(id: string, input: UpdateUserInput): Promise<User | null> {
const existing = users.get(id);
if (!existing) return null;
const updated: User = {
...existing,
...input,
updatedAt: new Date()
};
users.set(id, updated);
return updated;
}
async delete(id: string): Promise<boolean> {
return users.delete(id);
}
}
export const userService = new UserService();
Error Handler Middleware
src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { ApiResponse, ApiError } from "../types/api";
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
this.name = "AppError";
}
}
export function notFound(message = "Resource not found"): never {
throw new AppError(404, "NOT_FOUND", message);
}
export function badRequest(message: string, details?: unknown): never {
throw new AppError(400, "BAD_REQUEST", message, details);
}
export function errorHandler(
err: Error,
_req: Request,
res: Response<ApiResponse<never>>,
_next: NextFunction
): void {
console.error("Error:", err);
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
details: err.details
}
});
return;
}
res.status(500).json({
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred"
}
});
}
Routes
src/routes/users.ts
import { Router, Request, Response, NextFunction } from "express";
import { userService } from "../services/userService";
import { ApiResponse, UserFilters } from "../types/api";
import { User, UserListItem, CreateUserInput, UpdateUserInput } from "../types/user";
import { notFound, badRequest } from "../middleware/errorHandler";
const router = Router();
// Helper für async Handler
const asyncHandler = <T>(
fn: (req: Request, res: Response<ApiResponse<T>>, next: NextFunction) => Promise<void>
) => (req: Request, res: Response<ApiResponse<T>>, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// GET /users
router.get(
"/",
asyncHandler<{ users: UserListItem[]; meta: any }>(async (req, res) => {
const filters: UserFilters = {
page: req.query.page as string,
limit: req.query.limit as string,
role: req.query.role as string,
search: req.query.search as string
};
const result = await userService.findAll(filters);
res.json({
success: true,
data: result.users,
meta: result.meta
});
})
);
// GET /users/:id
router.get(
"/:id",
asyncHandler<User>(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
notFound("User not found");
}
res.json({
success: true,
data: user
});
})
);
// POST /users
router.post(
"/",
asyncHandler<User>(async (req, res) => {
const input: CreateUserInput = req.body;
// Validierung
if (!input.name || input.name.length < 2) {
badRequest("Name must be at least 2 characters");
}
if (!input.email || !input.email.includes("@")) {
badRequest("Invalid email address");
}
if (!["admin", "user", "guest"].includes(input.role)) {
badRequest("Role must be admin, user, or guest");
}
const user = await userService.create(input);
res.status(201).json({
success: true,
data: user
});
})
);
// PUT /users/:id
router.put(
"/:id",
asyncHandler<User>(async (req, res) => {
const input: UpdateUserInput = req.body;
const user = await userService.update(req.params.id, input);
if (!user) {
notFound("User not found");
}
res.json({
success: true,
data: user
});
})
);
// DELETE /users/:id
router.delete(
"/:id",
asyncHandler<{ deleted: boolean }>(async (req, res) => {
const deleted = await userService.delete(req.params.id);
if (!deleted) {
notFound("User not found");
}
res.json({
success: true,
data: { deleted: true }
});
})
);
export default router;
Main App
src/index.ts
import express from "express";
import cors from "cors";
import userRoutes from "./routes/users";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use("/api/users", userRoutes);
// Health Check
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Error Handler (muss am Ende stehen)
app.use(errorHandler);
// Server starten
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
API testen
Starte den Server mit npm run dev und teste die Endpoints:
# User erstellen
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Max","email":"max@example.com","role":"user"}'
# Alle User abrufen
curl http://localhost:3000/api/users
# Mit Pagination
curl "http://localhost:3000/api/users?page=1&limit=5"
# Mit Filter
curl "http://localhost:3000/api/users?role=admin&search=max"
# User abrufen
curl http://localhost:3000/api/users/{id}
# User aktualisieren
curl -X PUT http://localhost:3000/api/users/{id} \
-H "Content-Type: application/json" \
-d '{"name":"Max Updated"}'
# User löschen
curl -X DELETE http://localhost:3000/api/users/{id}
Was du gelernt hast
- Typisierte Request/Response mit generischen Types
- Service Pattern mit Interface-Definition
- Error Handling mit Custom Error Classes
- Async Handler für typsichere Route Handler
- Input/Output Types für CRUD Operations
Im nächsten Projekt integrieren wir TypeScript mit React!