Burger Discounts
International Burger Day falls on Tuesday of next week, so Madame Jellobutter decides to celebrate it by running the classic “buy 2 burgers get 1 free” promotion. She clearly lays out the rules of this promotion:
Given an order comprising multiple items, find the price of the second most expensive burger. Subtract this number from the pre-tax total of the order.
Add a new file src/order-confirmation/Discount.re
:
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let burgers =
items
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
|> Js.Array.filter(~f=item =>
switch (item) {
| Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
switch (burgers) {
| [|Burger(_), Burger(cheaperBurger)|] =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
};
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let burgers =
items
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
|> Js.Array.filter(~f=item =>
switch (item) {
| Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
switch (burgers) {
| [|Burger(_), Burger(cheaperBurger)|] =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
};
Discount.getFreeBurger
function
The new function Discount.getFreeBurger
takes an array of items, finds the second-most-expensive burger, and returns its price encased in Some
. If there is no second burger, it returns None
.
The functions used in Discount.getFreeBurger
are:
- Js.Array.sortInPlaceWith takes a callback function
~f
with type signature('a, 'a) => int
(accept two arguments of the same type and returnint
). It’s used to sort the items by price (highest to lowest). - Stdlib.compare has type signature
('a, 'a) => int
. It’s a polymorphic function capable of comparing many types, includingbool
,int
,string
, etc. Note that you can always just writecompare
instead ofStdlib.compare
, because theStdlib
module is always opened by default. - Js.Array.filter takes a callback function
~f
with type signature'a => bool
. It’s used to make sure all items in theburgers
array are all burgers.
At the moment, this code doesn’t compile, and that’s not the only thing wrong with it, but we’ll address each issue in due course.
Limitation of type inference
You should see this compilation error:
File "src/order-confirmation/Discount.re", line 9, characters 11-17:
9 | | Burger(_) => true
^^^^^^
Error: Unbound constructor Burger
File "src/order-confirmation/Discount.re", line 9, characters 11-17:
9 | | Burger(_) => true
^^^^^^
Error: Unbound constructor Burger
OCaml’s type inference isn’t able to figure out that the callback function passed to Js.Array.filter
receives an argument of Item.t
, so it doesn’t know where the Burger
constructor is coming from. But why does type inference work in the callback to Js.Array.sortInPlaceWith
?
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
The reason is that Item.toPrice
is invoked inside this callback, and its type signature is already known to be Item.t => float
. So type inference can figure out that item1
and item2
must both be of type Item.t
, because Item.toPrice
can only accept an argument of type Item.t
.
Type annotate callback argument
There aren’t any function invocations inside the callback to Js.Array.filter
, so we can help the compiler out by type annotating the item
argument:
|> Js.Array.filter(~f=(item: Item.t) =>
switch (item) {
| Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
|> Js.Array.filter(~f=(item: Item.t) =>
switch (item) {
| Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
Use full name
Explicit type annotation always works, but sometimes it’s enough to just give the compiler a hint. For example, we can use full names[1] for the constructors inside the switch expression:
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Item.Sandwich(_)
| Item.Hotdog => false
}
);
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Item.Sandwich(_)
| Item.Hotdog => false
}
);
Because Item.Burger
is a constructor of the Item.t
variant type, item
must have type Item.t
. For the sake of convenience, you don’t need to use full names in the second branch of the switch expression—OCaml is smart enough to infer which module the Sandwich
and Hotdog
constructors belong to.
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
);
By using the full name of the Burger
constructor, we can now easily refactor the callback function to use the fun
syntax:
|> Js.Array.filter(
~f=
fun
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false,
);
|> Js.Array.filter(
~f=
fun
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false,
);
Add new tests
Add some tests in new file src/order-confirmation/DiscountTests.re
:
open Fest;
test("0 burgers, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Sandwich(Ham),
Sandwich(Turducken),
|]),
None,
)
);
test("1 burger, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Sandwich(Ham),
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
|]),
None,
)
);
test("2 burgers of same price, discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
Sandwich(Ham),
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
|]),
Some(15.),
)
);
open Fest;
test("0 burgers, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Sandwich(Ham),
Sandwich(Turducken),
|]),
None,
)
);
test("1 burger, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Sandwich(Ham),
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
|]),
None,
)
);
test("2 burgers of same price, discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
Sandwich(Ham),
Burger({
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
}),
|]),
Some(15.),
)
);
To run these tests, add a new cram test to src/order-confirmation/tests.t
:
Discount tests
$ node ./output/src/order-confirmation/DiscountTests.mjs | sed '/duration_ms/d'
Discount tests
$ node ./output/src/order-confirmation/DiscountTests.mjs | sed '/duration_ms/d'
Run npm run test:watch
to see that the unit tests pass, then run npm promote
to make the cram test pass.
Records are immutable
It’s unnecessary to fully write out every burger record in the tests. Define a burger
record at the very top of DiscountTests
:
let burger: Item.Burger.t = {
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
};
let burger: Item.Burger.t = {
lettuce: false,
onions: 0,
cheese: 0,
tomatoes: false,
bacon: 0,
};
Then refactor the second and third tests to use this record:
test("1 burger, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|Hotdog, Sandwich(Ham), Burger(burger)|]),
None,
)
);
test("2 burgers of same price, discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger(burger),
Sandwich(Ham),
Burger(burger),
|]),
Some(15.),
)
);
test("1 burger, no discount", () =>
expect
|> equal(
Discount.getFreeBurger([|Hotdog, Sandwich(Ham), Burger(burger)|]),
None,
)
);
test("2 burgers of same price, discount", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger(burger),
Sandwich(Ham),
Burger(burger),
|]),
Some(15.),
)
);
It’s safe to reuse a single record this way because records are immutable. You can pass a record to any function and never worry that its fields might be changed by that function.
Record spread syntax
Add a new test to DiscountTests
:
test("2 burgers of different price, discount of cheaper one", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger({...burger, tomatoes: true}), // 15.05
Sandwich(Ham),
Burger({...burger, bacon: 2}) // 16.00
|]),
Some(15.05),
)
);
test("2 burgers of different price, discount of cheaper one", () =>
expect
|> equal(
Discount.getFreeBurger([|
Hotdog,
Burger({...burger, tomatoes: true}), // 15.05
Sandwich(Ham),
Burger({...burger, bacon: 2}) // 16.00
|]),
Some(15.05),
)
);
Again, we’re reusing the burger
record, but this time, we use record spread syntax to make copies of the burger
record that have slightly different field values. For example,
{...burger, tomatoes: true}
{...burger, tomatoes: true}
means to make a copy of burger
but with tomatoes
set to true
. It’s just a shorter and more convenient way to write this:
{
lettuce: burger.lettuce,
onions: burger.onions,
cheese: burger.cheese,
bacon: burger.bacon,
tomatoes: true,
}
{
lettuce: burger.lettuce,
onions: burger.onions,
cheese: burger.cheese,
bacon: burger.bacon,
tomatoes: true,
}
Ignoring function return values
Add another test to DiscountTests
that checks whether Discount.getFreeBurger
modifies the array passed to it:
test("Input array isn't changed", () => {
let items = [|
Item.Hotdog,
Burger({...burger, tomatoes: true}),
Sandwich(Ham),
Burger({...burger, bacon: 2}),
|];
Discount.getFreeBurger(items);
expect
|> deepEqual(
items,
[|
Item.Hotdog,
Burger({...burger, tomatoes: true}),
Sandwich(Ham),
Burger({...burger, bacon: 2}),
|],
);
});
test("Input array isn't changed", () => {
let items = [|
Item.Hotdog,
Burger({...burger, tomatoes: true}),
Sandwich(Ham),
Burger({...burger, bacon: 2}),
|];
Discount.getFreeBurger(items);
expect
|> deepEqual(
items,
[|
Item.Hotdog,
Burger({...burger, tomatoes: true}),
Sandwich(Ham),
Burger({...burger, bacon: 2}),
|],
);
});
You’ll get this compilation error:
File "src/order-confirmation/DiscountTests.re", line 65, characters 2-31:
65 | Discount.getFreeBurger(items);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type float option
but an expression was expected of type unit
because it is in the left-hand side of a sequence
File "src/order-confirmation/DiscountTests.re", line 65, characters 2-31:
65 | Discount.getFreeBurger(items);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type float option
but an expression was expected of type unit
because it is in the left-hand side of a sequence
When you call a function in OCaml, you must use its return value, unless the return value is ()
(the unit value). However, inside this test, we are calling Discount.getFreeBurger
to test its side effects, so the return value isn’t needed; as such, we can explicitly discard it by using Stdlib.ignore[2]:
Discount.getFreeBurger(items) |> ignore;
Discount.getFreeBurger(items) |> ignore;
Runtime representation of variant
After compilation succeeds, we find that the “Input array isn’t changed” unit test fails. Part of the output (cleaned up for readability) looks like this:
[
0,
{
TAG: 1,
_0: {
bacon: 0,
cheese: 0,
lettuce: false,
onions: 0,
tomatoes: true
}
},
{
TAG: 0,
_0: 1
},
{
TAG: 1,
_0: {
bacon: 2,
cheese: 0,
lettuce: false,
onions: 0,
tomatoes: false
}
}
]
[
0,
{
TAG: 1,
_0: {
bacon: 0,
cheese: 0,
lettuce: false,
onions: 0,
tomatoes: true
}
},
{
TAG: 0,
_0: 1
},
{
TAG: 1,
_0: {
bacon: 2,
cheese: 0,
lettuce: false,
onions: 0,
tomatoes: false
}
}
]
This is how Melange maps the original OCaml values to their JavaScript runtime values:
OCaml source | JavaScript runtime |
---|---|
Item.Hotdog | 0 |
Sandwich(Ham) | {TAG: 0, _0: 1} |
Burger({...burger, tomatoes: true}) | {TAG: 1, _0: {bacon: 0, cheese: 0, lettuce: false, onions: 0, tomatoes: true}} |
A variant constructor without arguments, like Hotdog
, gets turned into an integer. If the constructor has an argument, like Sandwich(Ham)
, then it’s turned into a record where the TAG
field is an integer and the _0
field contains the argument. Records, like the one encased in the Burger
constructor, are turned into JS objects. OCaml arrays, like the one that contains all the items, are turned into JS arrays.
WARNING
Variant constructors in the runtime don’t always have the TAG
field. That field only appears when there’s more than one variant constructor with an argument. See Data types and runtime representation for more details.
Arrays are mutable
The “Input array isn’t changed” unit test fails because arrays in OCaml are mutable (just as in JavaScript) and the Discount.getFreeBurger
function mutates its array argument. The easiest way to fix this is to swap the order of Js.Array.sortInPlaceWith
and Js.Array.filter
invocations:
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.sortInPlaceWith(~f=(item1, item2) =>
- compare(Item.toPrice(item1), Item.toPrice(item2))
)
Although sorting still happens in-place, the array being sorted is a new one created by Js.Array.filter
(the array containing only burgers), not the original input array.
Runtime representation of option
We need to add one more test to check that Discount.getFreeBurger
works when there are more than two burgers:
test("3 burgers of different price, return Some(15.15)", () =>
expect
|> equal(
Discount.getFreeBurger([|
Burger(burger), // 15
Hotdog,
Burger({...burger, tomatoes: true, cheese: 1}), // 15.15
Sandwich(Ham),
Burger({...burger, bacon: 2}) // 16.00
|]),
Some(15.15),
)
);
test("3 burgers of different price, return Some(15.15)", () =>
expect
|> equal(
Discount.getFreeBurger([|
Burger(burger), // 15
Hotdog,
Burger({...burger, tomatoes: true, cheese: 1}), // 15.15
Sandwich(Ham),
Burger({...burger, bacon: 2}) // 16.00
|]),
Some(15.15),
)
);
This test fails, with the key part of the output being:
+ Expected values to be strictly equal:
+ + actual - expected
+
+ + undefined
+ - 15.15
+ Expected values to be strictly equal:
+ + actual - expected
+
+ + undefined
+ - 15.15
Recall that Discount.getFreeBurger
has the return type option(float)
. This is how Melange maps option(float)
values to the JavaScript runtime[3]:
OCaml source | JavaScript runtime |
---|---|
None | undefined |
Some(15.15) | 15.15 |
So Node test runner is basically telling you that None
was returned, but Some(15.15)
was expected.
Pattern matching on arrays
The test is failing because the current “success” pattern match only accounts for a two-element array:
switch (burgers) {
| [|Burger(_), Burger(cheaperBurger)|] =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
switch (burgers) {
| [|Burger(_), Burger(cheaperBurger)|] =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
OCaml only allows you to pattern match on arrays of fixed length, so to fix this, we must instead match on a tuple of the first and second elements of the array:
switch (burgers[0], burgers[1]) {
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
switch (burgers[0], burgers[1]) {
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
Array access is unsafe
The first and second tests now fail due to index out of bounds
errors (since they work on arrays of length 0 and 1, respectively). Array access in OCaml is unsafe by default, so the simplest fix is to check the length of the array before using the switch expression:
Js.Array.length(burgers) < 2
? None
: (
switch (burgers[0], burgers[1]) {
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
}
);
Js.Array.length(burgers) < 2
? None
: (
switch (burgers[0], burgers[1]) {
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
}
);
An alternative approach is to catch the exception that gets raised using an exception
branch inside the switch expression:
switch (burgers[0], burgers[1]) {
| exception _ => None
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
switch (burgers[0], burgers[1]) {
| exception _ => None
| (Burger(_), Burger(cheaperBurger)) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
Array.get
array access function
In OCaml, the array access operator []
is just a function call. That is, burger[0]
is completely equivalent to Array.get(burger, 0)
.
Since the Stdlib
module is opened by default, the Stdlib.Array.get function is used for Array.get
, but it’s possible to override this by defining our own Array
module. Add a new file src/order-confirmation/Array.re
:
// Safe array access function
let get: (array('a), int) => option('a) =
(array, index) =>
switch (index) {
| index when index < 0 || index >= Js.Array.length(array) => None
| index => Some(Stdlib.Array.get(array, index))
};
// Safe array access function
let get: (array('a), int) => option('a) =
(array, index) =>
switch (index) {
| index when index < 0 || index >= Js.Array.length(array) => None
| index => Some(Stdlib.Array.get(array, index))
};
This function returns None
if the index
is out of bounds; otherwise it returns Some(Stdlib.Array.get(array, index))
, i.e. the element at index
encased by Some
.
Introducing our own Array
module triggers a new compilation error:
File "src/order-confirmation/Discount.re", line 18, characters 5-11:
18 | | (Burger(_), Burger(cheaperBurger)) =>
^^^^^^
Error: This variant pattern is expected to have type Item.t option
There is no constructor Burger within type option
File "src/order-confirmation/Discount.re", line 18, characters 5-11:
18 | | (Burger(_), Burger(cheaperBurger)) =>
^^^^^^
Error: This variant pattern is expected to have type Item.t option
There is no constructor Burger within type option
The “success” branch must now include Some
in the pattern match:
switch (burgers[0], burgers[1]) {
| (Some(Burger(_)), Some(Burger(cheaperBurger))) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
switch (burgers[0], burgers[1]) {
| (Some(Burger(_)), Some(Burger(cheaperBurger))) =>
Some(Item.Burger.toPrice(cheaperBurger))
| _ => None
};
Note that we no longer need to check the array length or catch an exception. Our new Array.get
function is a safe function that returns None
instead of raising an exception.
Your code should now compile and all unit tests should pass. If you haven’t done so already, run npm run promote
to promote the latest test output to become the expected test output inside tests.t
.
Variant constructors are not functions
What happens if you to try to rewrite Some(String.length("foobar"))
to "foobar" |> String.length |> Some
?
You’ll get a compilation error:
Error This expression should not be a constructor, the expected type is int -> 'a
Error This expression should not be a constructor, the expected type is int -> 'a
Variant constructors like Some
are not functions, so they can’t be used with the pipe last (|>
) operator. If you have a long chain of function invocations but you need to return a variant at the end, consider using an extra variable, e.g.
let result =
name
|> String.split_on_char(' ')
|> List.map(String.map(c => c |> Char.code |> (+)(1) |> Char.chr))
|> String.concat(" ")
|> String.cat("Hello, ");
Some(result);
let result =
name
|> String.split_on_char(' ')
|> List.map(String.map(c => c |> Char.code |> (+)(1) |> Char.chr))
|> String.concat(" ")
|> String.cat("Hello, ");
Some(result);
See full example on Melange Playground.
When the variant type you’re using is option
, though, you can still chain functions by using the Option.some
helper function:
name
|> String.split_on_char(' ')
|> List.map(String.map(c => c |> Char.code |> (+)(1) |> Char.chr))
|> String.concat(" ")
|> String.cat("Hello, ")
|> Option.some;
name
|> String.split_on_char(' ')
|> List.map(String.map(c => c |> Char.code |> (+)(1) |> Char.chr))
|> String.concat(" ")
|> String.cat("Hello, ")
|> Option.some;
Nice, you’ve implemented the burger discount, and you also understand more about arrays in OCaml. In the next chapter, you’ll implement the same discount logic using lists, which are a better fit for this problem.
Overview
- Type inference is less effective inside functions that don’t call other functions. In those cases, you can give the compiler more information:
- Type annotate the function arguments
- Use the full name for a value used inside the function
- The
Stdlib
module is opened by default - Records are immutable
- Use record spread syntax to copy a record while changing some fields on the copied record, e.g.
{...burger, lettuce: true, onions: 3}
- OCaml doesn’t allow you to ignore the return value of functions (unless the value is
()
), so you can useStdlib.ignore
to explicitly discard return values - Runtime representations of common data types:
- Variant constructor without argument → integer
- Variant constructor with argument → JavaScript object
- Record → JavaScript object
- Array → JavaScript array
None
→undefined
Some(value)
→value
- Variant constructors are not functions
- Array facts:
- Arrays are mutable, just like in JavaScript
- You can pattern match on arrays of fixed length
- Array access is unsafe by default
- What looks like operator usage in
array[index]
is actually just a call toArray.get(array, index)
- You can create your own
Array
module to override the behavior ofArray.get
Exercises
1. Discount.getFreeBurger
can be improved a bit. In particular, there’s no need to match on the Burger
constructor when non-burger items have already been filtered out. Refactor the function so that the “success” pattern match looks like this:
| Some(cheaperPrice) => Some(cheaperPrice)
| Some(cheaperPrice) => Some(cheaperPrice)
Also refactor the “failure” pattern match so there’s no wildcard.
Hint
Use Js.Array.map
Solution
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let prices =
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y));
switch (prices[1]) {
| None => None
| Some(cheaperPrice) => Some(cheaperPrice)
};
};
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let prices =
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y));
switch (prices[1]) {
| None => None
| Some(cheaperPrice) => Some(cheaperPrice)
};
};
Since the chain of function invocations now results in an array of float
s, we rename the variable from burgers
to prices
. We only need to match on the second element of prices
because if it exists, then a first element must also exist (but we don’t need to know its value).
2. Discount.getFreeBurger
can still be improved. Refactor it to remove the switch expression entirely.
Solution
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let prices =
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y));
prices[1];
};
// Buy 2 burgers, get 1 free
let getFreeBurger = (items: array(Item.t)) => {
let prices =
items
|> Js.Array.filter(~f=item =>
switch (item) {
| Item.Burger(_) => true
| Sandwich(_)
| Hotdog => false
}
)
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y));
prices[1];
};
3. Add a new function Discount.getHalfOff
that gives you a discount of half off the entire meal if there’s at least one burger that has one of every topping. Add these new tests to DiscountTests
and make sure they pass:
test("No burger has 1 of every topping, return None", () =>
expect
|> equal(
Discount.getHalfOff([|
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 0,
}),
|]),
None,
)
);
test("One burger has 1 of every topping, return Some", () => {
let items = [|
Item.Hotdog,
Sandwich(Portabello),
Burger({lettuce: true, tomatoes: true, cheese: 1, onions: 1, bacon: 1}),
|];
expect
|> equal(
Discount.getHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.reduce(~init=0.0, ~f=(+.));
Some(sum /. 2.0);
},
);
});
test("No burger has 1 of every topping, return None", () =>
expect
|> equal(
Discount.getHalfOff([|
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 0,
}),
|]),
None,
)
);
test("One burger has 1 of every topping, return Some", () => {
let items = [|
Item.Hotdog,
Sandwich(Portabello),
Burger({lettuce: true, tomatoes: true, cheese: 1, onions: 1, bacon: 1}),
|];
expect
|> equal(
Discount.getHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.reduce(~init=0.0, ~f=(+.));
Some(sum /. 2.0);
},
);
});
Hint
Use Js.Array.some
Solution
// Buy 1+ burger with 1 of every topping, get half off
let getHalfOff = (items: array(Item.t)) => {
let meetsCondition =
items
|> Js.Array.some(
~f=
fun
| Item.Burger({
lettuce: true,
tomatoes: true,
onions: 1,
cheese: 1,
bacon: 1,
}) =>
true
| Burger(_)
| Sandwich(_)
| Hotdog => false,
);
switch (meetsCondition) {
| false => None
| true =>
let total =
items
|> Js.Array.reduce(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
};
};
// Buy 1+ burger with 1 of every topping, get half off
let getHalfOff = (items: array(Item.t)) => {
let meetsCondition =
items
|> Js.Array.some(
~f=
fun
| Item.Burger({
lettuce: true,
tomatoes: true,
onions: 1,
cheese: 1,
bacon: 1,
}) =>
true
| Burger(_)
| Sandwich(_)
| Hotdog => false,
);
switch (meetsCondition) {
| false => None
| true =>
let total =
items
|> Js.Array.reduce(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
};
};
4. Update Discount.getHalfOff
so that it returns a discount of one half off the entire meal if there’s at least one burger that has at least one of every topping. While you’re at it, update the relevant tests in DiscountTests
:
module HalfOff = {
test("No burger has 1+ of every topping, return None", () =>
expect
|> equal(
Discount.getHalfOff([|
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 0,
}),
|]),
None,
)
);
test("One burger has 1+ of every topping, return Some", () => {
let items = [|
Item.Hotdog,
Sandwich(Portabello),
Burger({lettuce: true, tomatoes: true, cheese: 1, onions: 1, bacon: 2}),
|];
expect
|> equal(
Discount.getHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.reduce(~init=0.0, ~f=(+.));
Some(sum /. 2.0);
},
);
});
};
module HalfOff = {
test("No burger has 1+ of every topping, return None", () =>
expect
|> equal(
Discount.getHalfOff([|
Hotdog,
Sandwich(Portabello),
Burger({
lettuce: true,
tomatoes: true,
cheese: 1,
onions: 1,
bacon: 0,
}),
|]),
None,
)
);
test("One burger has 1+ of every topping, return Some", () => {
let items = [|
Item.Hotdog,
Sandwich(Portabello),
Burger({lettuce: true, tomatoes: true, cheese: 1, onions: 1, bacon: 2}),
|];
expect
|> equal(
Discount.getHalfOff(items),
{
// Don't use hardcoded value since Item.toPrice is non-deterministic
let sum =
items
|> Js.Array.map(~f=Item.toPrice)
|> Js.Array.reduce(~init=0.0, ~f=(+.));
Some(sum /. 2.0);
},
);
});
};
Note the use of submodule HalfOff
to group these two tests together.
Hint
Use when guard
Solution
// Buy 1+ burger with 1+ of every topping, get half off
let getHalfOff = (items: array(Item.t)) => {
let meetsCondition =
items
|> Js.Array.some(
~f=
fun
| Item.Burger({
lettuce: true,
tomatoes: true,
onions,
cheese,
bacon,
})
when onions > 0 && cheese > 0 && bacon > 0 =>
true
| Burger(_)
| Sandwich(_)
| Hotdog => false,
);
switch (meetsCondition) {
| false => None
| true =>
let total =
items
|> Js.Array.reduce(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
};
};
// Buy 1+ burger with 1+ of every topping, get half off
let getHalfOff = (items: array(Item.t)) => {
let meetsCondition =
items
|> Js.Array.some(
~f=
fun
| Item.Burger({
lettuce: true,
tomatoes: true,
onions,
cheese,
bacon,
})
when onions > 0 && cheese > 0 && bacon > 0 =>
true
| Burger(_)
| Sandwich(_)
| Hotdog => false,
);
switch (meetsCondition) {
| false => None
| true =>
let total =
items
|> Js.Array.reduce(~init=0.0, ~f=(total, item) =>
total +. Item.toPrice(item)
);
Some(total /. 2.0);
};
};
View source code and demo for this chapter.
The official term for something like
Item.Burger
(module name followed by value name) is access path, but this term isn’t widely used. ↩︎Another valid way to discard the return value of a function is:
reasonlet _: option(float) = Discount.getFreeBurger(items);
let _: option(float) = Discount.getFreeBurger(items);
This works, but
ignore
is more explicit and therefore the recommended approach. ↩︎Technically
option
is a variant, but Melange treats them as a special case—option
values are never represented as JS objects in the runtime. ↩︎