TypeScript jako system kontraktów w Next.js App Router
W Next.js App Router TypeScript działa jako system kontraktów między warstwami: serwer-klient, dane wejściowe-domena, wejście-encja, mutacja-UI. Zamiast adnotacji nakładanych na kod, typy eliminują niedopuszczalne stany na granicach. Zwiększa to odporność projektu bez zbędnego kodu.
Kluczowe obszary kontraktów:
- Przekazywanie payload z Server Component do Client Component
- Parsowanie params i searchParams na wejściu
- Normalizacja encji domenowych
- Wyniki mutacji z przewidywalnymi stanami
Kontrakt server-to-client przez payload
Przekazywanie danych z serwera do klienta to krytyczna granica. Bez typowanego payload klient opiera się na domysłach o strukturze, a serwer wysyła dowolne obiekty.
Definiujemy typ payload:
// src/demo/contracts.ts
export type HelloPayload = {
appName: string;
renderedAt: string;
mode: "server-to-client";
initialNotes: DemoNote[];
};
Strona serwerowa zbiera dane ściśle według kontraktu:
// src/app/(demo)/demo-contract/page.tsx
import type { HelloPayload } from "@/demo/contracts";
import { getNotes } from "@/demo/notesStore";
import HelloClient from "./HelloClient";
export default function DemoContractPage() {
const payload: HelloPayload = {
appName: "Workbench Notes",
renderedAt: new Date().toISOString(),
mode: "server-to-client",
initialNotes: getNotes(),
};
return <HelloClient payload={payload} />;
}
Takie podejście gwarantuje serializowalność i spójność. Granica server/client w App Router definiuje renderowanie, zależności i przepływ danych.
Normalizacja danych wejściowych na granicy
Params i searchParams przychodzą jako string | string[] | undefined. Przenoszenie surowych danych wejściowych do domeny prowadzi do type assertion i sprawdzeń w całym drzewie.
Właściwe podejście to parsowanie na granicy:
// src/demo/noteId.ts
export function parseNoteId(value: unknown): DemoNoteId | null {
if (typeof value !== "string") return null;
const v = value.trim();
if (!/^n\\d+$/.test(v)) return null;
return v;
}
Narzędzie dla searchParams:
// src/lib/searchParams.ts
export type StringParamValue = string | string[] | undefined | null;
export function getStringParam(value: StringParamValue): string | undefined {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value[0];
return undefined;
}
Po normalizacji kod działa z poprawnymi wartościami, a TypeScript nie przeszkadza w logice wewnętrznej.
Niewyrażalne stany w encjach domenowych
Typy eliminują logicznie niemożliwe kombinacje. Encja jest zawsze kompletna i spójna:
type DemoNoteStatus = "draft" | "published" | "archived";
type DemoNotePriority = 1 | 2 | 3;
type DemoNote = {
id: DemoNoteId;
title: string;
status: DemoNoteStatus;
priority: DemoNotePriority;
tags: string[];
description: string;
createdAt: IsoDateString;
updatedAt: IsoDateString;
};
Typ wejściowy dopuszcza niekompletne dane:
type DemoNoteCreateInput = {
title: string;
status?: DemoNoteStatus;
priority?: DemoNotePriority;
tags?: string[];
description?: string;
};
Budowa z wartościami domyślnymi:
// src/demo/notesStore.ts
export function createNote(input: DemoNoteCreateInput): DemoNote {
const id: DemoNoteId = `n${Date.now()}`;
const t = new Date().toISOString();
const note: DemoNote = {
id,
title: input.title.trim(),
status: input.status ?? "draft",
priority: input.priority ?? 2,
tags: input.tags ?? ["new"],
description: input.description ?? "",
createdAt: t,
updatedAt: t,
};
notes = [note, ...notes];
return note;
}
UI działa z przewidywalnymi obiektami bez dodatkowych sprawdzeń.
Obsługa błędów TypeScript jako sygnałów kontraktów
Type is not assignable sygnalizuje złamany kontrakt, a nie lokalny błąd:
// Poprawnie
export function demo_id_barrier(raw: string) {
const noteId = parseNoteId(raw);
if (!noteId) return null;
return getNoteById(noteId);
}
// Niepoprawnie
export function demo_bad_fix(raw: string) {
const unsafeId = raw as any;
return getNoteById(unsafeId);
}
Possibly undefined rozwiązuje się za pomocą guard'ów:
export function demo_parseNoteId(raw: string) {
const noteId = parseNoteId(raw);
if (!noteId) return null;
return getNoteById(noteId);
}
Wczesny return zawęża typ dla TypeScript.
Union-typy dla stanów operacji
Zamiast flag isLoading/hasError używa się discriminated union:
export type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
export function ok<T>(data: T): ActionResult<T> {
return { ok: true, data };
}
export function fail(error: string): ActionResult<never> {
return { ok: false, error };
}
Automatyczne zawężanie typu:
export function demo_narrowing() {
const state = createResult(Math.random() > 0.5);
if (!state.ok) {
return state.error;
}
return state.data.title;
}
Nielogiczne stany są niemożliwe.
Co jest ważne:
- TypeScript ustala kontrakty na granicach server/client, params/domena, wejście/encja
- Niewyrażalne stany eliminują błędy runtime w UI
- Błędy TS to sygnały złamanych kontraktów, a nie lokalne problemy
- Union-typy zastępują flagi stanami operacji
- Normalizacja danych wejściowych na granicy upraszcza kod wewnętrzny
— Editorial Team
Brak komentarzy.