Skip to content

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:

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

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

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

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

text
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 syntaxReason syntax
unit listlist(unit)
float optionoption(float)
int option listlist(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 type unit

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:

re
["", "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:

re
["", "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):

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

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

re
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 return Ok(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:

re
type error =
  | ExpiredCode
  | InvalidCode;
type error =
  | ExpiredCode
  | InvalidCode;

After which you can update Discount.getDiscountFunction to use the new type inside the Error constructor:

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

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

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

re
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

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

reason
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

reason
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 with Ok
  • Replace None with Error(`SomeTag)
  • Replace equal with deepEqual

However, there is a little wrinkle. What if you misspell one of the variant tags?

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

diff
+    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 to option, 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 using Fest.deepEqual
  • Functions in the Stdlib.String module won’t always handle Unicode characters correctly, so prefer to use the functions in Js.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 than List.map for this use case because the return value doesn’t need to be ignored.
  • 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 and VAL.

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

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

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

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



  1. It was quite a sight to see a giant burger zipping around the fairgrounds on a Segway while being chased by a small army of juggalos. ↩︎

  2. Instead of creating a variant tag out of the phrase “burger that has every topping”, we save ourselves some typing by using the much shorter “megaburger”. ↩︎