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.

Create a free account to save this page, take notes and track your progress. Sign in

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 pipe composes steps while keeping types aligned.
  • Utility and mapped types (Readonly, Record) describe the output.

Design rules to take away:

  1. Validate at the boundary (where data enters), then pass trusted types inside.
  2. Make impossible states unrepresentable: a validated order always has a positive quantity.
  3. Keep functions small and typed; compose them.

The code

Validate unknown input, process orders, summarise by statusTry it yourself
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 (Draft vs Order) over ? on every property.
  • Large if chains of casts. Small type guards (isRecord) keep validators readable.
  • Mutating inputs inside pipeline steps. Return new objects; readonly properties help enforce it.
  • Ignoring rejected rows. Always report why data was refused so problems can be fixed at the source.

Key points

  • Convert unknown input into typed values with guards at the system boundary.
  • Discriminated unions describe outcomes such as valid or invalid.
  • Generic helpers like pipe keep composed steps type-aligned.
  • Derived types and Record keyed 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.

Was this page helpful?