Back to Home

TypeScript Contracts Next.js App Router

The article shows the application of TypeScript as a contract system in Next.js App Router. Server-client payload types, params normalization, domain entities and union-states exclude invalid states at the application boundaries.

TypeScript as Contracts in Next.js: real examples
Advertisement 728x90

TypeScript as a Contract System in Next.js App Router

In Next.js App Router, TypeScript acts as a contract system between layers: server-client, external input-domain, input-entity, mutation-UI. Instead of annotations on top of code, types eliminate invalid states at boundaries. This enhances project resilience without extra code.

Key contract zones:

  • Passing payload from Server Component to Client Component
  • Parsing params and searchParams on input
  • Normalizing domain entities
  • Mutation results with predictable states

Server-to-client contract via payload

Passing data from server to client is a critical boundary. Without a typed payload, the client relies on guesses about structure, and the server sends arbitrary objects.

Google AdInline article slot

Define a payload type:

// src/demo/contracts.ts
export type HelloPayload = {
  appName: string;
  renderedAt: string;
  mode: "server-to-client";
  initialNotes: DemoNote[];
};

The server page assembles data strictly by contract:

// 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} />;
}

This approach guarantees serializability and consistency. The server/client boundary in App Router defines rendering, dependencies, and data flow.

Google AdInline article slot

Normalizing external input at the boundary

Params and searchParams arrive as string | string[] | undefined. Passing raw input into the domain leads to type assertions and checks throughout the tree.

The correct approach is parsing at the boundary:

// 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;
}

Utility for searchParams:

Google AdInline article slot
// 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;
}

After normalization, code works with valid values, and TypeScript doesn't interfere with internal logic.

Inexpressible states in domain entities

Types exclude logically impossible combinations. Entities are always complete and consistent:

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;
};

Input type allows incomplete data:

type DemoNoteCreateInput = {
  title: string;
  status?: DemoNoteStatus;
  priority?: DemoNotePriority;
  tags?: string[];
  description?: string;
};

Assembly with defaults:

// 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 works with predictable objects without checks.

TypeScript error handling as contract signals

Type is not assignable signals a broken contract, not a local error:

// Correct
export function demo_id_barrier(raw: string) {
  const noteId = parseNoteId(raw);
  if (!noteId) return null;

  return getNoteById(noteId);
}

// Incorrect
export function demo_bad_fix(raw: string) {
  const unsafeId = raw as any;
  return getNoteById(unsafeId);
}

Possibly undefined is resolved with guards:

export function demo_parseNoteId(raw: string) {
  const noteId = parseNoteId(raw);

  if (!noteId) return null;

  return getNoteById(noteId);
}

Early return narrows the type for TypeScript.

Union types for operation states

Instead of isLoading/hasError flags, use 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 };
}

Automatic type narrowing:

export function demo_narrowing() {
  const state = createResult(Math.random() > 0.5);

  if (!state.ok) {
    return state.error;
  }

  return state.data.title;
}

Illogical states are impossible.

Key takeaways:

  • TypeScript enforces contracts at server/client, params/domain, input/entity boundaries
  • Inexpressible states eliminate runtime errors in UI
  • TypeScript errors signal broken contracts, not local issues
  • Union types replace flags with operation states
  • Normalizing external input at the boundary simplifies internal code

— Editorial Team

Advertisement 728x90

Read Next