Modeling algorithms with types
Things get so much easier when you try to understand how the types work together to accomplish some work.
In this article, we are going to model a system that apply discounts using many strategies.
I'm going to use Haskell and javascript. haskell (or ocaml if you like) is great to visualize what's going on the type level...
We can starting by trying to think on the simplest way to accomplish the job.
We can start by using a simple subtraction (-)
.
applyDiscount :: Float -> Float -> Float
applyDiscount price discount = price - discount
let price = 10.0
discounts = [1.0, 4.0]
finalPrice = 5.0
in foldl applyDiscount price discounts == finalPrice
const applyDiscount = (price, discount) => price - discount;
const price = 10.0;
const discounts = [1.0, 4.0];
const finalPrice = 5.0;
discounts.reduce(applyDiscount, price) == finalPrice;
Then we can clarify what which Float
means
on the type specifiction...
type Price = Float
type Discount = Float
applyDiscount :: Price -> Discount -> Price
applyDiscount price discount = price - discount
Now we have Price -> Discount -> Price
which means
"given a Price and a Discount, it returns back the price
after the discount"...and that means, in this case,
that the Price acts like an accumulator!
With this information, we can now improve applyDiscount
to be more generic.
class ApplyDiscount a where
applyDiscount :: a -> Discount -> a
applyDiscounts :: ApplyDiscount a => a -> [Discount] -> a
applyDiscounts = foldl applyDiscount
In this case, in typescript, it should be the same as...
interface ApplyDiscount {
applyDiscount(discount: Discount): ApplyDiscount;
}
Now, we just need to create instances for each a
!
newtype ApplyAll = ApplyAll Price
deriving (Show, Eq)
instance ApplyDiscount a where
applyDiscount (ApplyAll p) d = ApplyAll (p - d)
let discounts = [1.0, 4.0]
strategy = ApplyAll 10.0
finalPrice = ApplyAll 5.0
in foldl applyDiscount strategy discounts == finalPrice
class ApplyAll {
constructor(x) { this.price = x; }
applyDiscount(d) { return new ApplyAll(this.price - d); }
}
const discounts = [1.0, 4.0];
const strategy = new ApplyAll(10.0);
const finalPrice = 5.0;
const applyDiscount =
(strategy, discount) => strategy.applyDiscount(discount);
discounts.reduce(applyDiscount, strategy).price == finalPrice;
Now we have our first strategy! ApplyAll
just apply all discounts available,
and this is a great first strategy.
A new requirement has arived!
Now, we have too apply all discounts, but we are not going to let the final price goes negative.
newtype ApplyAllNoNegative = ApplyAllNoNegative Price
deriving (Show, Eq)
instance ApplyDiscount a where
applyDiscount (ApplyAllNoNegative p) d =
ApplyAllNoNegative (max 0 (p - d))
let discounts = [5.0, 5.0]
strategy = ApplyAllNoNegative 8.0
finalPrice = ApplyAllNoNegative 0.0
in foldl applyDiscount strategy discounts == finalPrice
class ApplyAllNoNegative {
constructor(x) { this.price = x; }
applyDiscount(d) {
const price = Math.max(0, this.price - d);
return new ApplyAllNoNegative(price);
}
}
const discounts = [1.0, 4.0];
const strategy = new ApplyAllNoNegative(10.0);
const finalPrice = 5.0;
discounts.reduce(applyDiscount, strategy).price == finalPrice;
Too easy!
Our next task is to:
"If it goes negative, just collect all discounts that we couldn't apply, otherwise return the final price."
newtype CollectDiscountWhenGoesNegative =
Applying Price |
NotApplied [Discount]
deriving (Show, Eq)
instance ApplyDiscount a where
applyDiscount (Applying p) d =
let x = p - d
in if x >= 0 then
Applying x
else
NotApplied [d]
applyDiscount (NotApplied ls) c =
NotApplied (c : ls)
let discounts = [5.0, 5.0]
strategy = Applying 10.0
finalPrice = Applying 0.0
in foldl applyDiscount strategy discounts == finalPrice
let discounts = [5.0, 8.0]
strategy = Applying 10.0
finalPrice = NotApplied [8.0]
in foldl applyDiscount strategy discounts == finalPrice
// Don't do this! It's just to help visualize what's going on.
class CollectDiscountWhenGoesNegative {}
class NotApplied extends CollectDiscountWhenGoesNegative {
constructor(x) { this.list = x; }
applyDiscount(d) { retun new this(this.list.concat([d])); }
}
class Applying extends CollectDiscountWhenGoesNegative {
constructor(x) { this.price = x; }
applyDiscount(d) {
const price = this.price - d;
if (price >= 0) {
return new Applying(price);
} else {
return new NotApplied([d]);
}
}
}
const discounts = [1.0, 4.0];
const strategy = new Applying(10.0);
const finalPrice = 5.0;
discounts.reduce(applyDiscount, strategy).price == finalPrice;
const discounts = [1.0, 4.0];
const strategy = new Applying(1.0);
discounts.reduce(applyDiscount, strategy).list == [4.0];
It's really easy to work with this. If the discount goes negative, we know exactly which discounts were not applied to mark on the frontend as feedback.
Our final task is:
"It must not apply duplicate discounts, and it must keep the duplicated to send as feedback to the user. Also, it must use the previous strategies so we can allow it goes negative or not."
type AlreadyAppliedDiscounts = S.Set Discount
type DuplicatedDiscounts = [Discount]
data DontApplyDuplicate a =
DontApplyDuplicate AlreadyAppliedDiscounts DuplicatedDiscounts a
deriving (Show, Eq)
dadup = DontApplyDuplicate
instance (ApplyDiscount a) => ApplyDiscount (DontApplyDuplicate a) where
applyDiscount (DontApplyDuplicate ls xs s) d =
if S.member d ls then
dadup ls (d : xs) s
else
dadup (S.insert d ls) xs $ applyDiscount s d
-- could be a `Default` instance
new = dadup S.empty []
let discounts = [5.0, 5.0]
strategy = new (Applying 10.0)
finalPrice = dadup (S.fromList [5.0]) [5.0] (Applying 5)
in foldl applyDiscount strategy discounts == finalPrice
let discounts = [5.0, 8.0]
strategy = Applying 10.0
finalPrice = NotApplied [8.0]
in foldl applyDiscount strategy discounts == finalPrice
class DontApplyDuplicate {
constructor(strategy) {
this.usedDiscounts = new Set();
this.duplicated = [];
this.strategy = strategy;
}
applyDiscount(d) {
if (this.usedDiscount.has(d)) {
this.duplicated.push(d);
return this;
}
this.usedDiscount.add(d);
this.strategy = this.strategy.applyDiscount(d)
return this;
}
}
const discounts = [1.0, 4.0];
const strategy = new DontApplyDuplicate(new Applying(10.0));
const finalPrice = 5.0;
discounts.reduce(
(p, d) => p.applyDiscount(d),
strategy
).price == finalPrice;
const discounts = [1.0, 1.0, 4.0, 6.0];
const strategy = new DontApplyDuplicate(new Applying(10.0));
const {
usedDiscounts,
duplicated,
strategy: { list },
} = discounts.reduce(applyDiscount, strategy);
usedDiscount = [1.0, 4.0];
duplicated = [1.0];
list = [6.0];