Typsicherheit zwischen Server und Client – ein unterschätztes Problem
Inhaltsverzeichnis
- Das Problem mit fetch und JSON
- Der klassische Workaround: Validierung zur Laufzeit
- Dank Sveltekit: Echte Typsicherheit mit Remote Functions
- Fazit
Das Problem mit fetch und JSON
response.json() gibt Promise<any> zurück – der Browser weiß zur Laufzeit nicht, was der Server schickt. Der Cast auf einen konkreten Typ ist deshalb oft die einfachste Lösung:
type Post = { title: string; slug: string };
const response = await fetch('/api/posts');
const posts = await response.json() as Post[];
Weil any jeden Cast akzeptiert, prüft TypeScript hier nichts. as Post[] kompiliert immer, egal was tatsächlich ankommt – es ist eine Behauptung, keine Validierung. Ändert sich die API, bleibt der Compiler still und der Fehler taucht erst zur Laufzeit auf.
Shared DTOs als Lösung
Ein naheliegender Ansatz: Server und Client teilen sich einen gemeinsamen Typ. Ändert sich die Datenstruktur, schlägt der Compiler auf beiden Seiten an.
// src/lib/types.ts — used on both server and client
export type PostDto = { title: string; pubDate: Date };
// server
import type { PostDto } from '$lib/types';
app.get('/api/posts', (req, res) => {
const posts: PostDto[] = await db.getPosts();
res.json(posts);
});
// client
import type { PostDto } from '$lib/types';
const response = await fetch('/api/posts');
const posts = await response.json() as PostDto[];
Wird ein Feld umbenannt oder entfernt, schlägt der Compiler auf beiden Seiten an – das ist ein echter Vorteil. Aber der Ansatz hat eine versteckte Schwachstelle.
Das JSON-Problem
JSON kennt nur eine Handvoll primitiver Typen: string, number, boolean, null, array und object. Alles andere geht beim Serialisieren verloren. Ein Date-Objekt wird zu einem ISO-8601-String, eine Map wird zu {}, undefined in Arrays zu null.
Das bedeutet: Selbst wenn der Server ein korrekt typisiertes PostDto schickt, kommt auf dem Client etwas anderes an – und der Compiler schweigt, weil er dem Cast vertraut:
// server sends: { title: "Hello", pubDate: new Date("2026-05-20") }
// JSON.stringify turns it into: { "title": "Hello", "pubDate": "2026-05-20T00:00:00.000Z" }
const response = await fetch('/api/posts');
const posts = await response.json() as PostDto[];
// TypeScript is happy — but this is a string at runtime, not a Date
console.log(typeof posts[0].pubDate); // "string"
posts[0].pubDate.toLocaleDateString(); // TypeError: pubDate.toLocaleDateString is not a function
Shared DTOs schaffen also eine konsistente Lüge: Server und Client sind sich einig über pubDate: Date – aber zur Laufzeit ist es ein string.
Der klassische Workaround: Validierung zur Laufzeit
Die übliche Antwort auf dieses Problem ist Laufzeit-Validierung mit einer Schema-Bibliothek wie Zod:
import { z } from 'zod';
const PostSchema = z.object({
title: z.string(),
slug: z.string(),
});
const PostsSchema = z.array(PostSchema);
type Post = z.infer<typeof PostSchema>;
const response = await fetch('/api/posts');
const data = await response.json();
const posts = PostsSchema.parse(data); // throws if shape doesn't match
Das ist deutlich besser – parse wirft einen Fehler, wenn die Daten nicht dem Schema entsprechen. Aber es hat seinen Preis: Das Schema muss manuell gepflegt und mit dem tatsächlichen API-Response synchron gehalten werden. Ändert sich der Server, muss man daran denken, auch das Client-Schema anzupassen. Und eine vergessene Aktualisierung führt wieder zu denselben Laufzeitfehlern – nur mit einer etwas lesbareren Fehlermeldung.
Man hat also weiterhin zwei Quellen der Wahrheit: die Server-Implementierung und das Client-Schema.
Dank Sveltekit: Echte Typsicherheit mit Remote Functions
Rich Harris hat Ende 2025 ein vielversprechendes Feature vorgestellt für genau diesen Anwendungsbereich vorgestellt.
Remote Functions lösen die oben genannten Probleme. Anstelle von JSON verwenden sie devalue als Serialisierungsformat – eine Bibliothek, die Date, Map, Set, undefined und zirkuläre Referenzen korrekt überträgt. Was der Server schickt, kommt auf dem Client auch als derselbe Typ an.
Dazu leitet TypeScript den Rückgabetyp direkt aus der Server-Implementierung ab – kein Cast, kein händisch gepflegtes Schema, keine zweite Quelle der Wahrheit:
// src/lib/server/posts.remote.ts
import { query } from '$app/server';
export const getPosts = query(async () => {
// the return type is inferred here, on the server
return [{ title: 'Post_1', /* other props */}];
});
<script lang="ts">
import { getPosts } from '$lib/server/posts.remote';
// fully typed — TypeScript knows the shape without any cast or manual schema
const posts = getPosts();
</script>
<svelte:boundary>
{#snippet pending()}
<p>Lädt...</p>
{/snippet}
{#each posts as post}
<p>{post.title}</p>
{/each}
</svelte:boundary>
Ändert sich der Rückgabetyp der Query – zum Beispiel weil ein Feld entfernt oder umbenannt wird – schlägt TypeScript sofort an allen Stellen an, die diesen Wert verwenden. Kein manuelles Schema, keine vergessene Aktualisierung, keine Laufzeitfehler durch auseinanderlaufende Typen.
Es gibt eine einzige Quelle der Wahrheit: die Server-Implementierung. Alles andere wird daraus abgeleitet.
Fazit
Das as-Muster bei fetch ist eine häufige Fehlerquelle in TypeScript-Projekten. Die Typen stimmen im Editor, der Build ist grün, wahrscheinlich schlagen sogar die Tests nicht an, weil die gemockten Daten ja dem erwarteten Schema entsprechen – und trotzdem knallt es zur Laufzeit.
Remote Functions machen diese Kategorie von Fehler strukturell unmöglich. Das macht Svelte und Sveltekit zu meinen absoluten Favoriten unter allen Frontend- und Meta-Frameworks.