LearnLLDOOPAbstraction

Abstraction

Your order service sends an SMS when an order is confirmed. So it knows the vendor’s API key, the sender id, the DLT template id, and the retry rule. Then the company switches SMS vendors to save 3 paise a message, and the pull request touches checkout, refunds, login and the delivery tracker. Four teams review a change that was supposed to be about SMS.

Checkout never needed to know any of that. It needed one thing: tell this customer their order is confirmed.

Your answer

placeOrder() needs to notify the customer. What is the smallest thing placeOrder has to know about notifications for that to work? Write the method signature you would want.

The idea

The picture to keep
A switch on the wall

You press it and the light comes on. Today the power is from the grid. During a power cut it is from the inverter, and next year it might be solar on the roof. Your finger does the same thing every time. The wiring changed three times and the switch did not.

In one line: Show the caller what it can do. Hide how it gets done.

An abstraction is a small, stable description of what something does, with the how left out. In TypeScript the tool for it is the interface.

Before: the caller knows everything

function placeOrder(phone: string, orderId: number): string {
  // ...the order is saved, then:
  const apiKey = 'key_live_8f2a'
  const senderId = 'INDGEK'
  const templateId = '1107160000012345'
  const auth = `key=${apiKey} from=${senderId} template=${templateId}`
  const body = `Order ${orderId} confirmed`
 
  return `POST sms.example/send ${auth} to=${phone} text=${body}`
}
 
console.log(placeOrder('9810012345', 42).length > 100) // true

Four of the six lines in that function have nothing to do with placing an order. And they are copied into every other function that sends a message.

After: the caller knows one method

interface Notifier {
  send(to: string, message: string): string
}
 
class SmsNotifier implements Notifier {
  private readonly senderId = 'INDGEK'
 
  send(to: string, message: string): string {
    return `SMS from ${this.senderId} to ${to}: ${message}`
  }
}
 
class WhatsAppNotifier implements Notifier {
  send(to: string, message: string): string {
    return `WhatsApp to ${to}: ${message}`
  }
}
 
function placeOrder(
  notifier: Notifier,
  phone: string,
  orderId: number,
): string {
  // ...the order is saved, then:
  return notifier.send(phone, `Order ${orderId} confirmed`)
}
 
console.log(placeOrder(new SmsNotifier(), '9810012345', 42))
// > SMS from INDGEK to 9810012345: Order 42 confirmed
console.log(placeOrder(new WhatsAppNotifier(), '9810012345', 42))
// > WhatsApp to 9810012345: Order 42 confirmed

placeOrder now takes a Notifier. That type has one method on it, so one method is all placeOrder can see, even though SmsNotifier has a sender id and could have twenty more details. implements Notifier asks the compiler to confirm the class really has everything the interface lists.

Switching vendors is now a new class and one line where it is created. Checkout is not in the pull request.

Figure 1. placeOrder points at the interface and nothing else. The three classes on the right can change, or be replaced, without it noticing.

TypeScript checks the shape, not the name

This is where TypeScript differs from Java and C#, and it is worth ten minutes of your attention.

interface Notifier {
  send(to: string, message: string): string
}
 
class PushNotifier {
  send(to: string, message: string): string {
    return `Push to ${to}: ${message}`
  }
}
 
const notifier: Notifier = new PushNotifier()
console.log(notifier.send('asha', 'Your rider is here'))
// > Push to asha: Your rider is here
 
const fake: Notifier = { send: (to, message) => `(test) ${to}: ${message}` }
console.log(fake.send('asha', 'hello')) // (test) asha: hello

PushNotifier never says implements Notifier, and it is accepted anyway. So is a plain object with a send function. TypeScript asks one question: does this value have the methods the interface lists, with compatible types? This is called structural typing.

So why write implements at all? Because it moves the error. Without it, a typo in PushNotifier shows up far away, at the line where you try to use it as a Notifier. With it, the compiler complains inside the class, where the mistake is.

The plain object version is how you write a test double in three lines. That comes back in Dependency inversion.

When the implementations share code: abstract class

An interface has no code in it. If every notifier needs the same prefix and only the delivery differs, you would be pasting the prefix into each class. An abstract class is a half finished class: some methods are written, some are left as blanks to fill in.

abstract class Notifier {
  // Written once, shared by every notifier.
  send(to: string, message: string): string {
    return this.deliver(to, `[IndGeek] ${message}`)
  }
 
  // A blank. Each subclass must fill it in.
  protected abstract deliver(to: string, text: string): string
}
 
class SmsNotifier extends Notifier {
  protected override deliver(to: string, text: string): string {
    return `SMS to ${to}: ${text}`
  }
}
 
const notifier = new SmsNotifier()
console.log(notifier.send('9810012345', 'Order 42 confirmed'))
// > SMS to 9810012345: [IndGeek] Order 42 confirmed
 
const blank = new Notifier()
// Error: Cannot create an instance of an abstract class.

You cannot new an abstract class, because it has holes in it. protected means subclasses can see deliver and outside callers cannot. extends and override get a full page next, in Inheritance.

ChoiceWhat you gainWhat you payPick it when
interfaceA pure contract with no code. A class can implement several, and any object with the right shape fits, including a test fake.Shared logic has to live somewhere else, and it is erased at runtime so you cannot check instanceof against it.The default. Start here and move only when you have a reason.
abstract classA contract plus shared code and shared fields, written once in the parent.A class can extend only one, and every subclass is now tied to the parent and its constructor.Several implementations repeat the same steps and only one or two steps differ.

Two ways to get it wrong

The leaky abstraction

send(to, message, dltTemplateId) looks harmless. But a DLT template id only means something to an SMS vendor, so the WhatsApp class has to accept a parameter it ignores, and every caller has to supply one. The detail you meant to hide is back in the signature. If a parameter makes sense for only one implementation, it belongs inside that implementation.

The interface with one implementation

An interface is a cost: one more file, one more hop when you read the code. If there is one implementation and no second one in sight, and you do not need a fake for tests, you have not hidden anything. You have renamed it. Wait for the second case. Extracting an interface from a working class takes five minutes.

Interface or abstract class?

Give the rule, then the reason. “Interface by default, because a class can implement many and it keeps callers free of any implementation. Abstract class when the implementations share real code.” If you are answering in TypeScript, add that interfaces are structural and vanish at runtime. Most candidates do not know that, and it shows you have used the language.

TypeScript you just picked up
interface Notifier { send(...): string }
A contract: the methods a value must have. No code inside.
class SmsNotifier implements Notifier
Asks the compiler to verify the class satisfies the interface.
notifier: Notifier
A parameter typed by the interface accepts any implementation.
abstract class Notifier
A class that cannot be created directly. It exists to be extended.
abstract deliver(...): string
A method with no body. Every concrete subclass must write one.
protected
Visible to this class and its subclasses, hidden from everyone else.
private readonly senderId
Two modifiers together: hidden from outside, and never reassigned.

Checkpoint

Checkpoint

1. A class has a send(to, message) method with the right types but does not say implements Notifier. Can you pass it where a Notifier is expected?

2. Three notifier classes each start send() with the same five lines of formatting. What is the cleanest fix?

3. Notifier.send gains a third parameter, dltTemplateId, used only by the SMS class. What went wrong?

Say this in 60 seconds

Abstraction means the caller sees what something does and not how. In TypeScript I express it as an interface, so placeOrder depends on a Notifier with one send method, and whether that is SMS or WhatsApp can change without touching it. I use an interface by default and an abstract class only when implementations share real code, because a class can extend one parent but implement many interfaces. TypeScript checks interfaces structurally, by shape, so any object with the right methods fits, which makes test fakes trivial. The two mistakes are leaking an implementation detail into the contract, and adding an interface when there is only ever going to be one implementation.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.