TypeScript Fundamentals: Types, Generics and Real-World Patterns · Generics, Utility Types and Real-World TypeScript
Capstone: A Typed Order Pipeline
Combine everything: validate untrusted input with type guards, model results with discriminated unions, process orders through a generic pipeline, and report totals with derived types.
What you'll build
An order processor. Raw input of unknown shape is validated into typed orders, invalid rows are reported with reasons, and valid orders flow through a generic pipeline that applies a discount, adds tax and produces a summary.
The idea
This lesson reuses the course:
unknown+ type guards turn untrusted data into trusted types.- A discriminated union (
Valid | Invalid) records the outcome of validation. - A generic
pipecomposes steps while keeping types aligned. - Utility and mapped types (
Readonly,Record) describe the output.
Design rules to take away:
- Validate at the boundary (where data enters), then pass trusted types inside.
- Make impossible states unrepresentable: a validated order always has a positive quantity.
- Keep functions small and typed; compose them.
The code
type OrderStatus = "pending" | "shipped";
interface Order {
readonly id: number;
readonly customer: string;
readonly quantity: number;
readonly unitPrice: number;
readonly status: OrderStatus;
}
type Validation =
| { kind: "valid"; order: Order }
| { kind: "invalid"; row: unknown; reason: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function validate(row: unknown): Validation {
if (!isRecord(row)) return { kind: "invalid", row, reason: "not an object" };
const { id, customer, quantity, unitPrice, status } = row;
if (typeof id !== "number") return { kind: "invalid", row, reason: "id must be a number" };
if (typeof customer !== "string" || customer === "") return { kind: "invalid", row, reason: "customer is required" };
if (typeof quantity !== "number" || !Number.isInteger(quantity) || quantity <= 0) {
return { kind: "invalid", row, reason: "quantity must be a positive integer" };
}
if (typeof unitPrice !== "number" || unitPrice < 0) return { kind: "invalid", row, reason: "unitPrice must be >= 0" };
if (status !== "pending" && status !== "shipped") return { kind: "invalid", row, reason: "unknown status" };
return { kind: "valid", order: { id, customer, quantity, unitPrice, status } };
}
// A tiny generic pipeline: each step takes and returns the same type
type Step<T> = (value: T) => T;
const pipe = <T>(...steps: Step<T>[]): Step<T> => (value) => steps.reduce((acc, step) => step(acc), value);
interface Priced {
order: Order;
subtotal: number;
total: number;
}
const price = (order: Order): Priced => {
const subtotal = order.quantity * order.unitPrice;
return { order, subtotal, total: subtotal };
};
const bulkDiscount: Step<Priced> = (p) => (p.order.quantity >= 10 ? { ...p, total: p.total * 0.9 } : p);
const addTax = (rate: number): Step<Priced> => (p) => ({ ...p, total: p.total * (1 + rate) });
const process = pipe(bulkDiscount, addTax(0.2));
const input: unknown[] = [
{ id: 1, customer: "Ada", quantity: 2, unitPrice: 15, status: "pending" },
{ id: 2, customer: "Grace", quantity: 12, unitPrice: 10, status: "shipped" },
{ id: 3, customer: "", quantity: 1, unitPrice: 5, status: "pending" },
{ id: 4, customer: "Linus", quantity: 0, unitPrice: 9, status: "pending" },
"garbage",
{ id: 5, customer: "Barbara", quantity: 3, unitPrice: 20, status: "pending" },
];
const results = input.map(validate);
const priced: Priced[] = [];
for (const r of results) {
if (r.kind === "invalid") {
console.log(`rejected: ${r.reason}`);
} else {
priced.push(process(price(r.order)));
}
}
for (const p of priced) {
console.log(`#${p.order.id} ${p.order.customer.padEnd(8)} total ${p.total.toFixed(2)}`);
}
const totalsByStatus: Record<OrderStatus, number> = { pending: 0, shipped: 0 };
for (const p of priced) totalsByStatus[p.order.status] += p.total;
console.log(totalsByStatus);
Output
rejected: customer is required
rejected: quantity must be a positive integer
rejected: not an object
#1 Ada total 36.00
#2 Grace total 129.60
#5 Barbara total 72.00
{ pending: 108, shipped: 129.6 }Extend it: add a cancelled status and watch the compiler point at every place that needs updating (totalsByStatus, the validator). Then try replacing the hand-written validator with a schema library, keeping the same Validation type.
Common mistakes
- Trusting data inside the system that was never validated at the edge. Validate once at the boundary.
- Optional fields everywhere. Prefer separate types (
DraftvsOrder) over?on every property. - Large
ifchains of casts. Small type guards (isRecord) keep validators readable. - Mutating inputs inside pipeline steps. Return new objects;
readonlyproperties help enforce it. - Ignoring rejected rows. Always report why data was refused so problems can be fixed at the source.
Key points
- Convert
unknowninput into typed values with guards at the system boundary. - Discriminated unions describe outcomes such as valid or invalid.
- Generic helpers like
pipekeep composed steps type-aligned. - Derived types and
Recordkeyed by a union make the compiler check every case.
Further reading: The Handbook: Type Manipulation.
Deliverables
Why validate input once at the boundary instead of inside every function?
Model answer
Validating where data enters turns untrusted unknown values into trusted types. Everything inside can then rely on those types, which keeps functions simple and avoids repeating checks.
What do `Record<OrderStatus, number>` and an exhaustive check give you when a new status is added?
Model answer
Both force you to handle the new case: the Record literal fails to compile until it has the new key, and an exhaustive switch with a never default fails until the new case is handled.
Open in the app to mark lessons complete, take notes and track your progress.