Problem overview
A shop takes payments by card, by PayPal and in cash. Every payment has an amount in cents and a total, which is the amount plus a processing fee. The total is worked out the same way for every payment. Only the fee differs by kind:
Card: 2% of the amount, rounded down;PayPal: 3% of the amount, rounded down, plus 30 cents;Cash: no fee.
You write the abstract class Payment, which keeps the amount, declares an abstract fee() and has a concrete total(), and the three subclasses that each write their own fee(). For a card payment of 1999 cents, the fee is 39 (the exact value is 39.98), and the total is 2038.
Approach: share the total, leave the fee open
The rule "total equals amount plus fee" is the same for all three payment kinds, so it is written once, in Payment. What a fee is depends on the kind, and Payment can't know that. An abstract method solves this: abstract fee(): number; says that every payment has a fee and leaves the body out. Each subclass must then provide it.
When total() calls this.fee(), the version of the object's own class runs: a card's fee for a card, a PayPal fee for a PayPal payment. The total is written once and works for every kind, including kinds added later.
An abstract class can't be created directly. Nobody can write new Payment(500); only new Card(500) and the other subclasses work. Each fee and each total takes O(1) time.
Step by step
- The field. Declare
amount: numberinPayment. It lives in the parent once, and all three subclasses inherit it. - The constructor. Store the parameter in
this.amount. The subclasses don't write a constructor, so they inherit this one:new Card(1999)runsPayment's constructor. - The abstract method. Write
abstract fee(): number;with a semicolon in place of a body. - The total.
total()returnsthis.amount + this.fee(). - The card. The fee is
Math.floor(this.amount * 2 / 100). Multiply first, then divide, then round down once. - PayPal. The same shape with 3, plus 30 outside the rounding. The 30 cents are whole already and come on top, even for an amount of 0.
- Cash.
fee()returns0. Cash must still write the method, because it is abstract inPayment.
The solution in TypeScript
abstract class Payment {
amount: number;
constructor(amount: number) {
this.amount = amount;
}
abstract fee(): number;
total(): number {
return this.amount + this.fee();
}
}
class Card extends Payment {
fee(): number {
return Math.floor(this.amount * 2 / 100);
}
}
class PayPal extends Payment {
fee(): number {
return Math.floor(this.amount * 3 / 100) + 30;
}
}
class Cash extends Payment {
fee(): number {
return 0;
}
}
TypeScript details worth knowing:
abstractis a compile-time rule. The compiler refusesnew Payment(5)withCannot create an instance of an abstract class, and refuses a subclass that leavesfeeout withNon-abstract class 'Cash' does not implement inherited abstract member fee from class 'Payment'.- Errors don't stop the JavaScript. The compiler still writes the JavaScript, and a
Cashclass withoutfeecompiles to a class with no such method. Callingtotal()on it fails at run time withthis.fee is not a function. - Division keeps the fraction.
1999 * 2 / 100is39.98, andMath.floorturns it into39. There is no integer division operator, so you round explicitly. - Inherited constructor. A subclass without a constructor gets its parent's, with the same parameters.
Seeing it run
function show(ps: Payment[]) {
console.log(ps.map(p => p.total()).join(" "));
console.log(ps.reduce((s, p) => s + p.fee(), 0));
}
show([new Card(1999), new PayPal(500), new Cash(700)]);
show([new Card(49), new PayPal(33), new Card(50)]);
show([new Card(0), new PayPal(0), new Cash(0)]);
2038 545 700
84
49 63 51
31
0 30 0
30
The first line of each pair lists the totals and the second the sum of the fees. In the second group, 49 cents on a card is a fee of 0 (0.98 rounded down), and 33 cents on PayPal is 0 plus 30. A card payment of 50 cents pays exactly 1. Zero cents on PayPal still costs 30.
Adding a new kind of payment
Because total() only calls this.fee(), a new payment kind needs one small class. A bank transfer with a flat fee of 25 cents would look like this, in a file of your own:
class Bank extends Payment {
fee(): number {
return 25;
}
}
const list: Payment[] = [new Bank(1000), new Bank(0)];
console.log(list.map(p => p.total()).join(" "));
1025 25
Nothing in Payment changes. The array is typed Payment[], which is the abstract type, and each element answers total() through its own fee(). This is the way the lesson's tests use your classes too: through the abstract type, never through Card, PayPal or Cash directly.
Edge cases and common mistakes
- Dividing first.
Math.floor(150 / 100) * 2is2, butMath.floor(150 * 2 / 100)is3. The rounding must come after the multiplication. - Skipping
Math.floor. The fee of a 1999-cent card payment would be39.98and the total2038.98, which is not a whole number of cents. - Rounding the 30 cents too. Write
Math.floor(this.amount * 3 / 100) + 30. The 30 sits outside the call. - Leaving
feeout ofCash. It is abstract, so every concrete class has to write it, even if it only returns0. - Writing
total()in every subclass. That repeats the rule three times. Keep it inPayment; subclasses only supplyfee. - Using the percentage as a decimal. Writing
this.amount * 0.02is another way to say 2%, but the lesson's form is simpler to reason about: the productthis.amount * 2is a whole number, and the only division and the only rounding come at the end. - Amount 0. It is valid. Card and cash give a total of 0, and PayPal gives 30.
