Projekt: Full-Stack TypeScript App
Baue eine vollständige Full-Stack Anwendung mit geteilten Typen zwischen Frontend und Backend.
Aktualisiert:
Projekt: Full-Stack TypeScript App
In diesem Projekt verbinden wir alles Gelernte zu einer vollständigen Full-Stack Anwendung. Das Besondere: Wir teilen Typen zwischen Frontend und Backend für maximale Typ-Sicherheit.
Projekt Architektur
fullstack-app/
├── packages/
│ ├── shared/ # Geteilte Typen
│ ├── api/ # Express Backend
│ └── web/ # React Frontend
├── package.json
└── tsconfig.base.json
Monorepo Setup
Root package.json
{
"name": "fullstack-app",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "npm run dev --workspaces",
"build": "npm run build --workspaces",
"api": "npm run dev -w @app/api",
"web": "npm run dev -w @app/web"
}
}
tsconfig.base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"composite": true
}
}
Shared Package
packages/shared/package.json
{
"name": "@app/shared",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
packages/shared/src/types/user.ts
// User Entity
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: UserRole;
createdAt: string; // ISO Date String für JSON
updatedAt: string;
}
export type UserRole = "admin" | "user" | "guest";
// API Input/Output Types
export interface CreateUserInput {
email: string;
name: string;
password: string;
}
export interface UpdateUserInput {
name?: string;
avatar?: string;
}
export interface LoginInput {
email: string;
password: string;
}
export interface AuthResponse {
user: User;
token: string;
}
// User ohne sensible Daten
export type PublicUser = Omit<User, "role">;
packages/shared/src/types/task.ts
export interface Task {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
assigneeId?: string;
dueDate?: string;
createdAt: string;
updatedAt: string;
}
export type TaskStatus = "todo" | "in-progress" | "review" | "done";
export type TaskPriority = "low" | "medium" | "high" | "urgent";
export interface CreateTaskInput {
title: string;
description?: string;
priority?: TaskPriority;
assigneeId?: string;
dueDate?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
assigneeId?: string;
dueDate?: string;
}
export interface TaskFilters {
status?: TaskStatus;
priority?: TaskPriority;
assigneeId?: string;
search?: string;
}
packages/shared/src/types/api.ts
// Generische API Response
export interface ApiResponse<T> {
success: true;
data: T;
meta?: PaginationMeta;
}
export interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
}
export type ApiResult<T> = ApiResponse<T> | ApiError;
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
meta: PaginationMeta;
}
// Type Guard
export function isApiError<T>(response: ApiResult<T>): response is ApiError {
return !response.success;
}
export function isApiSuccess<T>(response: ApiResult<T>): response is ApiResponse<T> {
return response.success;
}
packages/shared/src/index.ts
// Re-export all types
export * from "./types/user";
export * from "./types/task";
export * from "./types/api";
API Package (Backend)
packages/api/src/routes/tasks.ts
import { Router } from "express";
import {
Task,
CreateTaskInput,
UpdateTaskInput,
ApiResponse,
PaginatedResponse,
TaskFilters
} from "@app/shared";
import { taskService } from "../services/taskService";
import { asyncHandler, notFound } from "../middleware/errorHandler";
import { authenticate } from "../middleware/auth";
const router = Router();
// GET /tasks
router.get(
"/",
authenticate,
asyncHandler(async (req, res) => {
const filters: TaskFilters = {
status: req.query.status as any,
priority: req.query.priority as any,
assigneeId: req.query.assigneeId as string,
search: req.query.search as string
};
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const result = await taskService.findAll(filters, page, limit);
const response: PaginatedResponse<Task> = {
success: true,
data: result.tasks,
meta: result.meta
};
res.json(response);
})
);
// GET /tasks/:id
router.get(
"/:id",
authenticate,
asyncHandler(async (req, res) => {
const task = await taskService.findById(req.params.id);
if (!task) {
notFound("Task not found");
}
const response: ApiResponse<Task> = {
success: true,
data: task
};
res.json(response);
})
);
// POST /tasks
router.post(
"/",
authenticate,
asyncHandler(async (req, res) => {
const input: CreateTaskInput = req.body;
const task = await taskService.create(input);
const response: ApiResponse<Task> = {
success: true,
data: task
};
res.status(201).json(response);
})
);
// PUT /tasks/:id
router.put(
"/:id",
authenticate,
asyncHandler(async (req, res) => {
const input: UpdateTaskInput = req.body;
const task = await taskService.update(req.params.id, input);
if (!task) {
notFound("Task not found");
}
const response: ApiResponse<Task> = {
success: true,
data: task
};
res.json(response);
})
);
// DELETE /tasks/:id
router.delete(
"/:id",
authenticate,
asyncHandler(async (req, res) => {
const deleted = await taskService.delete(req.params.id);
if (!deleted) {
notFound("Task not found");
}
const response: ApiResponse<{ deleted: boolean }> = {
success: true,
data: { deleted: true }
};
res.json(response);
})
);
export default router;
Web Package (Frontend)
packages/web/src/api/client.ts
import { ApiResult, isApiError } from "@app/shared";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000/api";
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
...options.headers
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
const response = await fetch(url, {
...options,
headers
});
const data: ApiResult<T> = await response.json();
if (isApiError(data)) {
throw new Error(data.error.message);
}
return data.data;
}
get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
post<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: "POST",
body: JSON.stringify(body)
});
}
put<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: "PUT",
body: JSON.stringify(body)
});
}
delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {
method: "DELETE"
});
}
}
export const apiClient = new ApiClient();
packages/web/src/api/tasks.ts
import { Task, CreateTaskInput, UpdateTaskInput, TaskFilters, PaginatedResponse } from "@app/shared";
import { apiClient } from "./client";
export async function getTasks(filters?: TaskFilters & { page?: number; limit?: number }) {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.priority) params.set("priority", filters.priority);
if (filters?.assigneeId) params.set("assigneeId", filters.assigneeId);
if (filters?.search) params.set("search", filters.search);
if (filters?.page) params.set("page", filters.page.toString());
if (filters?.limit) params.set("limit", filters.limit.toString());
const query = params.toString();
return apiClient.get<Task[]>(`/tasks${query ? `?${query}` : ""}`);
}
export async function getTask(id: string): Promise<Task> {
return apiClient.get<Task>(`/tasks/${id}`);
}
export async function createTask(input: CreateTaskInput): Promise<Task> {
return apiClient.post<Task>("/tasks", input);
}
export async function updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
return apiClient.put<Task>(`/tasks/${id}`, input);
}
export async function deleteTask(id: string): Promise<{ deleted: boolean }> {
return apiClient.delete<{ deleted: boolean }>(`/tasks/${id}`);
}
packages/web/src/hooks/useTasks.ts
import { useState, useEffect, useCallback } from "react";
import { Task, CreateTaskInput, UpdateTaskInput, TaskFilters } from "@app/shared";
import * as tasksApi from "../api/tasks";
interface UseTasksReturn {
tasks: Task[];
loading: boolean;
error: Error | null;
filters: TaskFilters;
setFilters: (filters: TaskFilters) => void;
createTask: (input: CreateTaskInput) => Promise<Task>;
updateTask: (id: string, input: UpdateTaskInput) => Promise<Task>;
deleteTask: (id: string) => Promise<void>;
refresh: () => Promise<void>;
}
export function useTasks(): UseTasksReturn {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<TaskFilters>({});
const fetchTasks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await tasksApi.getTasks(filters);
setTasks(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"));
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const createTask = useCallback(async (input: CreateTaskInput) => {
const newTask = await tasksApi.createTask(input);
setTasks(prev => [...prev, newTask]);
return newTask;
}, []);
const updateTask = useCallback(async (id: string, input: UpdateTaskInput) => {
const updatedTask = await tasksApi.updateTask(id, input);
setTasks(prev => prev.map(t => t.id === id ? updatedTask : t));
return updatedTask;
}, []);
const deleteTask = useCallback(async (id: string) => {
await tasksApi.deleteTask(id);
setTasks(prev => prev.filter(t => t.id !== id));
}, []);
return {
tasks,
loading,
error,
filters,
setFilters,
createTask,
updateTask,
deleteTask,
refresh: fetchTasks
};
}
packages/web/src/components/TaskBoard.tsx
import { Task, TaskStatus, UpdateTaskInput } from "@app/shared";
interface TaskBoardProps {
tasks: Task[];
onUpdateTask: (id: string, input: UpdateTaskInput) => Promise<Task>;
onDeleteTask: (id: string) => Promise<void>;
}
const columns: { status: TaskStatus; title: string }[] = [
{ status: "todo", title: "To Do" },
{ status: "in-progress", title: "In Progress" },
{ status: "review", title: "Review" },
{ status: "done", title: "Done" }
];
export function TaskBoard({ tasks, onUpdateTask, onDeleteTask }: TaskBoardProps) {
const tasksByStatus = columns.map(column => ({
...column,
tasks: tasks.filter(t => t.status === column.status)
}));
const handleDrop = async (taskId: string, newStatus: TaskStatus) => {
await onUpdateTask(taskId, { status: newStatus });
};
return (
<div className="task-board">
{tasksByStatus.map(column => (
<TaskColumn
key={column.status}
title={column.title}
status={column.status}
tasks={column.tasks}
onDrop={handleDrop}
onDelete={onDeleteTask}
/>
))}
</div>
);
}
interface TaskColumnProps {
title: string;
status: TaskStatus;
tasks: Task[];
onDrop: (taskId: string, status: TaskStatus) => void;
onDelete: (id: string) => void;
}
function TaskColumn({ title, status, tasks, onDrop, onDelete }: TaskColumnProps) {
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const taskId = e.dataTransfer.getData("taskId");
onDrop(taskId, status);
};
return (
<div
className="task-column"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<h3>{title} ({tasks.length})</h3>
<div className="task-list">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
interface TaskCardProps {
task: Task;
onDelete: (id: string) => void;
}
function TaskCard({ task, onDelete }: TaskCardProps) {
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData("taskId", task.id);
};
return (
<div
className={`task-card priority-${task.priority}`}
draggable
onDragStart={handleDragStart}
>
<h4>{task.title}</h4>
{task.description && <p>{task.description}</p>}
<div className="task-meta">
<span className="priority">{task.priority}</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</div>
</div>
);
}
Vorteile dieser Architektur
- Geteilte Typen: Änderungen an Types werden sofort in Frontend und Backend sichtbar
- Typ-Sicherheit: Ende-zu-Ende Type Safety von API bis UI
- Konsistenz: Gleiche Datenstrukturen überall
- Refactoring: Änderungen werden projekt-weit erkannt
Was du gelernt hast
- Monorepo Setup mit TypeScript Workspaces
- Shared Packages für geteilte Typen
- API Client mit generischen Typen
- Type Guards für API Responses
- React Hooks mit shared Types
Herzlichen Glückwunsch! Du hast den TypeScript-Kurs abgeschlossen und bist bereit für professionelle TypeScript-Entwicklung!