Skip to content

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:

reason
// 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 return int). 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, including bool, int, string, etc. Note that you can always just write compare instead of Stdlib.compare, because the Stdlib 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 the burgers 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?

reason
|> 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:

re
|> 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:

re
|> 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.

re
|> 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:

re
|> 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:

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:

cram
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:

re
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:

re
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:

re
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,

reason
{...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:

reason
{
  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:

reason
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]:

reason
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:

json
[
  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 sourceJavaScript runtime
Item.Hotdog0
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:

re
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:

re
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:

diff
+      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 sourceJavaScript runtime
Noneundefined
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:

reason
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:

re
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:

re
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:

re
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:

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:

re
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.

re
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:

re
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 use Stdlib.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
    • Noneundefined
    • 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 to Array.get(array, index)
    • You can create your own Array module to override the behavior of Array.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:

reason
| 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
re
// 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 floats, 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
re
// 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:

re
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
re
// 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:

re
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
re
// 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.



  1. The official term for something like Item.Burger (module name followed by value name) is access path, but this term isn’t widely used. ↩︎

  2. Another valid way to discard the return value of a function is:

    reason
    let _: option(float) = Discount.getFreeBurger(items);
    let _: option(float) = Discount.getFreeBurger(items);

    This works, but ignore is more explicit and therefore the recommended approach. ↩︎

  3. Technically option is a variant, but Melange treats them as a special case—option values are never represented as JS objects in the runtime. ↩︎