TypeScript Boundaries: Deep Dive #67
Component Systems Strategist
This post follows the plan’s “TypeScript Boundaries” focus and global requirements:
- The App Router Mindset
- Core Concepts (Best Practices)
- Components and Composition
- Extra (plan): API Reference
This post uses standard TypeScript + React patterns. If you see conceptual APIs elsewhere on the site, treat them as illustrative.
TypeScript boundaries are where your app decides what it trusts. The goal isn’t “type every line” — it’s to define stable contracts between:
- external inputs (network, URL params, localStorage),
- domain models (validated data),
- UI props (render-ready shape).
When you make boundaries explicit, routing migrations are safer: you can move screens between segments without changing validation rules.
Boundary Types (A Useful Mental Model)
There are three boundaries that matter:
- Ingress boundary: untrusted input → validated shape.
- Domain boundary: validated shape → richer domain model (optional).
- UI boundary: domain model → render props (often derived).
The practical rule: do not let untrusted shapes leak into the UI boundary.
Worked Example: Ingress Boundary for Route Params + Fetch Results
We’ll build a /users/[id] page that:
- parses the
idparam safely, - fetches a user,
- validates the response,
- and passes a stable UI model into a component.
Step 1: Define Validators (Zod-style Without a Dependency)
You can use a library, but you can also start with lightweight type guards.
export type User = {
id: string;
name: string;
email: string;
role: "admin" | "member";
};
export function parseUser(input: unknown): User {
if (input == null || typeof input !== "object") throw new Error("Invalid user payload");
const value = input as any;
const id = typeof value.id === "string" ? value.id : null;
const name = typeof value.name === "string" ? value.name : "Anonymous";
const email = typeof value.email === "string" ? value.email : "unknown@example.com";
const role = value.role === "admin" || value.role === "member" ? value.role : "member";
if (!id) throw new Error("Missing user id");
return { id, name, email, role };
}
Step 2: Parse Route Params at the Ingress Boundary
export function parseUserIdParam(param: unknown): string {
if (typeof param !== "string" || param.trim().length === 0) {
throw new Error("Invalid user id param");
}
return param;
}
Step 3: Fetch + Validate (Server Component Example)
async function fetchUser(id: string) {
const res = await fetch(`https://example.invalid/api/users/${encodeURIComponent(id)}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`User fetch failed (${res.status})`);
const json = (await res.json()) as unknown;
return parseUser(json);
}
export default async function UserPage({ params }: { params: { id: string } }) {
const id = parseUserIdParam(params.id);
const user = await fetchUser(id);
const uiModel = toUserCardModel(user);
return <UserCard model={uiModel} />;
}
Step 4: UI Boundary (Derived Render Model)
export type UserCardModel = {
title: string;
subtitle: string;
badge: "Admin" | "Member";
};
export function toUserCardModel(user: User): UserCardModel {
return {
title: user.name,
subtitle: user.email,
badge: user.role === "admin" ? "Admin" : "Member",
};
}
export function UserCard({ model }: { model: UserCardModel }) {
return (
<section data-component="UserCard">
<h1>{model.title}</h1>
<p>{model.subtitle}</p>
<strong>{model.badge}</strong>
</section>
);
}
Boundary Checklist
- Treat route params and fetch JSON as
unknown. - Validate at ingress before passing into UI.
- Derive a UI model so components don’t depend on backend shape.
- Keep contracts stable so routing refactors don’t ripple through types.