Capstone: ship one feature, typed end-to-end
The finale: ship a 'create + list paginated comments' feature end-to-end. One zod schema derives every layer; a discriminated Result is handled exhaustively; the boundary is validated not cast; the client type is inferred — each move naming the earlier unit it came from.
You have one feature to ship: create a comment, then list the comments on a thread, paginated. A row in Postgres, an API endpoint, a client call, a <CommentList>. Every layer wants to know the shape of a comment. The junior instinct is to type that shape four times — once for the DB, once for the API response, once for the client, once for the props — and keep them “in sync” by hand. The senior move is to declare it once and let the compiler propagate it through every layer. This lesson ties together everything in the track by typing that single feature the right way, and naming which earlier unit each move comes from.
One source of truth, then derive (units 00, 02, 05, 08)
The whole strategy hangs on one rule from unit 08: derive, don’t duplicate. Pick one artifact that defines a comment, and make every other type a function of it. A zod schema is the strongest choice because it is simultaneously a runtime validator and a static type:
import { z } from "zod";
// THE source of truth. Everything downstream is derived from this.
export const Comment = z.object({
id: z.string().uuid(),
threadId: z.string().uuid(),
body: z.string().min(1).max(2000),
authorId: z.string().uuid(),
createdAt: z.coerce.date(),
});
// `z.infer` turns the runtime schema into a static type (unit 05's `infer`,
// unit 00's type-vs-value: `Comment` the value lives in value space,
// `Comment` the type lives in type space — same name, two namespaces).
export type Comment = z.infer<typeof Comment>;
// ^? { id: string; threadId: string; body: string; authorId: string; createdAt: Date }typeof Comment reads the value (the schema object) and lifts it into type space; z.infer then walks it into the object type. One edit to the schema — add editedAt — and every derived type changes with it. There is no second declaration to forget.
The input to create is a strict subset, and we derive that too rather than hand-writing it (unit 05 Pick/Omit, unit 02 object types):
export const NewComment = Comment.pick({ threadId: true, body: true, authorId: true });
export type NewComment = z.infer<typeof NewComment>;
// ^? { threadId: string; body: string; authorId: string }Model the result as a discriminated union, handle it exhaustively (unit 02)
When you return Comment | null, the caller knows the call failed but not why — and that missing information cascades into generic error messages and defensive if chains that still don’t handle every case. createComment can succeed, hit a validation error, or hit a thread that doesn’t exist. Don’t return Comment | null and lose why it failed — model the outcomes as a discriminated union keyed on a literal tag (unit 02), so narrowing on tag tells the compiler exactly which other fields exist:
type CreateResult =
| { tag: "created"; comment: Comment }
| { tag: "invalid"; issues: string[] }
| { tag: "thread_missing"; threadId: string };
function describe(r: CreateResult): string {
switch (r.tag) {
case "created": return `#${r.comment.id}`; // r.comment is in scope
case "invalid": return r.issues.join(", "); // r.issues is in scope
case "thread_missing": return `no thread ${r.threadId}`;
default: {
// Exhaustiveness guard: if a new tag is added and not handled,
// `r` is no longer `never` here and this line fails to compile.
const _exhaustive: never = r;
return _exhaustive;
}
}
}The never assignment is the trick from unit 02: in a fully-handled switch, control flow analysis narrows r to never in default, so the assignment type-checks. When you add a fourth variant later and forget a case, r is that variant — not assignable to never — so the compiler points at the exact gap rather than letting the unhandled case silently fall through.
Validate at the boundary — types are promises until validated (unit 08)
Here is the move that separates real type safety from theatre. The DB driver hands you unknown (or worse, any). A static annotation on that value is a promise the runtime never made (unit 08, and unit 00’s type-erasure point). You make the promise true by parsing, not casting:
declare function dbQuery(sql: string, args: unknown[]): Promise<unknown[]>;
async function listComments(threadId: string): Promise<Comment[]> {
const rows = await dbQuery("select * from comments where thread_id = $1", [threadId]);
// ^? unknown[] — the driver knows nothing about our shape
// WRONG (don't do this — it's the unit 08 anti-pattern):
// return rows as Comment[]; // a lie: zero runtime checking, createdAt is still a string
// RIGHT: parse at the boundary. z.array(Comment) coerces createdAt and rejects junk.
return z.array(Comment).parse(rows);
// ^? Comment[] — now the type is *earned*, not asserted
}as Comment[] would compile and then explode at runtime the first time createdAt is a string instead of a Date. parse turns a malformed row into a thrown ZodError at the boundary, where you can log it and return a 400 — instead of a cryptic .getTime is not a function three layers deep.
▸Why this works
Why is parse strictly better than a type guard you hand-write here? A hand-written function isComment(x: unknown): x is Comment re-encodes the shape a third time and can drift from the schema — and TypeScript does not check that the predicate body actually matches the asserted type, so a buggy guard is a silent as. z.array(Comment).parse derives the check from the same source of truth, so the runtime check and the static type can never disagree. Reserve hand-written guards for shapes you cannot express as a schema.
One small generic, the right amount of clever (units 03, 04, 05)
The list endpoint is paginated, and every paginated endpoint shares a shape. Capture it once as a generic — but keep it boring. This is the unit 05 lesson on restraint: a generic earns its place by removing duplication, not by showing off conditional-type gymnastics that blow up compile time.
// A single reusable shape. `T` is inferred at the call site, but the body stays
// a plain object (unit 03 generic functions, unit 04 the type system,
// unit 05 "stop before it's clever").
interface Paginated<T> {
items: T[];
nextCursor: string | null;
total: number;
}
function makePage<T>(items: T[], total: number, nextCursor: string | null): Paginated<T> {
return { items, total, nextCursor };
}
const page = makePage(await listComments("t-1"), 42, "cursor-43");
// ^? Paginated<Comment>
page.items[0].body; // fully typed; T was inferred as Comment from the argumentT is inferred from the items argument — no annotation at the call site. We deliberately did not reach for a DeepPartial<T> or a conditional-type cursor encoder: per unit 05, that cleverness costs reader comprehension and compiler time (deeply recursive conditional types are a real perf cliff) and buys nothing the team needs here. Right amount of clever = the minimum that kills the duplication.
satisfies for the route table; a type guard at the auth boundary (unit 06)
The route table is config: you want each handler’s literal types preserved (so routes.listComments keeps its exact signature) while still checking the whole object against a contract. That is exactly what satisfies is for (unit 06, TS 4.9+) — : RouteTable would widen the values; satisfies RouteTable validates without widening:
type Handler = (input: unknown) => Promise<unknown>;
type RouteTable = Record<string, Handler>;
const routes = {
createComment: async (input: unknown) => z.array(Comment).parse([]),
listComments: async (input: unknown) => listComments(NewComment.parse(input).threadId),
} satisfies RouteTable;
// Keys stay literal — autocomplete knows "createComment" | "listComments",
// not just `string`, because `satisfies` checked the shape without widening.
type RouteName = keyof typeof routes;
// ^? "createComment" | "listComments"At the auth boundary, a user-defined type guard (unit 06) narrows an unauthenticated request to an authenticated one for the rest of the handler:
type Ctx = { userId: string | null };
function isAuthed(ctx: Ctx): ctx is Ctx & { userId: string } {
return ctx.userId !== null;
}
// after `if (isAuthed(ctx))`, ctx.userId is `string`, not `string | null`.The flow across client and server: infer the client, don’t re-declare it (unit 08)
The payoff. With a tRPC-style server router, the client type is inferred from the server, so the comment shape declared once in the schema reaches the React component without a single re-declaration (unit 08 trpc-end-to-end). Re-typing the response on the client is the duplication we have been killing all lesson:
// server.ts — router shape is just an object of procedures
const appRouter = {
comments: {
list: (input: { threadId: string }): Promise<Comment[]> => listComments(input.threadId),
create: (input: NewComment): Promise<CreateResult> => { throw new Error("impl elided"); },
},
};
export type AppRouter = typeof appRouter; // the contract, derived not written
// client.tsx — the client is typed FROM AppRouter; no shape re-declared here
declare const trpc: {
comments: { list: { useQuery: (i: { threadId: string }) => { data?: Comment[] } } };
};
function CommentList({ threadId }: { threadId: string }) {
const { data } = trpc.comments.list.useQuery({ threadId });
// ^? Comment[] | undefined — inferred end-to-end from the one schema
return data?.map((c) => c.body) ?? []; // c is Comment, fully typed
}Change body to text in the zod schema and the React component fails to compile — the contract is enforced across the wire by inference, not by discipline. For the surrounding system, see React Server Components and Suspense streaming (frontend) for where this data is fetched and rendered, and Modeling REST resources (apis) for shaping the endpoint if you expose REST instead of tRPC.
What NOT to do — the unit 08 pitfalls, restated
- No
asat the boundary.rows as Comment[]is an unchecked lie;z.array(Comment).parse(rows)earns the type. The only thingasbuys at a boundary is a runtime crash later, further from the cause. - Derive, don’t duplicate. If a comment’s shape appears in more than one hand-written place, deleting a field becomes a manual hunt. Schema →
z.infer→Pick/Omit→typeof appRoutermeans one edit ripples everywhere. - Watch inference performance. The clever generic you don’t write costs zero compile time. Deeply recursive conditional/mapped types (unit 05) can stall
tscand editor hovers — reach for them only when a plain interface genuinely can’t express the shape.
In listComments, the driver returns unknown[]. You want a value typed Comment[]. Which line gives you real, runtime-backed type safety?
Order the moves to build the comments feature with end-to-end type safety, from source of truth outward:
- 1 Declare ONE source of truth: the zod Comment schema (and NewComment = Comment.pick(...))
- 2 Derive the static types: type Comment = z.infer<typeof Comment> — no second declaration
- 3 Model outcomes as a discriminated CreateResult union and handle it exhaustively (never default)
- 4 Validate at the runtime boundary: z.array(Comment).parse(rows), never `as Comment[]`
- 5 Pin the route table with `satisfies RouteTable` so handler literal types are preserved
- 6 Export typeof appRouter so the client infers Comment[] with zero re-declaration
The zod schema is the single source of truth, and z.infer / typeof / Pick / typeof appRouter all read from it. In one word, every other type in the feature is best described not as a copy but as a ____ of that schema.
- 01What does 'single source of truth, then derive' mean concretely for the comment feature, and which earlier mechanisms do the deriving?
- 02Why is `z.array(Comment).parse(rows)` the right boundary move and `rows as Comment[]` the wrong one?
- 03How does the comment shape reach the client without being re-declared, and what is 'the right amount of clever' for the pagination generic?
That is the whole track in one feature: declare once, derive everything (units 00, 02, 05), model outcomes as discriminated unions and handle them exhaustively (unit 02), parse at the boundary so types are earned not promised (unit 08), write the one small generic and no more (units 03, 04, 05), pin config with satisfies and guard boundaries with type predicates (unit 06), and let inference carry the contract to the client (unit 08). If any of those moves felt shaky, the deep units are where to go back: conditional types and utility types from scratch (units 04, 05) for the infer/Pick machinery, generic functions and constraints (unit 03) for Paginated<T>, overloads and this (unit 06) for satisfies and guards, and zod / tRPC / common pitfalls (unit 08) for the boundary and the wire. Now when you see a new type declared by hand in a second place — a separate ApiComment, a re-typed response, a cast at the driver boundary — you will know exactly which move is missing and which unit to reach for to close the gap.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.