I'll Be Backend
Principles · TypeScript

Fail fast: validate at the boundary

Reject bad input at the boundary, before any code changes state.

11 steps4 chapters~5 min at 1.25×#fail fast#validation#guard clauses#constructors
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the TypeScript video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–7

Payoff

Steps 8–9

Recap

Steps 10–11

The code: before and after

Before

class InvalidOrder extends Error {}
class SoldOut extends Error {}

class TicketShop {
  seatsLeft = 100;
  receipts: string[] = [];

  buy(quantityText: string, email: string): string {
    const quantity = this.reserve(quantityText);
    return this.issueReceipt(quantity, email);
  }

  reserve(quantityText: string): number {
    const quantity = Number(quantityText);
    if (!Number.isInteger(quantity) || quantity < 1)
      throw new InvalidOrder("quantity");
    if (quantity > this.seatsLeft) throw new SoldOut();
    this.seatsLeft -= quantity;
    return quantity;
  }

  issueReceipt(quantity: number, email: string): string {
    this.receipts.push(`${email}: ${quantity} tickets`);
    return "200 ok";
  }
}

class BuyHandler {
  shop = new TicketShop();

  handle(form: Record<string, string>): string {
    try {
      return this.shop.buy(form.quantity, form.email);
    } catch (e) {
      if (e instanceof InvalidOrder) return "400 bad request";
      if (e instanceof SoldOut) return "409 sold out";
      throw e;
    }
  }
}

After

class InvalidOrder extends Error {}
class SoldOut extends Error {}

class TicketOrder {
  constructor(readonly quantity: number, readonly email: string) {
    if (!Number.isInteger(quantity) || quantity < 1)
      throw new InvalidOrder("quantity");
    if (!email.includes("@"))
      throw new InvalidOrder("email");
  }
}

class TicketShop {
  seatsLeft = 100;
  receipts: string[] = [];

  buy(order: TicketOrder): string {
    this.reserve(order.quantity);
    return this.issueReceipt(order.quantity, order.email);
  }

  reserve(quantity: number): void {
    if (quantity > this.seatsLeft) throw new SoldOut();
    this.seatsLeft -= quantity;
  }

  issueReceipt(quantity: number, email: string): string {
    this.receipts.push(`${email}: ${quantity} tickets`);
    return "200 ok";
  }
}

class BuyHandler {
  shop = new TicketShop();

  handle(form: Record<string, string>): string {
    try {
      const quantity = Number(form.quantity);
      const order = new TicketOrder(quantity, form.email);
      return this.shop.buy(order);
    } catch (e) {
      if (e instanceof InvalidOrder) return "400 bad request";
      if (e instanceof SoldOut) return "409 sold out";
      throw e;
    }
  }
}

Transcript, step by step

Problem

Step 1: A ticket shop for one concert Before

This is a ticket shop for one concert with a hundred seats, and Buy Handler is its only caller. The handler gets the web form as a record of strings and hands the quantity and the email to buy, which reserves seats and then issues a receipt. Reserve parses the quantity, checks it, and takes the seats. The catch turns each error class into the HTTP-style status text the handler returns. Number of abc doesn't throw, it gives Not a Number, so the integer check catches it and abc also gets 400 bad request.

Step 2: Reject bad emails Code smell

Support wants receipts sent only to real addresses, so an email without an at sign has to be rejected. The email is first used in issue receipt, so that's where the check goes, right at the top. The compiler is happy, and a bad email now gets 400 bad request.

Step 3: Checked halfway through Code smell

Each input is checked where it's first used, deep inside, so a bad request is found halfway through the work, after earlier lines have already changed state. The quantity is parsed here, checked here, the seats are taken here, and only then is the email checked, here. Send quantity four with the email ann dot example dot com, and you get 400 bad request, but seats left has already dropped by four. No receipt exists for that order, so four seats are gone that nobody bought. And every new rule means hunting for the method that first touches its field.

Refactor

Step 4: Back it out, refactor first Plan

Let's back the email check out. The boundary is where outside data enters the code, and here that's the handler. We'll refactor first, so every input check runs at the boundary before anything changes, and then add the email rule.

Step 5: Parse in the handler Move Statements to Callers

First, the parse moves out of reserve and up into the handler's try block. Buy and reserve now take a number, and reserve no longer hands it back. Abc still turns into Not a Number, and reserve's integer check still gives it 400 bad request.

Step 6: Name the order Introduce Parameter Object

Next, the quantity and the email always travel together, so we give them one name, a Ticket Order class. Its constructor declares both fields as read-only parameter properties, so the class stays one line long. The handler builds the order inside its try, and buy takes it as one parameter. Reserve still takes a number, so it didn't change.

Step 7: Check in the constructor Move Statements into Function

Now the quantity check moves out of reserve and into Ticket Order's constructor. The constructor throws, so new never hands the handler an order for zero tickets or for Not a Number. The sold-out guard stays in reserve, because it depends on seats left, and only the shop knows that.

Payoff

Step 8: Add the email rule again Payoff

Now the email rule goes back in, as one check in Ticket Order's constructor. Reserve and issue receipt didn't change at all. A bad email still gets 400 bad request, because the constructor throws an Invalid Order inside the handler's try.

Step 9: Why it was easy Payoff

The parse and both input checks now sit at the boundary, in the handler and in the constructor. Seats are taken only after a whole valid order exists, and issue receipt can trust its email. So the form with ann dot example dot com gets 400 bad request, and seats left is still a hundred. Every Ticket Order is valid from the moment it exists, and its fields are read-only, so the inner code never checks input again, and the next input rule is one check in one place. This is fail fast at the boundary, and it fits here because the handler is the one place form data comes in. Since reserve changes seats left, a whole valid order has to exist before it runs, and then a bad form can never change state.

Recap

Step 10: Where we started, where we ended Before / after

On the left, raw strings went deep into the shop, and reserve parsed and checked them. The email check ran only after the seats were taken. On the right, Ticket Order holds both input checks, and buy only accepts an order that passed them. Reserve keeps only the sold-out guard, right before it changes seats left.

Step 11: When to reach for it Takeaways

Reach for it when data from outside, like a form, a file or an API call, flows into code that changes state. Skip it for a rule that depends on current state, like sold out, because only the shop can check that, so keep it as a guard clause right before the change. You'll spot it by raw strings passed deep into the code, input checks spread over inner methods, or a check that runs after a line changed state. The URL constructor works this way, because it throws a Type Error the moment the text isn't a URL, so a broken address never travels on.