Promo Codes
As International Burger Day looms ever closer, Madame Jellobutter is eager to start her burger-related promotions. She bought ads on local billboards and she even wore a giant burger costume to pass out flyers at a local music festival[1]. Depending on the ad, potential customers will either see the promo code FREE, which corresponds to the “buy n burgers, get n/2 burgers free” discount, or HALF, which corresponds to the “buy a burger with everything on it and get half off the entire order” discount.
getDiscountFunction
function
Add a new function that maps a given promo code to its corresponding discount function:
let getDiscountFunction = code => {
switch (code |> Js.String.toUpperCase) {
| "FREE" => Some(getFreeBurgers)
| "HALF" => Some(getHalfOff)
| _ => None
};
};
let getDiscountFunction = code => {
switch (code |> Js.String.toUpperCase) {
| "FREE" => Some(getFreeBurgers)
| "HALF" => Some(getHalfOff)
| _ => None
};
};
Instead of Js.String.toUpperCase, we could’ve used String.uppercase_ascii, but as its name implies, String.uppercase_ascii
can only handle strings containing ASCII characters. This is a common restriction for functions in the Stdlib.String module, so for most string operations, you should prefer the functions in Js.String.
Madame Jellobutter informs you that the FREE promotion is only active during the month of May, so you change Discount.getDiscountFunction
accordingly:
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Some(getFreeBurgers)
| "HALF" => Some(getHalfOff)
| _ => None
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Some(getFreeBurgers)
| "HALF" => Some(getHalfOff)
| _ => None
};
};
result
type
The function now takes a date
argument and returns None
if the promo code is FREE and the given date isn’t within the month of May. This logic is correct, but there’s still a flaw: when an error occurs, there’s no way to inform the user of the reason for error. Whether the user misspells the promo code or enters it on June 1, the only feedback they’d get is that it doesn’t work, but they wouldn’t know why. We can remedy this by returning result
instead of option
:
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "FREE" => Error("Expired code")
| "HALF" => Ok(getHalfOff)
| _ => Error("Invalid code")
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "FREE" => Error("Expired code")
| "HALF" => Ok(getHalfOff)
| _ => Error("Invalid code")
};
};
option
vs result
Other than the name, the Ok
constructor of result
is semantically equivalent to the Some
constructor of option
. So the main difference is that the Error
constructor of result
takes an argument, while the None
constructor of option
cannot take an argument. In short, None
can only signal that there’s an error, but Error
can signal what the error is.
TIP
We don’t need to type annotate anything when using the Ok
and Error
constructors because they are always in scope, due to the result type being defined in Stdlib
(which is open by default).
Test for invalid promo codes
Add tests for Discount.getDiscountFunction
in a new submodule called DiscountTests.GetDiscount
:
module GetDiscount = {
test("Invalid promo code return Error", () => {
let date = Js.Date.make();
["", "FREEDOM", "UNICORN", "POO"]
|> List.map(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
);
});
};
module GetDiscount = {
test("Invalid promo code return Error", () => {
let date = Js.Date.make();
["", "FREEDOM", "UNICORN", "POO"]
|> List.map(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
);
});
};
Here we iterate over a few invalid promo codes using List.map
and check that getDiscountFunction
returns Error("Invalid code")
for all of them.
Native syntax in error messages
Regrettably, this code doesn’t compile:
File "docs/order-confirmation/DiscountTests.re", lines 7-14, characters 4-9:
7 | ....["", "FREEDOM", "UNICORN", "POO"]
8 | |> List.map(code =>
9 | expect
10 | |> equal(
11 | Discount.getDiscountFunction(code, date),
12 | Error("Invalid code"),
13 | )
14 | );
Error: This expression has type unit list
but an expression was expected of type unit
File "docs/order-confirmation/DiscountTests.re", lines 7-14, characters 4-9:
7 | ....["", "FREEDOM", "UNICORN", "POO"]
8 | |> List.map(code =>
9 | expect
10 | |> equal(
11 | Discount.getDiscountFunction(code, date),
12 | Error("Invalid code"),
13 | )
14 | );
Error: This expression has type unit list
but an expression was expected of type unit
This error message might seem a little cryptic because it’s using native OCaml syntax and not the Reason syntax that we cover in this book. For reference, this is how the type notations map between native and Reason syntaxes:
Native syntax | Reason syntax |
---|---|
unit list | list(unit) |
float option | option(float) |
int option list | list(option(int)) |
Basically, when you see nested types in error messages, reverse the order of the types and add parentheses to translate it from native to Reason syntax.
List.iter
function
Returning to the error message, if we translate it to Reason syntax, it’s saying:
Error: This expression has type
list(unit)
but an expression was expected of typeunit
Recall that OCaml considers it an error if we generate a value but don’t do anything with it. So the easiest way to fix this is by purposefully discarding the value using ignore
:
["", "FREEDOM", "UNICORN", "POO"]
|> List.map(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
)
|> ignore;
["", "FREEDOM", "UNICORN", "POO"]
|> List.map(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
)
|> ignore;
An even better solution would be to use List.iter:
["", "FREEDOM", "UNICORN", "POO"]
|> List.iter(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
);
["", "FREEDOM", "UNICORN", "POO"]
|> List.iter(code =>
expect
|> equal(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
);
List.iter
runs a side-effect function on each element of a list, and it returns unit
, which doesn’t need to be ignored.
Runtime representation of result
Run the tests (npm run test
) and you’ll get an error (truncated for clarity):
+ Values have same structure but are not reference-equal:
+
+ {
+ TAG: 1,
+ _0: 'Invalid code'
+ }
+
+ code: 'ERR_ASSERTION'
+ name: 'AssertionError'
+ expected:
+ TAG: 1
+ _0: 'Invalid code'
+ actual:
+ TAG: 1
+ _0: 'Invalid code'
+ Values have same structure but are not reference-equal:
+
+ {
+ TAG: 1,
+ _0: 'Invalid code'
+ }
+
+ code: 'ERR_ASSERTION'
+ name: 'AssertionError'
+ expected:
+ TAG: 1
+ _0: 'Invalid code'
+ actual:
+ TAG: 1
+ _0: 'Invalid code'
The result
type is a variant type with two constructors Ok
and Error
, and both of these constructors have an argument. This means that both of them are represented as objects in the JavaScript runtime, e.g.:
OCaml source | JavaScript runtime |
---|---|
Ok(4567) | {TAG: 0, _0: 4567} |
Error("Expired code") | {TAG: 1, _0: "Expired code"} |
Since we are comparing objects and not values, we must use Fest.deepEqual instead of Fest.equal:
expect
|> equal(
|> deepEqual(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
expect
|> equal(
|> deepEqual(
Discount.getDiscountFunction(code, date),
Error("Invalid code"),
)
Test for expired promo codes
Add a new test in DiscountTests.GetDiscount
that checks the temporal behavior of getDiscountFunction
:
test("FREE promo code works in May but not other months", () => {
for (month in 0 to 11) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=float_of_int(month),
~date=10.,
);
expect
|> deepEqual(
Discount.getDiscountFunction("FREE", date),
month == 4 ? Ok(Discount.getFreeBurgers) : Error("Expired code"),
);
}
});
test("FREE promo code works in May but not other months", () => {
for (month in 0 to 11) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=float_of_int(month),
~date=10.,
);
expect
|> deepEqual(
Discount.getDiscountFunction("FREE", date),
month == 4 ? Ok(Discount.getFreeBurgers) : Error("Expired code"),
);
}
});
This test loops over every month of the year:
- If the month is May, then
Discount.getDiscountFunction("FREE", date)
should returnOk(Discount.getFreeBurgers)
- If the month isn’t May, then it should return
Error("Expired code")
For loop
This is the first for loop we’ve seen so far! This is one of the few scenarios in OCaml where a for loop makes sense: iterating over a sequence of numbers and calling a side-effect function on each number.
Add error
type
Right now, Discount.getDiscountFunction
returns two types of errors: Error("Expired code")
and Error("Invalid code")
, which have the same type. Our code will be less brittle if these two different kinds of errors also have different types. Add a new type error
to Discount
:
type error =
| ExpiredCode
| InvalidCode;
type error =
| ExpiredCode
| InvalidCode;
After which you can update Discount.getDiscountFunction
to use the new type inside the Error
constructor:
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "FREE" => Error(ExpiredCode)
| "HALF" => Ok(getHalfOff)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "FREE" => Error(ExpiredCode)
| "HALF" => Ok(getHalfOff)
| _ => Error(InvalidCode)
};
};
Returning different error constructors encased by Error
is the safe alternative to raising different exceptions.
Refactor discount functions
Update Discount.getFreeBurgers
to also use result
instead of option
:
let getFreeBurgers = (items: list(Item.t)) => {
let prices =
items
|> List.filter_map(item =>
switch (item) {
| Item.Burger(burger) => Some(Item.Burger.toPrice(burger))
| Sandwich(_)
| Hotdog => None
}
);
switch (prices) {
| [] => Error("To enjoy this promo, buy at least 2 burgers")
| [_] => Error("To enjoy this promo, buy at least 1 more burger")
| prices =>
let result =
prices
|> List.sort((x, y) => - Float.compare(x, y))
|> List.filteri((index, _) => index mod 2 == 1)
|> List.fold_left((+.), 0.0);
Ok(result);
};
};
let getFreeBurgers = (items: list(Item.t)) => {
let prices =
items
|> List.filter_map(item =>
switch (item) {
| Item.Burger(burger) => Some(Item.Burger.toPrice(burger))
| Sandwich(_)
| Hotdog => None
}
);
switch (prices) {
| [] => Error("To enjoy this promo, buy at least 2 burgers")
| [_] => Error("To enjoy this promo, buy at least 1 more burger")
| prices =>
let result =
prices
|> List.sort((x, y) => - Float.compare(x, y))
|> List.filteri((index, _) => index mod 2 == 1)
|> List.fold_left((+.), 0.0);
Ok(result);
};
};
This is a straightforward refactor, but to fix your tests, you would need to reproduce the error messages verbatim in DiscountTests
, e.g.:
test("0 burgers, no discount", () =>
expect
|> equal(
Discount.getFreeBurgers([
Hotdog,
Sandwich(Ham),
Sandwich(Turducken),
]),
Error("To enjoy this promo, buy at least 2 burgers"),
)
);
test("0 burgers, no discount", () =>
expect
|> equal(
Discount.getFreeBurgers([
Hotdog,
Sandwich(Ham),
Sandwich(Turducken),
]),
Error("To enjoy this promo, buy at least 2 burgers"),
)
);
You would need to change your tests every time the error message changes, which is annoying. But error messages don’t necessarily need to be inside Discount.getFreeBurgers
, there should be a way to indicate what an error is without using the actual error message.
Polymorphic variants
Instead of using a string as the argument for the Error
constructor, you can instead use a polymorphic variant:
let getFreeBurgers = (items: list(Item.t)) => {
let prices =
items
|> List.filter_map(item =>
switch (item) {
| Item.Burger(burger) => Some(Item.Burger.toPrice(burger))
| Sandwich(_)
| Hotdog => None
}
);
switch (prices) {
| [] => Error(`NeedTwoBurgers)
| [_] => Error(`NeedOneBurger)
| prices =>
let result =
prices
|> List.sort((x, y) => - Float.compare(x, y))
|> List.filteri((index, _) => index mod 2 == 1)
|> List.fold_left((+.), 0.0);
Ok(result);
};
};
let getFreeBurgers = (items: list(Item.t)) => {
let prices =
items
|> List.filter_map(item =>
switch (item) {
| Item.Burger(burger) => Some(Item.Burger.toPrice(burger))
| Sandwich(_)
| Hotdog => None
}
);
switch (prices) {
| [] => Error(`NeedTwoBurgers)
| [_] => Error(`NeedOneBurger)
| prices =>
let result =
prices
|> List.sort((x, y) => - Float.compare(x, y))
|> List.filteri((index, _) => index mod 2 == 1)
|> List.fold_left((+.), 0.0);
Ok(result);
};
};
Polymorphic variants are similar to the variants you’ve seen before, the main difference being that you don’t need to define the variant tags beforehand. Instead, you can freely use variant tags (like `NeedTwoBurgers
and `NeedOneBurger
) inside a function, and the type of the function is inferred from the usage. For example, the type of Discount.getFreeBurgers
is now
list(Item.t) => result(float, [> `NeedOneBurger | `NeedTwoBurgers ])
list(Item.t) => result(float, [> `NeedOneBurger | `NeedTwoBurgers ])
TIP
The name of a variant tag must start with the backtick (`
) character.
Refactor Discount.getHalfOff
to return result
as well[2]:
switch (meetsCondition) {
| false => None
| false => Error(`NeedMegaBurger)
| true =>
let total =
items
|> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
Ok(total /. 2.0);
switch (meetsCondition) {
| false => None
| false => Error(`NeedMegaBurger)
| true =>
let total =
items
|> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
Ok(total /. 2.0);
The type of Discount.getHalfOff
is now
list(Item.t) => result(float, [> `NeedMegaBurger ])
list(Item.t) => result(float, [> `NeedMegaBurger ])
Fixing the tests
Fixing the tests is mostly a mechanical effort and consists of these steps:
- Replace
Some
withOk
- Replace
None
withError(`SomeTag)
- Replace
equal
withdeepEqual
However, there is a little wrinkle. What if you misspell one of the variant tags?
test("1 burger, no discount", () =>
expect
|> deepEqual(
Discount.getFreeBurgers([Hotdog, Sandwich(Ham), Burger(burger)]),
Error(`NeedOneBurgers),
);
test("1 burger, no discount", () =>
expect
|> deepEqual(
Discount.getFreeBurgers([Hotdog, Sandwich(Ham), Burger(burger)]),
Error(`NeedOneBurgers),
);
The constructor should be called `NeedOneBurger
but here it’s been misspelled as `NeedOneBurgers
, yet it still compiles! This is due to the open-ended nature of polymorphic variants. The compiler knows that Discount.getFreeBurgers
currently can’t return Error(`NeedOneBurgers)
, but because variant tags don’t need to be defined up-front, it has no way to know that the function will never return Error(`NeedOneBurgers)
in the future.
Runtime representation of polymorphic variants
If you run the test with the misspelling, you’ll get this error (shortened for clarity):
+ expected:
+ TAG: 1
+ _0: 'NeedOneBurgers'
+ actual:
+ TAG: 1
+ _0: 'NeedOneBurger'
+ expected:
+ TAG: 1
+ _0: 'NeedOneBurgers'
+ actual:
+ TAG: 1
+ _0: 'NeedOneBurger'
You can see that variant tags without arguments are just strings in the JS runtime.
For reference, the runtime representation of a variant tag with an argument is an object with NAME
and VAL
fields. For example, `foo(42)
becomes {"NAME": "foo", "VAL": 42}
.
Variants vs polymorphic variants
Variants are always going to be more reliable than polymorphic variants because you must define them using type
before you can use them, meaning if you ever misspell a constructor, you’ll get an error. Polymorphic variants are more flexible and convenient but allow you to make silly mistakes. However, we will see in the next chapter that polymorphic variants are just as type-safe as variants.
Espectáculo! You’ve made your discount logic more expressive by converting it to use result
instead of option
. You’ve also started to experience the power of polymorphic variants. In the next chapter, we’ll build a new ReasonReact component that uses our discount functions.
Overview
- The
result
type is similar tooption
, except that the “failure” constructor takes an argument- The
Ok
constructor becomes an object in the JS runtime - The
Error
constructor becomes an object in the JS runtime - Inside tests,
result
values must be compared usingFest.deepEqual
- The
- Functions in the
Stdlib.String
module won’t always handle Unicode characters correctly, so prefer to use the functions inJs.String
- Compilation error messages use OCaml’s native syntax when describing types
- Convert from native to Reason syntax by reversing the order of the types and inserting parentheses between them
List.iter
is useful for running a side-effect function on every element of a list. It’s better thanList.map
for this use case because the return value doesn’t need to beignore
d.- For loops are useful for the narrow use case of running a side-effect function on a sequence of numbers
- Polymorphic variants are like the variants we’ve already seen but with some differences:
- The variant tags don’t need to defined before they are used
- A tag must start with the backtick (
`
) character - A variant tag without arguments becomes a string in the JS runtime
- A variant tag with argument(s) becomes an object in the JS runtime. The keys in the object are
NAME
andVAL
.
Exercises
1. Rewrite the “FREE promo code works in May but not other months” to use List.iter
instead of a for loop.
Hint
Use List.init.
Solution
test("FREE promo code works in May but not other months", () => {
List.init(12, i => i)
|> List.iter(month => {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=float_of_int(month),
~date=10.,
);
expect
|> deepEqual(
Discount.getDiscountFunction("FREE", date),
month == 4 ? Ok(Discount.getFreeBurgers) : Error(ExpiredCode),
);
})
});
test("FREE promo code works in May but not other months", () => {
List.init(12, i => i)
|> List.iter(month => {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=float_of_int(month),
~date=10.,
);
expect
|> deepEqual(
Discount.getDiscountFunction("FREE", date),
month == 4 ? Ok(Discount.getFreeBurgers) : Error(ExpiredCode),
);
})
});
2. Modify Discount.getDiscountFunction
so that it returns Ok(getHalfOff)
only if the promo code is “HALF” and the date is May 28, International Burger Day. Make sure it passes the following test:
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 28 ? Ok(Discount.getHalfOff) : Error(ExpiredCode),
);
}
});
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 28 ? Ok(Discount.getHalfOff) : Error(ExpiredCode),
);
}
});
Solution
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff)
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff)
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
3. Add a new function Discount.getSandwichHalfOff
that takes half off the entire order if you order at least one of every type of sandwich. Make sure it passes the following tests:
module SandwichHalfOff = {
test("Not all sandwiches, return Error", () =>
expect
|> deepEqual(
Discount.getSandwichHalfOff([
Sandwich(Unicorn),
Hotdog,
Sandwich(Portabello),
Sandwich(Ham),
]),
Error(`MissingSandwichTypes),
)
);
test("All sandwiches, return Ok", () => {
let items = [
Item.Sandwich(Turducken),
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 2,
}),
Sandwich(Unicorn),
Sandwich(Ham),
];
expect
|> deepEqual(
Discount.getSandwichHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items |> List.map(Item.toPrice) |> List.fold_left((+.), 0.0);
Ok(sum /. 2.0);
},
);
});
};
module SandwichHalfOff = {
test("Not all sandwiches, return Error", () =>
expect
|> deepEqual(
Discount.getSandwichHalfOff([
Sandwich(Unicorn),
Hotdog,
Sandwich(Portabello),
Sandwich(Ham),
]),
Error(`MissingSandwichTypes),
)
);
test("All sandwiches, return Ok", () => {
let items = [
Item.Sandwich(Turducken),
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 2,
}),
Sandwich(Unicorn),
Sandwich(Ham),
];
expect
|> deepEqual(
Discount.getSandwichHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items |> List.map(Item.toPrice) |> List.fold_left((+.), 0.0);
Ok(sum /. 2.0);
},
);
});
};
Hint 1
Add a new record type that keeps track of which sandwich types have been encountered in the order.
Hint 2
Use List.filter_map and ListLabels.fold_left.
Solution
type sandwichTracker = {
portabello: bool,
ham: bool,
unicorn: bool,
turducken: bool,
};
/** Buy 1+ of every type of sandwich, get half off */
let getSandwichHalfOff = (items: list(Item.t)) => {
let tracker =
items
|> List.filter_map(
fun
| Item.Sandwich(sandwich) => Some(sandwich)
| Burger(_)
| Hotdog => None,
)
|> ListLabels.fold_left(
~init={
portabello: false,
ham: false,
unicorn: false,
turducken: false,
},
~f=(tracker, sandwich: Item.Sandwich.t) =>
switch (sandwich) {
| Portabello => {
...tracker,
portabello: true,
}
| Ham => {
...tracker,
ham: true,
}
| Unicorn => {
...tracker,
unicorn: true,
}
| Turducken => {
...tracker,
turducken: true,
}
}
);
switch (tracker) {
| {portabello: true, ham: true, unicorn: true, turducken: true} =>
let total =
items
|> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Ok(total /. 2.0);
| _ => Error(`MissingSandwichTypes)
};
};
type sandwichTracker = {
portabello: bool,
ham: bool,
unicorn: bool,
turducken: bool,
};
/** Buy 1+ of every type of sandwich, get half off */
let getSandwichHalfOff = (items: list(Item.t)) => {
let tracker =
items
|> List.filter_map(
fun
| Item.Sandwich(sandwich) => Some(sandwich)
| Burger(_)
| Hotdog => None,
)
|> ListLabels.fold_left(
~init={
portabello: false,
ham: false,
unicorn: false,
turducken: false,
},
~f=(tracker, sandwich: Item.Sandwich.t) =>
switch (sandwich) {
| Portabello => {
...tracker,
portabello: true,
}
| Ham => {
...tracker,
ham: true,
}
| Unicorn => {
...tracker,
unicorn: true,
}
| Turducken => {
...tracker,
turducken: true,
}
}
);
switch (tracker) {
| {portabello: true, ham: true, unicorn: true, turducken: true} =>
let total =
items
|> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Ok(total /. 2.0);
| _ => Error(`MissingSandwichTypes)
};
};
4. Modify Discount.getDiscountFunction
so that it returns Ok(getSandwichHalfOff)
only if the promo code is “HALF” and the date is November 3, World Sandwich Day. Make sure it passes the following test:
test(
"HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov",
() => {
for (dayOfMonth in 1 to 30) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=10.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 3
? Ok(Discount.getSandwichHalfOff) : Error(ExpiredCode),
);
}
});
test(
"HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov",
() => {
for (dayOfMonth in 1 to 30) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=10.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 3
? Ok(Discount.getSandwichHalfOff) : Error(ExpiredCode),
);
}
});
Solution
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff)
| "HALF" when month == 10.0 && dayOfMonth == 3.0 => Ok(getSandwichHalfOff)
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff)
| "HALF" when month == 10.0 && dayOfMonth == 3.0 => Ok(getSandwichHalfOff)
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
View source code and demo for this chapter.