
Janne Göbel
Frontend-Dev
27.08.2026 | 8 min Lesezeit
Type-Sharing in Fullstack-TypeScript-Projekten
End-to-End Typensicherheit, aber wie?

"Durchgehende Typensicherheit", das ist das große Versprechen bei der Fullstack TypeScript Entwicklung. Was auf den ersten Blick logisch und einfach erscheint, wirft auf den zweiten allerdings die Frage auf: "End-to-End Type Safety, aber wie?".
Ich denke, jedem TypeScript-Entwickler sollte der Stellenwert der Typisierung bewusst sein und diesen auch schätzen. Niemand sollte der/die Eine sein wollen, dessen/deren TS-Code aus implicit any sowie !-Operatoren besteht und die Strategie in Sachen Typensicherheit auf beten beruht. In dem Fall kann man TypeScript auch über Bord werfen und bei Plain-JavaScript bleiben.
Angenommen, wir möchten beispielsweise eine Fullstack To-Do-App in TS entwickeln, bestehend aus einem Frontend und einem Backend mit Anbindung an eine Datenbank. Klassische Architektur für den Gottvater aller Demo-Softwareprojekte. Der Technologie-Stack ist eher zweitrangig; alles Folgende lässt sich auf Node.js-Express/Fastify- sowie NestJS-Backends mit React- oder Vue-Frontends anwenden. Also wie teilen wir unsere Typdeklaration zwischen Server und Client?
Bonus: Validierungsregeln
Um die Datenintegrität in der Datenbank zu gewährleisten, sollten schreibende Zugriffe über das Backend stets validiert werden. Außerdem ergibt es Sinn, dass diese Validierung nicht nur einmal unmittelbar vor dem Write im Backend geschieht, sondern mehrstufig bereits auf Clientseite. Somit trifft alles, was ich in diesem Beitrag über das Type-Sharing darlege, gleichermaßen auch auf das Teilen von Validierungsregeln zu.
Copy-Paste
In einem so kleinen Projekt ist der minimale Ansatz meist naheliegend. So könnte man das "Data Transfer Object" (DTO) für das To-Do definieren und die type.ts per Copy-Paste zwischen Frontend und Backend teilen. Been there, done that.
type ToDoDTO = {
id: string;
task: string;
done: boolean;
deadline?: Date;
createdAt: Date;
updatedAt: Date;
};
Frontend und Backend halten und konsumieren jeweils eine Kopie des DTO-Typs. Bei jeder Änderung muss die Parität zwischen beiden Anwendungen per Copy-Paste wieder hergestellt werden.
// fastify Backend
// src/modules/todo/type.ts
export type ToDoDTO = { ... }
// src/modules/todo/routes.ts
import type { ToDoDTO } from "./type.ts";
...
app.post<{ Body: Pick<ToDoDTO, "task" | "deadline"> }>(
"/",
async (request, reply) => { ... },
);
// React Frontend
// src/lib/types/todo.ts
export type ToDoDTO = { ... }
// src/features/create.tsx
import type { ToDoDTO } from "@lib/types/todo.ts";
...
const { mutateAsync } = useMutation({
mutationFn: async (value: Pick<ToDoDTO, "task" | "deadline">) =>
await fetch(url, {
method: "POST",
body: JSON.stringify(value),
}),
});
Die Codebeispiele sind absichtlich sehr simpel gehalten, da so die Vor- und Nachteile der Lösung deutlich werden. Allerdings birgt diese, neben dem einfachen Setup (nämlich keinem), eigentlich nur Risiken.
Es gibt keine "Single-Source-of-Truth", das heißt es gibt keine Garantie, dass beide Frontend und Backend unter dem ToDoDTO dasselbe verstehen.
// Backend
type ToDoDTO = {
...
deadline: Date; // non-nullable
};
// Frontend
type ToDoDTO = {
...
deadline?: Date; // optional
};
Selbst wenn beide Seiten identische Typdeklarationen halten, so ist deren Verwendung nicht verbindlich und mangelhafte Ableitungen sowie Erweiterungen möglich.
// Backend
... Pick<ToDoDTO, "task" | "deadline" | "done"> ...
// Frontend
... Pick<ToDoDTO, "task" | "deadline"> ... // Inkompatible Mutation

Was in einem kleinen Solo-Projekt nach verwaltbaren Risiken aussieht, potenziert sich in einem schnell wachsenden Projekt mit mehreren Mitarbeitern.
Vorteile
- Simples Setup / Schneller Start
Nachteile
- Wartungsaufwand
- Keine Single-Source-of-Truth / Risiko falscher Sicherheit
- Schlechte Skalierbarkeit
Privates NPM Paket
Eine zentrale Wahrheitsquelle ist die Grundlage für verlässliche Typen, die dafür sorgen, dass ich nachts ruhig schlafen kann. Ein Weg, die eindeutige Wahrheitsquelle zu erreichen, wäre den geteilten Code in ein separates Repository auszulagern.
Projekt/
├── backend/
├── frontend/
└── todo-domain-shared/
└── src/
└── types/
└── schema/
Allerdings muss dieses Projekt als NPM-Paket veröffentlicht werden, sodass Frontend und Backend dessen Inhalt gleichermaßen konsumieren können. Das hat den entscheidenden Vorteil, dass Typen nur einmal - an einer zentralen Stelle - definiert und gewartet werden müssen.
// todo-domain-shared
// src/types/todo.ts
export type ToDoDTO = { ... };
export type CreateToDo = Pick<ToDoDTO, "task" | "deadline">;
export type UpdateToDo = Omit<ToDoDTO, "createdAt" | "updatedAt">;
Damit diese Lösung reibungslos funktioniert muss das Paket versioniert werden sowie Frontend und Backend dieselbe Version verwenden.
// fastify Backend
// package.json
{
...
"dependencies": {
"@my-todos-app/todo-domain-shared": "0.9.5",
},
...
}
// src/modules/todo/routes.ts
import type { CreateToDo } from "@my-todos-app/todo-domain-shared";
...
app.post<{ Body: CreateToDo }>("/", async (request, reply) => { ... });
// React Frontend
// package.json
{
...
"dependencies": {
"@my-todos-app/todo-domain-shared": "0.9.5",
},
...
}
// src/features/create.tsx
import type { CreateToDo } from "@my-todos-app/todo-domain-shared";
...
const { mutateAsync } = useMutation({
mutationFn: async (value: CreateToDo) =>
await fetch(url, {
method: "POST",
body: JSON.stringify(value),
}),
});
Ein Problem wäre somit eingeschränkt gelöst - aber warum eingeschränkt? Denn unsere "Single-Source-of-Truth" bleibt gebunden an eine bestimmte Version. Somit gibt es immer noch keine Garantie dafür, dass die verwendeten Stände des Pakets innerhalb des Stacks synchron sind. Ebenso ist die korrekte Verwendung der geteilten Typen nach wie vor nicht zwingend gegeben.
Vorteile
- Single-Source-of-Truth / Typensicherheit
- Bessere Wart- und Skalierbarkeit
Nachteile
- Setup- und Wartungsaufwand
Mono-Repository
Dem aufmerksamen Leser könnte die Ordnerstruktur der NPM-Paket-Lösung bereits bekannt vorgekommen sein. Eine weitere Möglichkeit, um eine zentrale Typdefinition zu erreichen liegt in der Repository-Architektur.

Denn genau dieser Aufbau lässt sich in einem Mono-Repository zusammenfassen.
my-todos-app/
└── apps/
├── backend/
└── frontend/
└── packages/
├── types/
└── schemas/
In einem Mono-Repository teilen sich alle Anwendungen dieselbe Versionskontrolle und Speicher. Somit ist keine separate Versionierung mehr nötig und der Code kann ohne weiteres im Stack geteilt werden.
// packages/types/src/todo.ts
export type ToDoDTO = { ... };
export type CreateToDo = Pick<ToDoDTO, "task" | "deadline">;
export type UpdateToDo = Omit<ToDoDTO, "createdAt" | "updatedAt">;
// apps/backend/src/modules/todo/routes.ts
import type { CreateToDo } from "@my-todos-app/types";
...
app.post<{ Body: CreateToDo }>("/", async (request, reply) => { ... });
// apps/frontend/src/features/create.tsx
import type { CreateToDo } from "@my-todos-app/types";
...
const { mutateAsync } = useMutation({
mutationFn: async (value: CreateToDo) =>
await fetch(url, {
method: "POST",
body: JSON.stringify(value),
}),
});
Mit dem Mono-Repository-Ansatz lässt sich das Type-Sharing-Problem beheben. Ausgenommen ist hier jedoch, wie die Typen im Endeffekt angewendet werden, da Queries/Mutations noch immer per Hand implementiert werden müssen. Zudem hängt die Verwendung eines Mono-Repositorys nicht nur von konsistenten Typen ab. Die Organisation innerhalb des Projekts, die Coding-Philosophie, sowie der konkrete Anwendungsfall sind letztendlich maßgeblich bei dieser Entscheidung.
Vorteile
- Single-Source-of-Truth / Typensicherheit
- Gute Wart- und Skalierbarkeit
Nachteile
- Erhöhter Setupaufwand
Code-/Type-Generierung
Immer wieder war die Rede von der "Single-Source-of-Truth" und deren Rolle bei der Typensicherheit einer Anwendung. Aber was, wenn ich sagen würde, dass eine dedizierte Stelle mit geteilten Typdefinitionen gar nicht zwingend notwendig ist? So ist die Überraschungsenthüllung meines TS-Type-Sharing-Beitrags, die Typen gar nicht zu teilen. Schockierend oder?
Bei allen Lösungsansätzen, die ich bisher vorgestellt habe, musste ich zum Ende anmerken, dass keiner die korrekte Verwendung der geteilten Typen garantiert. Da dies in Fullstack-Projekten allerdings der springende Punkt ist, kommt an genau dieser Stelle Code-Generierung ins Spiel und vollendet meinen Mount Rushmore des Type-Sharings. Streng genommen definiert das Backend mit seinem Code schon eine Wahrheit bezüglich seiner Schnittstellen und Endpunkte.
// fastify Backend
// src/app.ts
import swagger from "@fastify/swagger";
import { jsonSchemaTransform } from "fastify-type-provider-zod";
await app.register(swagger, {
openapi: {
info: {
title: "my-todos-app-api",
version: "0.9.5",
},
servers: [ ... ],
components: { ... },
},
transform: jsonSchemaTransform,
});
// src/modules/todo/routes.ts
import type { ToDoDTO } from "./type.ts";
...
fastify.post<{ Body: CreateToDo }>(
"/",
{ schema: { tags: ["ToDo"], body: CreateToDoSchema } },
async (request, reply) => { ... },
);
Anhand einer geeigneten OpenAPI-Dokumentation kann man sich dann die entsprechenden Typen, aber auch Schemas und schlüsselfertige Queries generieren.
// React Frontend
// src/features/create.tsx
import { createToDoMutation } from "@/lib/api/@tanstack/react-query.gen";
...
const { mutateAsync } = useMutation(createToDoMutation());
Gerade das ist es, was diesen Ansatz so charmant macht. Denn dadurch, dass der Code aus den gültigen Spezifikationen des Backends generiert wird, ist - sofern nicht händisch verändert - eine korrekte Verwendung zugesichert. Wie bereits erwähnt, setzt das eine umfassende Dokumentation der Backend-Schnittstellen voraus, welche in einem Projekt mit mehreren Mitarbeitern vermutlich ohnehin ratsam wäre. Synchronisation ist auch hier ein Thema, welches nicht vernachlässigt werden darf, da der generierte Code immer up-to-date sein muss. Dennoch bleibt das Backend eigenständig und kann im Zweifel von verschiedenen Frontends implementiert werden.
Vorteile
- Schlüsselfertiger / Typsicherer Code
- Einfache Implementierung von API-Requests
- Gute Wart- und Skalierbarkeit
Nachteile
- Erhöhter Setup- und Dokumentationsaufwand
Fazit
Was sollte man also am Ende des Tages aus diesem Beitrag mitnehmen? An erster Stelle sollten meiner Meinung nach immer konsistente Typen stehen, da diese schlussendlich entscheidend für die Developer-Experience und Skalierbarkeit der jeweiligen Anwendung ist. Darüber hinaus ist es - denke ich - vollkommen egal, für welchen Weg man sich am Ende des Tages entscheidet. Möchte ich eine App für die Haushaltsaufgaben in meiner WG entwickeln, ist ein Copy-Paste-Ansatz unter Umständen vollkommen ausreichend. Ist mein Ziel jedoch eine Fullstack-Anwendung mit kommerziellem Nutzen, bei der das Entwicklerteam und die Anwendung selbst in Zukunft stark wachsen sollen, muss die Lösung robust und gut zu warten sein.