Next.js App Router에서 계약 시스템으로서의 TypeScript
Next.js App Router에서 TypeScript는 서버-클라이언트, 외부 입력-도메인, 입력-엔티티, 변이-UI 등 계층 간 계약 시스템 역할을 합니다. 코드 위에 주석을 추가하는 대신, 타입이 경계에서 유효하지 않은 상태를 제거합니다. 이는 추가 코드 없이 프로젝트의 견고성을 향상시킵니다.
주요 계약 영역:
- 서버 컴포넌트에서 클라이언트 컴포넌트로 페이로드 전달
- 입력 시 params와 searchParams 파싱
- 도메인 엔티티 정규화
- 예측 가능한 상태의 변이 결과
페이로드를 통한 서버-클라이언트 계약
서버에서 클라이언트로 데이터를 전달하는 것은 중요한 경계입니다. 타입이 지정되지 않은 페이로드의 경우, 클라이언트는 구조에 대한 추측에 의존하고 서버는 임의의 객체를 전송합니다.
페이로드 타입 정의:
// src/demo/contracts.ts
export type HelloPayload = {
appName: string;
renderedAt: string;
mode: "server-to-client";
initialNotes: DemoNote[];
};
서버 페이지는 계약에 따라 엄격하게 데이터를 구성합니다:
// 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} />;
}
이 접근 방식은 직렬화 가능성과 일관성을 보장합니다. App Router의 서버/클라이언트 경계는 렌더링, 의존성 및 데이터 흐름을 정의합니다.
경계에서 외부 입력 정규화
Params와 searchParams는 string | string[] | undefined로 도착합니다. 원시 입력을 도메인에 전달하면 트리 전체에 타입 단언과 검사가 발생합니다.
올바른 접근 방식은 경계에서 파싱하는 것입니다:
// 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;
}
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;
}
정규화 후 코드는 유효한 값으로 작동하며, TypeScript는 내부 로직을 방해하지 않습니다.
도메인 엔티티에서 표현 불가능한 상태
타입은 논리적으로 불가능한 조합을 제외합니다. 엔티티는 항상 완전하고 일관됩니다:
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;
};
입력 타입은 불완전한 데이터를 허용합니다:
type DemoNoteCreateInput = {
title: string;
status?: DemoNoteStatus;
priority?: DemoNotePriority;
tags?: string[];
description?: string;
};
기본값으로 조립:
// 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는 검사 없이 예측 가능한 객체로 작동합니다.
계약 신호로서의 TypeScript 오류 처리
타입이 할당 불가능은 로컬 오류가 아닌 깨진 계약을 신호합니다:
// 올바른 방법
export function demo_id_barrier(raw: string) {
const noteId = parseNoteId(raw);
if (!noteId) return null;
return getNoteById(noteId);
}
// 잘못된 방법
export function demo_bad_fix(raw: string) {
const unsafeId = raw as any;
return getNoteById(unsafeId);
}
undefined 가능성은 가드로 해결됩니다:
export function demo_parseNoteId(raw: string) {
const noteId = parseNoteId(raw);
if (!noteId) return null;
return getNoteById(noteId);
}
조기 반환은 TypeScript에 대한 타입을 좁힙니다.
작업 상태를 위한 유니온 타입
isLoading/hasError 플래그 대신 판별 유니온 사용:
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 };
}
자동 타입 좁히기:
export function demo_narrowing() {
const state = createResult(Math.random() > 0.5);
if (!state.ok) {
return state.error;
}
return state.data.title;
}
논리적으로 불가능한 상태는 발생하지 않습니다.
핵심 요약:
- TypeScript는 서버/클라이언트, params/도메인, 입력/엔티티 경계에서 계약을 강제합니다
- 표현 불가능한 상태는 UI에서 런타임 오류를 제거합니다
- TypeScript 오류는 로컬 문제가 아닌 깨진 계약을 신호합니다
- 유니온 타입은 플래그를 작업 상태로 대체합니다
- 경계에서 외부 입력 정규화는 내부 코드를 단순화합니다
— Editorial Team
아직 댓글이 없습니다.