Zum Inhalt springen
TypeScript Profi 90 min

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

  1. Geteilte Typen: Änderungen an Types werden sofort in Frontend und Backend sichtbar
  2. Typ-Sicherheit: Ende-zu-Ende Type Safety von API bis UI
  3. Konsistenz: Gleiche Datenstrukturen überall
  4. 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!

Zurück zum TypeScript Kurs