I'll Be Backend
Principles · TypeScript

Programming to an interface: widen concrete parameter types

Let one receipt method send through any channel that can deliver it.

10 steps4 chapters~4 min at 1.25×#oop#interfaces#coupling#parameters
Language
TypeScript+11 soon
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–6

Payoff

Steps 7–8

Recap

Steps 9–10

The code: before and after

Before

type Rental = { days: number; price: number };
const outbox: string[] = [];
class SmsGateway {
  private credits = 100;
  constructor(private readonly phone: string) {}
  send(text: string): void {
    this.credits--;
    outbox.push(`SMS ${this.phone}: ${text}`);
  }
  topUp(amount: number): void {
    this.credits += amount;
  }
}
class Receipts {
  static send(rental: Rental, sms: SmsGateway): void {
    const text =
      `${rental.days} days, ${rental.price} dollars`;
    sms.send(text);
  }
}
function desk(): string {
  const trip: Rental = { days: 3, price: 215 };
  Receipts.send(trip, new SmsGateway("555-0101"));
  return outbox.join("\n");
}

After

type Rental = { days: number; price: number };
const outbox: string[] = [];
interface MessageSender {
  send(text: string): void;
}
class SmsGateway implements MessageSender {
  private credits = 100;
  constructor(private readonly phone: string) {}
  send(text: string): void {
    this.credits--;
    outbox.push(`SMS ${this.phone}: ${text}`);
  }
  topUp(amount: number): void {
    this.credits += amount;
  }
}
class EmailGateway implements MessageSender {
  constructor(private readonly address: string) {}
  send(text: string): void {
    outbox.push(`Mail ${this.address}: ${text}`);
  }
}
class Receipts {
  static send(rental: Rental, sender: MessageSender): void {
    const text =
      `${rental.days} days, ${rental.price} dollars`;
    sender.send(text);
  }
}
function desk(): string {
  const trip: Rental = { days: 3, price: 215 };
  Receipts.send(trip, new SmsGateway("555-0101"));
  Receipts.send(trip, new EmailGateway("fleet@acme.com"));
  return outbox.join("\n");
}

Transcript, step by step

Problem

Step 1: Receipts by text Before

The car rental desk from the last lesson builds its prices from parts, and now it sends each customer a receipt. The SMS Gateway texts one phone and counts credits, and it also has a top up method that receipts never call. Each text lands in the outbox array, which stands in for the phone network. Receipts builds the receipt line from the rental and hands it to the gateway. The desk function sends the receipt for a three day trip, and the outbox it returns holds three days, two hundred fifteen dollars. Text was the only channel, so naming the class it used was the shortest path.

Step 2: Receipts by email Code smell

Business customers now want their receipts by email. The quick way adds an Email Gateway that writes to an address, and a second method that takes it. A TypeScript class allows one body per method name, so that method needs a new name, send email. The desk sends the trip's receipt both ways, and the outbox now holds a mail line under the text line.

Step 3: One method per class Code smell

The receipt method's parameter type is a concrete class, so it accepts that one class only, though it calls just one of its methods, send. TypeScript compares classes by shape, and an Email Gateway has no top up and no private phone, so it doesn't fit that parameter. You can see it in the text parameter here, the email parameter here, and the copied receipt line here. The receipt wording now lives in two methods, so changing dollars to USD takes two edits that can drift apart. And a push channel next would be a third copy of the method.

Refactor

Step 4: Refactor first Back it out

So let's take the email change out again. We'll refactor first, so receipts ask for something any channel can fill, and then email will be one new class.

Step 5: Name what Receipts uses Extract Interface

Receipts calls one method on the gateway, send, and nothing else. So we extract an interface, Message Sender, with that one method, and the SMS Gateway implements it. Top up stays out of it, because receipts never top up, so the interface holds only what Receipts calls. The gateway's send already matches the interface, so its body doesn't change.

Step 6: Widen the parameter Change Signature

Programming to an interface means a method asks for the interface it uses, not one class that has it. So we widen the parameter, which means we change it to a type that more classes fit, and here that type is Message Sender. The body now calls send on the sender, the one method the interface declares. The desk function doesn't change and still passes an SMS Gateway, because an SMS Gateway is a Message Sender.

Payoff

Step 7: Email, as one class Add email

Now email comes back, as an Email Gateway that implements Message Sender. The desk function adds one line, and the outbox gets the mail line under the text line, as before. Receipts, Message Sender and the SMS Gateway didn't change, and there's no send email method.

Step 8: One method, any sender Programming to an interface

The two concrete parameters are now one Message Sender parameter, and the receipt line exists once, here. A push channel would be one more class that implements Message Sender. The Gang of Four put it as program to an interface, not an implementation. It fits here because Receipts calls only send, so when it asks for an interface with just that method, any class that can send fits the parameter.

Recap

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

On the left, Receipts asked for an SMS Gateway, though it only ever called send. On the right, it asks for a Message Sender, the interface with that one method. Email Gateway implements it, so email took one new class and no copied method.

Step 10: When to reach for it Takeaways

Reach for it when a method uses a small part of a class, and a second class could do that part. Skip it when only one type will ever fill the parameter, like Rental here, because an interface for a plain data type adds a file and nothing else. You'll spot it by a parameter typed with a concrete class when the method calls only one of its methods, and by copied methods that differ only in that type. Array from takes any Iterable, so a Set, a Map or your own generator fits.