This capstone is the culmination of the entire course: you will build a fully-typed, end-to-end full-stack cricket application where a single source of truth for types flows from the database through the server, across the network boundary, and into the React client — so that renaming a field on the server produces a compile error in the client, and no untyped data exists anywhere in the system. It is the project that proves you can wield TypeScript not as a per-file convenience but as an architectural foundation for an entire application, the way the most demanding production systems use it. Every concept from the six modules converges here: the foundations of interfaces and unions, the core type system of generics and discriminated unions, the applied utility and conditional types, the advanced type-level programming, the production concerns of strict config and validation, and the mastery-level patterns of illegal-states-unrepresentable and parse-don't-validate.
The application is a cricket statistics platform with a typed API: a server that stores players and matches, exposes a contract-defined set of endpoints with validated request handling, and a React client that consumes those endpoints with full type inference and discriminated-union state. The architectural goal — and the real lesson — is end-to-end type safety: the client and server share one set of type definitions, so the type contract is enforced across the network boundary the same way it is within a single file. This is genuinely portfolio-defining work, the kind that demonstrates to any employer that you can architect a type-safe system, because it requires composing the entire toolkit into a coherent whole where the types are the connective tissue holding the application together. By the end you will have built, and understood, a system where TypeScript guarantees that every layer agrees — which is the highest expression of what the language offers.
Learning Objectives
- Architect a single shared source of truth for types that flows from server to client, so the network boundary is type-checked exactly like an in-file call.
- Compose every layer — entity types, a typed API contract, validated server handlers, and an inferring client — into one coherent end-to-end-typed system.
- Apply parse-don't-validate at the server boundary so untrusted input becomes typed-valid data, with the same schema providing both runtime validation and static types.
- Make illegal states unrepresentable across the stack using discriminated unions for API results and client state, so contradictory states cannot occur anywhere.
- Use generics, indexed access, and conditional types so the client derives each endpoint's exact response type from the shared contract with zero manual annotation.
- Configure the project for production with strict mode, shared types via project references or a shared module, and a type-check gate that proves every layer agrees.
Technical Requirements
- A shared types module defining CricketPlayer and Match entities plus an Endpoints contract mapping each endpoint to its request and response types, imported by both server and client.
- A discriminated-union ApiResult<T> type used by every endpoint so success carries typed data and failure carries a status and message, handled exhaustively on both sides.
- A server with a generic request-handling layer that derives parameter and response types from the shared contract via indexed access, constrained to keyof Endpoints.
- Boundary validation on the server that takes unknown request input and parses it into the contract's typed shape, rejecting malformed input with a typed error result.
- A typed client whose methods derive their argument and return types from the same shared contract, so calling an endpoint returns exactly the server's response type.
- A React UI that consumes the client, modelling its loading/success/error state as a discriminated union so the data is only accessible once it has actually loaded.
- Strict mode enabled across the whole project with no any and no non-null assertions, proving every layer's types agree under maximum strictness.
- A verification step — tsc --noEmit plus a small set of runtime and type tests — confirming the client and server agree and that endpoints behave correctly.
Architecture & Design
The architecture's organising principle is a single shared type contract that both the server and the client import, making the types the connective tissue of the whole system. At the base sit the entity types — `CricketPlayer` and `Match` — describing the domain. Above them sits the `Endpoints` contract, an object type mapping each endpoint key to its request and response types, and a discriminated `ApiResult<T>` union that every endpoint returns. This shared module is the heart of the design: because both the server's handlers and the client's methods derive their types from it, the two halves cannot disagree — a change to a response type in the contract simultaneously updates what the server must return and what the client receives, with the compiler flagging any layer that falls out of step. This is what 'end-to-end type safety' means concretely: the contract is enforced across the network boundary as rigorously as a function signature is enforced within a module.
The data flow runs in a complete loop. The client invokes a typed method, which sends a request whose shape is dictated by the contract; the server receives it as untrusted `unknown`, validates and parses it into the contract's typed shape at the boundary, runs the business logic against typed entities, and returns an `ApiResult` whose type the contract specifies; the client receives that result with exactly the contract's response type and narrows the discriminated union to render success or failure. Each layer is responsible for one concern — the contract defines agreement, the server's boundary validation bridges untrusted to trusted, the generic handler derives types from the contract, and the client's state machine makes loading states safe — and the layering keeps these concerns isolated while the shared types keep them consistent. The design decisions throughout reflect the course's mastery principles: illegal states are unrepresentable (the `ApiResult` and client state are discriminated unions), validation happens once at the boundary (parse-don't-validate), and the domain is modelled precisely (closed-set roles, variant results). This is not merely a typed application but a type-architected one, where the type system is the load-bearing structure ensuring every part agrees.
// shared/contract.ts — the single source of truth, imported by SERVER and CLIENT.
// --- entity types (the domain) ---
export interface CricketPlayer {
id: number;
name: string;
role: "batter" | "bowler" | "all-rounder";
battingAverage: number;
}
export interface Match {
id: number;
venue: string;
result: "win" | "loss" | "tie" | "no-result";
}
// --- the API contract: every endpoint's request params and response type ---
export interface Endpoints {
getPlayer: { params: { id: number }; response: CricketPlayer };
listPlayers: { params: { role?: CricketPlayer["role"] }; response: CricketPlayer[] };
getMatch: { params: { id: number }; response: Match };
createPlayer: { params: Omit<CricketPlayer, "id">; response: CricketPlayer };
}
// --- discriminated result: illegal states (data AND error) unrepresentable ---
export type ApiResult<T> =
| { ok: true; value: T }
| { ok: false; status: number; message: string };
// Helper type accessors used by both sides (indexed access into the contract).
export type Params<E extends keyof Endpoints> = Endpoints[E]["params"];
export type Response<E extends keyof Endpoints> = Endpoints[E]["response"];Phase 1 — Core Implementation
Phase 1 establishes the shared contract and the server's generic core — the foundation everything else derives from. The shared module (shown in the architecture above) defines the entities, the `Endpoints` contract, the `ApiResult` discriminated union, and the `Params`/`Response` accessor types that use indexed access to pull each endpoint's shapes from the contract. The server's core is a single generic dispatch function constrained to `keyof Endpoints`, whose parameter and return types are derived from the contract via those accessors, so adding an endpoint to the contract automatically extends what the server can type-safely handle. This is where the course's earlier modules converge structurally: generics and constraints from Module 2, indexed access from Module 3, and the discriminated `ApiResult` from the mastery patterns all combine into the typed backbone.
The deeper purpose of building the contract and core first is that they define the agreement the rest of the system conforms to — once they exist, the server handlers and client methods are guided by the compiler to match. The shared module is deliberately implementation-free: it contains only types and the small helper accessors, so both server and client can import it without pulling in server-only or client-only code, which is what lets one definition serve both sides. The generic core demonstrates that a single well-typed function, reading the contract through indexed access, can dispatch every endpoint with full type safety — the same pattern you built in the Module 3 API client project, now sitting at the centre of a full system. Getting this foundation right means every handler and every client call inherits correctness automatically, which is precisely why the architecture invests in it first: it is the load-bearing structure on which the validated server and the inferring client are built.
// server/core.ts — Phase 1: the generic, contract-driven dispatch core.
import type { Endpoints, ApiResult, Params, Response } from "../shared/contract";
// A handler for an endpoint receives its typed params, returns its typed response.
type Handler<E extends keyof Endpoints> = (params: Params<E>) => Response<E>;
// The handler registry is typed so every endpoint MUST have a matching handler.
type Handlers = { [E in keyof Endpoints]: Handler<E> }; // mapped type over the contract
// In-memory store (stands in for a database).
const players = new Map<number, Endpoints["getPlayer"]["response"]>();
players.set(2, { id: 2, name: "Virat Kohli", role: "batter", battingAverage: 50.1 });
const handlers: Handlers = {
getPlayer: (p) => players.get(p.id)!, // (validated upstream)
listPlayers: (p) => [...players.values()].filter((x) => !p.role || x.role === p.role),
getMatch: (p) => ({ id: p.id, venue: "MCG", result: "win" }),
createPlayer:(p) => { const id = Date.now(); const np = { id, ...p }; players.set(id, np); return np; },
};
// The generic core: endpoint constrained to keyof Endpoints; types derived per call.
export function dispatch<E extends keyof Endpoints>(
endpoint: E,
params: Params<E>,
): ApiResult<Response<E>> {
try {
const handler = handlers[endpoint] as Handler<E>;
return { ok: true, value: handler(params) };
} catch (err) {
return { ok: false, status: 500, message: (err as Error).message };
}
}
console.log(dispatch("getPlayer", { id: 2 }));Phase 2 — Feature Completion
Phase 2 adds the server's boundary validation and exposes the endpoints over HTTP, completing the server half. The validation layer embodies parse-don't-validate: each endpoint's incoming request is typed `unknown`, then a parser checks it against the contract's expected param shape and either returns the typed-valid params or a typed `ApiResult` error — so the `dispatch` core only ever receives validated, correctly-typed input. This is the bridge between the untrusted network and the trusted typed interior, and it is the single most important production concern from Module 5, applied at the system's most exposed surface. The same parsing functions could be generated from a schema library, with the schema serving as one source for both the runtime validation and the static type.
The deeper design point is that validation is concentrated entirely at this boundary, so everything inward — the `dispatch` core, the handlers, the entity logic — operates on data whose types are guaranteed accurate, never re-checking. This is what makes the server both safe and clean: the messy uncertainty of untrusted input is handled once, at the edge, and the typed interior trusts its data completely. The HTTP layer wires each route to validate-then-dispatch, returning the `ApiResult` as JSON, so the wire format carries the same discriminated structure the types describe. Completing the server this way means it is fully type-safe end to end on its own side: untrusted input is parsed into the contract's types, the generic core dispatches with derived types, and the response conforms to the contract — and crucially, because the contract is shared, this same response type is exactly what the client will receive, setting up the end-to-end guarantee the final phase completes.
// server/api.ts — Phase 2: boundary validation (parse, don't validate) + HTTP.
import { dispatch } from "./core";
import type { Endpoints, ApiResult, Params } from "../shared/contract";
// Parse untrusted input into the contract's typed params, or return a typed error.
function parseParams<E extends keyof Endpoints>(endpoint: E, raw: unknown):
{ ok: true; params: Params<E> } | { ok: false; result: ApiResult<never> } {
const fail = (msg: string) =>
({ ok: false as const, result: { ok: false as const, status: 400, message: msg } });
if (typeof raw !== "object" || raw === null) return fail("body must be an object");
const r = raw as Record<string, unknown>;
switch (endpoint) {
case "getPlayer":
case "getMatch":
if (typeof r.id !== "number") return fail("id (number) required");
return { ok: true, params: { id: r.id } as Params<E> };
case "createPlayer":
if (typeof r.name !== "string" || !["batter","bowler","all-rounder"].includes(r.role as string)
|| typeof r.battingAverage !== "number")
return fail("invalid player payload");
return { ok: true, params: r as Params<E> };
case "listPlayers":
return { ok: true, params: { role: r.role } as Params<E> };
default:
return fail("unknown endpoint");
}
}
// HTTP handler: validate at the boundary, then dispatch with trusted typed input.
export function handleRequest<E extends keyof Endpoints>(endpoint: E, body: unknown): ApiResult<Endpoints[E]["response"]> {
const parsed = parseParams(endpoint, body);
if (!parsed.ok) return parsed.result; // typed error, no crash
return dispatch(endpoint, parsed.params); // dispatch gets VALIDATED typed params
}
console.log(handleRequest("getPlayer", { id: 2 })); // success
console.log(handleRequest("getPlayer", { id: "two" })); // typed 400 errorPhase 3 — Polish & Production Readiness
Phase 3 builds the typed client and the React UI, completing the end-to-end loop and proving the central thesis: because the client imports the same shared contract as the server, calling an endpoint returns exactly the server's response type, and the compiler enforces agreement across the network boundary. The client's methods derive their argument and return types from the contract via the same indexed-access accessors the server uses, so there is zero manual annotation and zero possibility of the client and server disagreeing about a shape — rename a field in the shared contract and both the server handler and the client consumer fail to compile until updated. The React component models its loading/success/error state as a discriminated union, making illegal states unrepresentable: the player data is accessible only in the `success` branch, so the classic bug of rendering data that has not loaded cannot occur.
The production-readiness work ties off the system: strict mode is enabled across the whole project with no `any` and no non-null assertions, a `tsc --noEmit` step proves every layer's types agree, and a small set of tests — runtime tests for the server's success and error paths, and `expectTypeOf` type tests confirming the client's return types match the contract — locks in the guarantees against regression. This is where the entire course pays off at once: the strict configuration of Module 5, the testing discipline of Module 5, the discriminated-union UI state and parse-don't-validate of the mastery patterns, the generics and indexed access of Modules 2 and 3, and the foundational typed shapes of Module 1 all operate together in one coherent system. When `tsc --noEmit` passes with no escape hatches, it is a complete proof that the database shapes, the server handlers, the network contract, and the React UI all agree — which is the highest expression of end-to-end type safety and exactly the achievement that makes this a portfolio-defining capstone.
// client/api.ts + ui.tsx — Phase 3: the inferring client and discriminated UI state.
import type { Endpoints, ApiResult, Params } from "../shared/contract";
import { handleRequest } from "../server/api"; // (real client would fetch over HTTP)
// The client derives EVERY method's types from the SAME shared contract.
async function call<E extends keyof Endpoints>(endpoint: E, params: Params<E>):
Promise<ApiResult<Endpoints[E]["response"]>> {
// In a real app: fetch(`/api/${endpoint}`, { body: JSON.stringify(params) }).
return handleRequest(endpoint, params); // returns the contract's exact response type
}
export const cricketApi = {
getPlayer: (id: number) => call("getPlayer", { id }), // returns ApiResult<CricketPlayer>
listPlayers: (role?: Endpoints["listPlayers"]["params"]["role"]) => call("listPlayers", { role }),
createPlayer:(p: Params<"createPlayer">) => call("createPlayer", p),
};
// React UI state as a discriminated union — illegal states unrepresentable.
type ViewState =
| { status: "loading" }
| { status: "ready"; player: Endpoints["getPlayer"]["response"] } // data ONLY here
| { status: "failed"; message: string };
function renderPlayer(state: ViewState): string {
switch (state.status) {
case "loading": return "Loading...";
case "ready": return `${state.player.name}: avg ${state.player.battingAverage}`; // typed
case "failed": return `Error: ${state.message}`;
default: { const _x: never = state; return _x; } // exhaustive across the stack
}
}
// End-to-end: rename 'battingAverage' in the shared contract and THIS line fails to compile.
async function demo() {
const result = await cricketApi.getPlayer(2);
const state: ViewState = result.ok
? { status: "ready", player: result.value } // result.value is the contract's CricketPlayer
: { status: "failed", message: result.message };
console.log(renderPlayer(state));
}
demo();
// Verify the whole system agrees:
// tsc --noEmit # proves DB shapes, server, contract, and UI all align
// vitest # runtime + type tests lock in the guaranteesEvaluation Rubric
- A single shared contract module defines the entities, endpoint map, and ApiResult, and is imported by both server and client so the types cannot diverge.
- The server's generic dispatch core is constrained to keyof Endpoints and derives parameter and response types from the contract via indexed access.
- Untrusted input is parsed into the contract's typed shape at the server boundary, with the typed core and handlers never re-validating already-trusted data.
- Every endpoint returns a discriminated ApiResult, and both server and client narrow on the ok discriminant, with illegal states unrepresentable throughout.
- The client's methods derive their argument and return types from the same shared contract, so calling an endpoint yields exactly the server's response type.
- The React UI models loading, success, and error as a discriminated union, so data is accessible only once loaded and every case is handled exhaustively.
- The whole project compiles under strict mode with no any and no non-null assertions, and a tsc --noEmit gate plus tests prove every layer agrees end to end.
Extension Challenges: (1) Replace the hand-written boundary validation with a schema library like Zod, defining each endpoint's params schema once and deriving both the runtime validator and the contract's static type from it via infer, so validation and types share a single source. (2) Generate the typed client methods automatically from the Endpoints contract using mapped and template-literal types, so adding an endpoint to the contract requires no client code changes. (3) Add real HTTP with fetch and a typed error-handling layer, plus optimistic-update client state modelled as a discriminated union, and wire a CI pipeline running tsc --noEmit and Vitest as mandatory gates — turning the capstone into a deployable, fully-type-safe production application that demonstrates the complete arc of everything this course has taught.