Skip to content
academia.sh

Lesson 06 / 14

Interfaces and Abstract Classes

The selection criterion for two abstraction tools: building two arrangements where shared behavior is split between an abstract class and an interface, showing by running code that single inheritance breaks when a carrier needs two abstractions at once, and counting the number of repeated lines.

Contents

The Tariff class in the previous lesson did two jobs: declaring which methods must exist and throwing an error when one was not implemented. These two jobs can be separated. One abstraction can declare only the contract; another can carry the contract together with the shared implementation. The choice between the two tools arises from this.

An interface is a contract that declares which methods a type offers and carries no implementation; this is the form introduced in the TypeScript course. An abstract class carries the shared implementation alongside the contract and hands it down to subclasses. The difference between the two is summed up in one line: a type can implement many interfaces, but can derive from only one class.

Sharing Shared Behavior With an Abstract Class

In the fee library, carriers hold two separate capabilities. The first is being chargeable: the carrier knows the unit fee per shipment, and the computation that caps the total at the minimum fee is shared.

// abstract/chargeable.mjs — abstract class: carries the shared total, leaves the unit fee to the subclass
export const MINIMUM = 5000;

export class ChargeableCarrier {
  unitFee(shipment) { throw new Error(`unitFee not implemented: ${shipment.code}`); }
  totalFee(shipments) {
    return Math.max(shipments.reduce((t, s) => t + this.unitFee(s), 0), MINIMUM);
  }
}

The second is being trackable: the carrier returns the state record for a tracking code, and the logic that picks the newest one among the records is shared as well.

// abstract/trackable.mjs — abstract class: carries the shared logic that picks the latest state
export class TrackableCarrier {
  stateRecord(code) { throw new Error(`stateRecord not implemented: ${code}`); }
  lastState(codes) {
    const records = codes.map((c) => this.stateRecord(c)).filter((r) => r !== null);
    return records.reduce((latest, r) => (r.time > latest.time ? r : latest), records[0]);
  }
}

The transit carrier needs both at once: it is both chargeable and trackable. The direct answer is to derive from both abstract classes at once.

// abstract/transit-invalid.mjs — attempt to derive from two abstract classes at once
import { ChargeableCarrier } from "./chargeable.mjs";
import { TrackableCarrier } from "./trackable.mjs";

export class TransitCarrier extends ChargeableCarrier, TrackableCarrier {
  unitFee(s) { return 6000 + Math.ceil(s.weightGrams / 1000) * 1200; }
  stateRecord(code) { return { code, state: "in transit", time: code.length }; }
}

The following command runs this file. The parts of the output that contain the file path, the stack trace, and the version line have been stripped out because they depend on the environment; the remaining three lines are the error itself.

node abstract/transit-invalid.mjs 2>&1 | sed -n '2,3p;5p'
export class TransitCarrier extends ChargeableCarrier, TrackableCarrier {
                                                     ^
SyntaxError: Unexpected token ','

The error comes out not at runtime but while the file is being compiled. Single inheritance is not a library restriction; it is a constraint written into the language’s grammar. The same constraint holds in every single-inheritance language, and it sets the upper bound for every design that shares its implementation through a class hierarchy: a type can inherit shared implementation from at most one source.

The Way Out and Its Cost

One way around the constraint is to derive from one abstract class and copy the other one’s shared code.

// abstract/transit.mjs — fix: derives from one abstract class, copies the other's shared code
import { ChargeableCarrier } from "./chargeable.mjs";

export class TransitCarrier extends ChargeableCarrier {
  unitFee(s) { return 6000 + Math.ceil(s.weightGrams / 1000) * 1200; }
  stateRecord(code) { return { code, state: "in transit", time: code.length }; }
  lastState(codes) {
    const records = codes.map((c) => this.stateRecord(c)).filter((r) => r !== null);
    return records.reduce((latest, r) => (r.time > latest.time ? r : latest), records[0]);
  }
}

export class PartialCarrier extends ChargeableCarrier {
  stateRecord(code) { return { code, state: "pending", time: 0 }; }
}

A second way is to chain the two abstract classes: derive TrackableCarrier from ChargeableCarrier. This gets around the constraint, but it makes every tracked carrier chargeable; a transit-point tracker that is not billed also inherits the fee methods. By the previous lesson’s criterion, this produces a subtype whose contract does not hold.

Sharing Shared Behavior With an Interface

In the second arrangement, the abstraction declares only the contract. The contract is a list of the required method names; conformance is checked structurally.

// interface/contract.mjs — interfaces declare only the required method names
export const CHARGEABLE = ["unitFee"];
export const TRACKABLE = ["stateRecord"];

export const missing = (object, ...contracts) =>
  contracts.flat().filter((name) => typeof object[name] !== "function");

Shared behavior is not shared through inheritance but through functions that take the carrier as an argument. These functions are not tied to any class; they work with any object that satisfies the contract.

// interface/shared.mjs — shared behavior is not inherited; it takes the carrier as an argument
export const MINIMUM = 5000;

export const totalFee = (carrier, shipments) =>
  Math.max(shipments.reduce((t, s) => t + carrier.unitFee(s), 0), MINIMUM);

export function lastState(carrier, codes) {
  const records = codes.map((c) => carrier.stateRecord(c)).filter((r) => r !== null);
  return records.reduce((latest, r) => (r.time > latest.time ? r : latest), records[0]);
}

The transit carrier satisfies both contracts and copies no shared code.

// interface/transit.mjs — implements both interfaces at once; no shared code is copied
export class TransitCarrier {
  unitFee(s) { return 6000 + Math.ceil(s.weightGrams / 1000) * 1200; }
  stateRecord(code) { return { code, state: "in transit", time: code.length }; }
}

export class PartialCarrier {
  stateRecord(code) { return { code, state: "pending", time: 0 }; }
}

Repeated Lines

The measurement counts how many lines in the concrete carrier’s file appear verbatim in the shared-behavior file; comments, import lines, and very short lines do not enter the count.

// repetition.mjs — counts repeated shared-behavior lines across the two arrangements
import { readFileSync } from "node:fs";

const lines = (path) => readFileSync(path, "utf8").split("\n")
  .map((s) => s.trim())
  .filter((s) => s.length > 3 && !s.startsWith("//") && !s.startsWith("import"));

const repeated = (source, shared) => {
  const set = new Set(lines(shared));
  return lines(source).filter((s) => set.has(s)).length;
};

console.log(`abstract class arrangement  repeated lines = ${repeated("abstract/transit.mjs", "abstract/trackable.mjs")}`);
console.log(`interface arrangement       repeated lines = ${repeated("interface/transit.mjs", "interface/shared.mjs")}`);
node repetition.mjs
abstract class arrangement  repeated lines = 3
interface arrangement       repeated lines = 0

Three lines look like a small number, but it is not fixed. Every carrier that needs both capabilities copies the same three lines; in a fleet of ten carriers that becomes thirty lines, and when the rule for picking the last state changes, ten files get touched. In the interface arrangement, the same change is in a single file, because the shared code was never copied.

Two Arrangements Give the Same Result

For the comparison to be meaningful, the two arrangements have to do the same job. The same run also measures when the missing implementation is spotted: in both fleets, the third carrier does not implement the unitFee method.

// run.mjs — shows the two arrangements give the same result and when the missing implementation surfaces
import { TransitCarrier as AbstractFull, PartialCarrier as AbstractPartial } from "./abstract/transit.mjs";
import { TransitCarrier as InterfaceFull, PartialCarrier as InterfacePartial } from "./interface/transit.mjs";
import { totalFee, lastState } from "./interface/shared.mjs";
import { missing, CHARGEABLE, TRACKABLE } from "./interface/contract.mjs";

const SHIPMENTS = [{ code: "TR1", weightGrams: 4000 }, { code: "TR2", weightGrams: 900 }];
const CODES = ["TR1", "TR2200", "TR30"];

const a = new AbstractFull();
const i = new InterfaceFull();
console.log(`abstract  total = ${a.totalFee(SHIPMENTS)}  last state = ${a.lastState(CODES).code}`);
console.log(`interface total = ${totalFee(i, SHIPMENTS)}  last state = ${lastState(i, CODES).code}`);

const abstractFleet = [new AbstractFull(), new AbstractFull(), new AbstractPartial()];
let processed = 0;
let error = "";
try {
  for (const t of abstractFleet) { t.totalFee(SHIPMENTS); processed += 1; }
} catch (e) { error = e.message; }
console.log(`abstract  processed before error = ${processed}/3  error: ${error}`);

const interfaceFleet = [new InterfaceFull(), new InterfaceFull(), new InterfacePartial()];
const missingList = interfaceFleet.map((t) => missing(t, CHARGEABLE, TRACKABLE));
const eligible = interfaceFleet.filter((_, idx) => missingList[idx].length === 0);
for (const t of eligible) totalFee(t, SHIPMENTS);
console.log(`interface rejected before use = ${missingList.filter((e) => e.length > 0).length}/3` +
  `  missing method: ${missingList.flat().join(",")}`);
console.log(`interface runtime errors = 0  processed = ${eligible.length}/3`);
node run.mjs
abstract  total = 18000  last state = TR2200
interface total = 18000  last state = TR2200
abstract  processed before error = 2/3  error: unitFee not implemented: TR1
interface rejected before use = 1/3  missing method: unitFee
interface runtime errors = 0  processed = 2/3

The two arrangements gave the same total and the same last state. They diverged on the missing implementation: in the abstract class arrangement, the error came out only once the third carrier started being processed, and at that point two carriers had already been processed. In the interface arrangement, the gap was reported by name before any work was done.

This difference does not come from the tool itself but from where the check happens. The abstract class’s throwing method waits for a call; the contract list requires no call. In an environment that uses static typing, the same check moves down to compile time and never appears in the run at all — the structural typing built in the TypeScript course does exactly this.

Selection Criterion

An abstract class is preferred when the shared implementation is genuinely shared and it is certain that a type belongs to a single family; inheritance carries both the contract and the code in one step. An interface is preferred when a type is expected to carry more than one capability; the measured difference of 3 lines against 0, multiplied by the number of carriers, is the cost of this choice.

The two tools do not exclude each other. A common arrangement is to declare the contract with an interface and gather the shared implementation in standalone functions or a single helper type; that way sharing does not consume the inheritance line. This arrangement is called composition, and the choice between it and inheritance is the subject of the next lesson.

Summary

  • An interface declares only the contract; an abstract class carries the shared implementation together with the contract. A type can implement many interfaces but can derive from only one class.
  • The attempt to derive from two abstract classes at once stopped with a SyntaxError before it even reached runtime; single inheritance is a constraint written into the language’s grammar.
  • The way out of the constraint is copying shared code: 3 lines were repeated in the abstract class arrangement, 0 in the interface arrangement.
  • The repetition is not fixed; every carrier that needs both capabilities duplicates the same three lines, and when the rule changes, the number of files touched equals the number of carriers.
  • The two arrangements produced the same total and the same last state; where they diverged was the moment the missing implementation was noticed: in the abstract class after two carriers had been processed, in the interface before any work was done.
  • Chaining the two abstract classes gets around the constraint but produces a subtype whose contract does not hold, by making every tracked carrier chargeable.

Next Step

In this lesson, shared behavior was shared two ways: through inheritance and through functions that take the carrier as an argument. The second could carry both capabilities together because it did not consume the inheritance line. The same choice has one more consequence, and it appears when the superclass changes something within itself. The next lesson measures this: a run shows a subclass’s behavior silently changing when a superclass method’s implementation changes, then the same change is tried on a composed version.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close