A Simple Sandwich, Part I

Dec 28, 2022

Drawing of a sandwich

I wrote this as a sort of response to a post by my friend Danny Fekete. For any of this to make sense, you’ll want to read his post first. These are my thoughts related to the ideas Danny brought up as well as Haskell code inspired by Danny’s Ruby code. I would describe the subject as programming philosophy.

Food for Thought

The point of the “exact instructions challenge” shown in this video is to explain to kids (or any non-programmers) what it’s like to write a computer program to solve a task. I used to think this was a good explanation, but now I’ve changed my mind. What’s being said is that giving a computer instructions to solve a task is challenging because you need to be very precise. The computer is dumb and won’t fill in the gaps; it will not necessarily do what you have in mind if you give it vague instructions. Instead, it does the wrong thing or produces an error. So let’s think about this. Why is it different from giving a human vague instructions? Well, a human can fill in the blanks. Why? Because they have the same context as the instruction-giver and both minds abstract the details. If a human did not have the proper context, they would do the task wrong, like the dad in the video. So, the dad is not acting specifically like a computer but rather any receiver with improper context that is attempting the task anyway. Realistically, if a computer has improper context to perform an instruction, it will produce an error rather than try to do it and fail. In whatever programming language you choose, if you try to run the function makeSandwich and that function doesn’t exist, it will produce an error instead of trying to make a sandwich (whatever it means for a computer to make a sandwich…).

So really, what we’re talking about here is context and abstraction, not necessarily humans vs computers. Now you think I’m being pedantic. True, computers are usually the ones with less context and can’t do abstraction on their own. But programmers are the ones deciding what context is needed! If a language is made at a higher level of abstraction than another, it may need less context than the other to accomplish the same task. For example, in Haskell, I can write putStrLn (unwords ["Hello,", "world!"]) to print “Hello, world!”, but in C, I would have to write a lot more code (giving more context about how to make an array into a sentence of words). The fact is, no programmer writes code from scratch. Everyone is building upon other work, even if you’re including the language’s standard library. In fact, if I take the effort to write the unwords function in C, then the resulting code is not too different from Haskell: printf("%s", unwords((char**){"Hello,", "world!"}, 2));.

In the end, if someone (it doesn’t matter who) has made a makeSandwich function and it makes a sandwich (somehow…), I can tell the computer to run that function and I don’t need to give it extra information. The same as telling a human who I trust to make a sandwich, “make me a sandwich”.

Now that we have sophisticated AI (demonstrated by tools like ChatGPT), computers actually have as much context as the average person for many problems. The AI can do spoken language processing that appears to be on par with humans. I can give it a problem to solve, written in plain English, and it will give a solution! Many people would be fooled into thinking they’re chatting with a human.

Programming a Sandwich

That said, let’s say we want to demonstrate what it would be like to program the computer to make a sandwich anyway. In Danny’s post, he wrote a great, complete example in Ruby code. After reading through it, it brings up some interesting points to talk about:

  • Error handling
  • Mutability and state
  • Programming paradigms (object-oriented programming, functional programming)
  • Type theory

Fortunately for me, Danny already handled the hardest part: deciding what functionality to include in the sandwich-making code. After all, we can’t expect the computer to make a real sandwich in the end, so one has to (somewhat arbitrarily) decide what a valid solution requires.

For my own exploration, I decided to take the following steps:

  • Translate Danny’s OOP (object-oriented programming) solution into Haskell
  • Write an idiomatic Haskell solution
  • Write a Haskell solution using type-level programming
  • Write an Agda solution

Before getting to the code, I want to express some thoughts about programming concepts.

Error Handling

Deciding how to handle errors is a notoriously tricky problem in programming. We have to think about what kind of errors our code may encounter. Are they logic-breaking errors, in which case we want our app to print a message and quit running when it encounters one? Or are they more like warnings, where we can print a message to the user and allow the code to continue running? Should we even handle them at all or just let the program break when it may? Maybe we should mix and match? Can we reduce the possible errors some other way?

Before I could write any Haskell solution to the sandwich-making problem, I had to decide how to handle errors. After all, many things can go wrong along the way, like attempting to put a knife in a closed jar of peanut butter.

Because Haskell is a purely functional language, every function is considered either to be “pure” or in a monad (such as “IO”). Using the monad concept is how Haskell gets around the “impure” or “side effect” capability while retaining a pure mathematical foundation. It is favourable to keep as many functions pure as possible, so handling errors in a pure way would be ideal.

Breaking errors

One option to handle errors is to simply break execution and print an error message whenever one happens. In Haskell, this would be done using the built-in error function, which can be used in any function. But this comes with caveats:

  • We can’t know which functions may produce an error by looking at their type signature.
  • We can’t recover from errors.

Exceptions

Another option is to throw exceptions. Then we can catch errors, but it comes with a big caveat:

  • Every function that may throw an exception (or include another function that may throw an exception) must be in the IO monad. That means virtually all the code has to live in the IO monad, and we don’t have any pure functions.

Maybe

A more idiomatic option in Haskell is to use the Maybe type. Any function that might produce an error returns a Maybe a value, which is either Nothing or Just a, where a is any type. For example,

relinquishContents :: Jar -> Maybe (Jar, Condiment)

This version of relinquishContents takes a Jar and returns either Nothing if it fails (because the jar is closed or empty) or a pair (Jar, Condiment) of a new empty Jar and a Condiment. The problem with using Maybe is we don’t have any message attached to the error side; we just have Nothing. So we know something went wrong, but we don’t know what it is.

Either

Finally, the solution I settled on is the other idiomatic option in Haskell: the Either type. It’s almost the same as Maybe, except its values are Left a and Right b, where a and b are any types. This way, we have,

relinquishContents :: Jar -> Either String (Jar, Condiment)

In the error case, relinquishContents returns a Left String (such as Left "The jar is closed and knife-impermeable."). In the good case, it returns a Right (Jar, Condiment). In other words, we either have an error message or a good return value.

Taking this path, every function that might produce an error must return an Either type, which allows us to keep most of the code pure. Also, we have no choice but to write code which handles the errors where they may occur. We can’t simply skip over the fact that a function may produce an error; we have to handle both sides of the result: Left and Right.

Mutability and State

In OOP, it’s common to make an instance of an object with some properties and then mutate its properties along the way. For example, we may have a CondimentJar with a contents property that starts off as some string, like “Peanut Butter”. When we want to empty the jar, we set its contents property to nil, effectively mutating the state of the jar.

This can often make code easy to write but harder to follow. For example, in the Ruby solution’s Sandwich class, I didn’t know what the build! method was going to do when I first saw it. Based on the name, I figured it would build a sandwich and mutate the instance somehow. I didn’t know if it would also mutate something inside the sandwich, like the slices of bread. Without reading the rest of the code, how could I know what other variables might get mutated down the line? To discover the function’s purpose and result, I had to read its entire body. In Haskell, I only need to read a function’s type signature to know exactly what its capabilities are.

In pure functional code, there’s no such thing as mutability. When we have a CondimentJar with its contents set to “Peanut Butter”, we can’t simply change that jar. It will always have peanut butter as its contents. Instead of mutating the jar’s state, we must make a new jar which is a copy of the first one, but with its contents set to a different value.

To people less familiar with the concept of immutability, this may seem like a burden, and sometimes it is! But really, it’s just a different perspective on writing code. Instead of keeping track of every variable and its current state at any point in the code, immutability ensures that no variables can ever change, so we can easily discern their value.

Now, this presents us with a philosophical problem in the sandwich-making context. What sense does it make to have a jar which is always full of peanut butter and a knife which is always clean, and when we put them to use we have a new empty jar and a new loaded knife? And does it make sense that we still have access to the old objects? The way I see it, we can think of this in different ways.

In one way, we can say we simply don’t care that it doesn’t represent the real world accurately and as long as we don’t make use of the old objects after we use them, we’re not doing anything we couldn’t do in reality. We just have to always use the latest version of each object.

Another way to think about it is that having access to the old objects is like being able to travel through time. We can think of every variable as being in a particular snapshot of the universe, which we can always go back to. However, this idea breaks down when we can access a new object and its older counterpart simultaneously, which is kind of like having multiple universes that can interact (like the Marvel multiverse).

The most accurate representation of state in a pure functional context is to keep all stateful things in a variable that must be passed as an argument to any function that may update state. This is like passing around the universe (or at least, the important things in it) so we only have access to one version of it at any point in time. In Haskell, there are libraries which handle this in a monad. Then we can write code which looks like we’re updating state, but anything that involves state must be inside the state monad.

In my code, I opted for the first approach; simply ignore the problem! I figure the sandwich-making is represented well enough and the code is simpler to understand.

Haskell Solution - OOP Translation

Since Haskell is a functional language, the following Haskell code is not idiomatic. It is a translation of the object-oriented Ruby solution. Similarly to how we might translate a poem from Portuguese to English word-for-word, the result may have proper grammar and spelling, but the English translation won’t sound poetic like it would if it were composed in English from the start.

Condiment.hs

module Condiment where

type Condiment = String

data OpenOrClosed = Open | Closed
  deriving (Eq)

data Jar = Jar
  { contents :: Maybe Condiment
  , lid :: OpenOrClosed
  }

newJar :: Condiment -> Jar
newJar c = Jar
  { contents = Just c
  , lid = Closed
  }

isEmpty :: Jar -> Bool
isEmpty Jar{contents=Nothing} = True
isEmpty _ = False

hasStuff :: Jar -> Bool
hasStuff = not . isEmpty

isClosed :: Jar -> Bool
isClosed Jar{lid=Closed} = True
isClosed _ = False

closeJar :: Jar -> Jar
closeJar cj = cj {lid=Closed}

isOpen :: Jar -> Bool
isOpen = not . isClosed

openJar :: Jar -> Jar
openJar cj = cj {lid=Open}

relinquishContents :: Jar -> Either String (Jar, Condiment)
relinquishContents cj@Jar{contents=Just c}
  | isClosed cj = Left "The jar is closed and knife-impermeable."
  | isEmpty cj = Left "The jar is empty. How disappointing."
  | otherwise = Right (cj{contents=Nothing}, c)

In this OOP translation, a Haskell record is defined for each of its Ruby class counterpart. In idiomatic Haskell, records are used frequently but not to represent classes/objects in such a way. And the small functions like isClosed correspond to OOP methods or property accessors, which would be replaced by pattern matching in idiomatic Haskell.

In Condiment.hs, the newJar function acts like an object constructor in OOP (e.g., Ruby’s initialize method). It takes a Condiment to tell it what the contents of the jar should be and gives back a closed jar full of that condiment.

-- Haskell
let pb = Condiment.newJar "Peanut Butter"
# Ruby
pb = CondimentJar.new("Peanut Butter")

As discussed above about error handling, relinquishContents returns an Either type which may either be an error message (Left String) or a pair of a new empty jar and a condiment (Right (Jar, Condiment)).

relinquishContents :: Jar -> Either String (Jar, Condiment)
relinquishContents cj@Jar{contents=Just c}
  | isClosed cj = Left "The jar is closed and knife-impermeable."
  | isEmpty cj = Left "The jar is empty. How disappointing."
  | otherwise = Right (cj{contents=Nothing}, c)

Knife.hs

module Knife where

import qualified Condiment

data Knife = Knife
  { contents :: Maybe Condiment.Condiment
  }

new :: Knife
new = Knife {contents=Nothing}

isClean :: Knife -> Bool
isClean Knife {contents=Nothing} = True
isClean _ = False

clean :: Knife -> Knife
clean k = k {contents=Nothing}

isLoaded :: Knife -> Bool
isLoaded = not . isClean

loadFrom :: Knife -> Condiment.Jar -> Either String (Knife, Condiment.Jar)
loadFrom k cj
  | isLoaded k = Left "This knife is already loaded. Don't mix your condiments!"
  | otherwise = uncurry load <$> Condiment.relinquishContents cj
  where
    load cj' c = (k {contents=Just c}, cj')

Interesting to note here is the decision to include loadFrom in the Knife.hs module. It seemed right to put it here because the OOP version has loadFrom as a method of the Knife object. But in this Haskell version, loadFrom is a pure function that happens to take a Knife and a Condiment.Jar as two arguments, so it doesn’t need to belong in any specific module. It would work just as well to put it in Main.hs. In fact, the same could be said for any of the functions. The choice to put them in a particular module is somewhat arbitrary; it simply makes sense intuitively to bundle them together based on context. In the case of loadFrom, it would make just as much sense to put it in Condiment.hs or Main.hs.

Bread.hs

module Bread where

import qualified Condiment
import qualified Knife

data Surface = Surface
  { contents :: Maybe Condiment.Condiment
  }

newSurface :: Surface
newSurface = Surface
  { contents = Nothing
  }

surfaceIsPlain :: Surface -> Bool
surfaceIsPlain Surface {contents=Nothing} = True
surfaceIsPlain _ = False

surfaceIsSmeared :: Surface -> Bool
surfaceIsSmeared = not . surfaceIsPlain

data Slice = Slice
  { top :: Surface
  , bottom :: Surface
  }

newSlice :: Slice
newSlice = Slice
  { top = newSurface
  , bottom = newSurface
  }

sliceIsPlain :: Slice -> Bool
sliceIsPlain Slice {top=t, bottom=b}
  = surfaceIsPlain t && surfaceIsPlain b

sliceIsSmeared :: Slice -> Bool
sliceIsSmeared = not . sliceIsPlain

smearSurface :: Knife.Knife -> Surface -> Either String (Knife.Knife, Surface)
smearSurface k s
  | surfaceIsSmeared s = Left "This surface was already smeared!"
  | Knife.isClean k = Left "This knife is too clean to smear with."
  | otherwise = Right (Knife.clean k, s {contents=Knife.contents k})

Sandwich.hs

module Sandwich where

import qualified Bread
import qualified Condiment
import qualified Knife

import qualified Data.Maybe as Maybe
import qualified Data.List as L

data Sandwich = Sandwich
  { slices :: [Bread.Slice]
  , built :: Bool
  , isCut :: Bool
  }

new :: [Bread.Slice] -> Sandwich
new slices = Sandwich
  { slices = slices
  , built = False
  , isCut = False
  }

flavours :: Sandwich -> [Condiment.Condiment]
flavours = concat . map sliceFlavours . slices
  where
    sliceFlavours :: Bread.Slice -> [Condiment.Condiment]
    sliceFlavours = Maybe.catMaybes . map Bread.contents . sequence [Bread.top, Bread.bottom]

showFlavours :: Sandwich -> String
showFlavours = f . flavours
  where
    f :: [Condiment.Condiment] -> String
    f cs
      | length cs == 2 = L.intercalate " and " cs
      | otherwise = L.intercalate ", " (init cs) ++ ", and " ++ last cs

isReadyToEat :: Sandwich -> Bool
isReadyToEat sw = built sw && isCut sw

build :: Sandwich -> Either String Sandwich
build sw
  | built sw = Left "It's already a glorious tower of food!"
  | length (slices sw) < 2 = Left "Not enough slices"
  | outsideSmeared = Left "This sandwich would be icky to hold."
  | tooPlain = Left "This sandwich might actually be a loaf."
  | otherwise = Right (sw {built=True})
  where
    bottomSmeared :: Bool
    bottomSmeared = Bread.surfaceIsSmeared . Bread.bottom . head $ slices sw

    topSmeared :: Bool
    topSmeared = Bread.surfaceIsSmeared . Bread.top . last $ slices sw

    outsideSmeared :: Bool
    outsideSmeared = length (slices sw) >= 2 && (bottomSmeared || topSmeared)

    tooPlain :: Bool
    tooPlain = any Bread.sliceIsPlain . init . tail $ slices sw

cut :: Sandwich -> Knife.Knife -> Either String Sandwich
cut sw k
  | (not . built) sw = Left "Build the sandwich and then cut it in one glorious stroke."
  | Knife.isLoaded k = Left "No! You'll get the edge all yucky with that knife."
  | isCut sw = Left "One cut will do."
  | otherwise = Right (sw {isCut=True})

Main.hs

module Main where

import qualified Condiment
import qualified Knife
import qualified Bread
import qualified Sandwich

main :: IO ()
main = do
  let bread = replicate 5 Bread.newSlice
  let pb = Condiment.newJar "Peanut Butter"
  let jelly = Condiment.newJar "Jelly"
  let knife = Knife.new

  -- First attempt. Didn't open the jar of peanut butter.
  either printError putStrLn $ do
    (pbKnife, pbEmpty) <- knife `Knife.loadFrom` pb -- Problem
    (usedKnife1, surface1) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
    (jellyKnife, jellyEmpty) <- knife `Knife.loadFrom` Condiment.openJar jelly
    (usedKnife2, surface2) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
    let sw = Sandwich.new bread
    Sandwich.build sw
    return "Sandwich made!"

  -- Next attempt. Used too much bread inside.
  either printError putStrLn $ do
    (pbKnife, pbEmpty) <- knife `Knife.loadFrom` Condiment.openJar pb
    (usedKnife1, surface1) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
    (jellyKnife, jellyEmpty) <- knife `Knife.loadFrom` Condiment.openJar jelly
    (usedKnife2, surface2) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
    let sw = Sandwich.new bread
    Sandwich.build sw -- Problem
    return "Sandwich made!"

  -- Successful sandwich making!
  either printError putStrLn $ do
    (pbKnife, pbEmpty) <- knife `Knife.loadFrom` Condiment.openJar pb
    (usedKnife1, surface1) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
    (jellyKnife, jellyEmpty) <- knife `Knife.loadFrom` Condiment.openJar jelly
    (usedKnife2, surface2) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
    let sw = Sandwich.new [head bread, last bread]
    Sandwich.build sw
    return "Sandwich made!"
  where
    printError :: String -> IO ()
    printError e = putStrLn ("Error: " ++ e)

This module, Main.hs, is where things get more interesting. In Haskell, we always need a main function that lives in the IO (input/output) monad, otherwise we would never be able to see any results from running our application. The functions that return an Either type for errors are also used as a monad. Haskell’s do notation uses monadic operations which end up looking like imperative instructions. It’s really just syntactic sugar for doing things sequentially and updating context as it goes. The interesting thing is that each code block containing a sandwich-making attempt acts similarly to try-catch exception handling in other languages. If anything goes wrong, that is, if any function returns a Left value, it gets printed with “Error:” before it. If all goes right, it prints the result of the block: “Sandwich made!”.

Again, there’s nothing stopping us from using pbKnife more than once because we’re not keeping track of any state, so some care must be taken when making a sandwich.

In the end, I think this version is quite readable (if you know Haskell), though the modules contain more functions than needed, and the custom types are all records instead of more intuitive types, as in the following version.

What is a sandwich?

Something about the Sandwich type (or Ruby class) doesn’t sit well with me. In my mind, it doesn’t make sense for a sandwich to have a built property that says whether the sandwich is built correctly or not. What would it mean to have a sandwich that is not built? That sounds like something that is not a sandwich, which shouldn’t be part of the definition of what a sandwich is. If it is possible to make an instance of a sandwich that is not a proper sandwich, maybe the type/class/definition of a sandwich needs more refining. In this definition, a sandwich is allowed to have any number of slices. Wouldn’t it make more sense for a sandwich to require at least two slices of bread? Or, even more accurately, a sandwich requires exactly two slices of bread (any inner slices of bread can be considered part of the sandwich–unless that’s all it has, in which case it is a loaf). I see it as partway to an accurate definition of a sandwich but stopped short.

Haskell Solution - Idiomatic

In this version, I attempted a more idiomatic Haskell solution, using fewer records, more pattern matching, and more features of types.

I also took some liberties to reframe parts of the problem. In Danny’s code, I noticed a validation to make sure we’re using a knife where another utensil wouldn’t work.

def smear!(knife:, surface:)
    unless knife.is_a?(Knife)
      raise InvalidKnifeError, "That's not hygienic."
    end
# ...

I decided if we’re going to be checking that we’re using a knife, we may as well include other utensils. Otherwise, the only kind of utensil the program knows about is a knife. In Haskell, we never need to check whether a value is of a particular type; that’s made explicit by static typing, and the compiler does the type-checking work.

I took a similar approach to bread. We may also include different flavours of bread to make things more interesting.

Because this version is idiomatic code, it’s also much shorter, so I put it all in a single module.

Main.hs

module Main where

data UtensilShape = Knife | Spoon | Fork
  deriving (Show, Eq)

data Utensil = Utensil
  { uShape :: UtensilShape
  , uCondiment :: Maybe Condiment
  }
  deriving (Show)

fetchUtensil :: UtensilShape -> Utensil
fetchUtensil shape = Utensil
  { uShape = shape
  , uCondiment = Nothing
  }

data Condiment = PeanutButter | Jelly
  deriving (Show, Eq)

data OpenOrClosed = Open | Closed
  deriving (Show, Eq)

data CondimentJar = CondimentJar
  { cjCondiment :: Maybe Condiment
  , cjLid :: OpenOrClosed
  }
  deriving (Show)

fetchCondimentJar :: Condiment -> CondimentJar
fetchCondimentJar c = CondimentJar
  { cjCondiment = Just c
  , cjLid = Closed
  }

loadFrom :: Utensil -> CondimentJar -> Either String (Utensil, CondimentJar)
loadFrom _ CondimentJar{cjLid=Closed} = Left "The jar is closed and knife-impermeable."
loadFrom _ CondimentJar{cjCondiment=Nothing} = Left "The jar is empty. How disappointing."
loadFrom Utensil{uShape=Fork} _ = Left "Forks aren't the right shape for condiments."
loadFrom u cj@CondimentJar{cjCondiment=Just c}
  = Right (u { uCondiment = Just c }, cj { cjCondiment = Nothing })

openJar :: CondimentJar -> CondimentJar
openJar cj = cj { cjLid = Open }

data BreadFlavour = Sourdough | WholeGrain | White
  deriving (Show)

data SliceOfBread = SliceOfBread
  { sobFlavour :: BreadFlavour
  , sobTop :: Maybe Condiment
  , sobBottom :: Maybe Condiment
  }
  deriving (Show)

fetchSliceOfBread :: BreadFlavour -> SliceOfBread
fetchSliceOfBread flavour = SliceOfBread
  { sobFlavour = flavour
  , sobTop = Nothing
  , sobBottom = Nothing
  }

data Surface = Top | Bottom
  deriving (Show, Eq)

smearSliceOfBread :: Utensil -> Surface -> SliceOfBread -> Either String (SliceOfBread, Utensil)
smearSliceOfBread u surface slice
  | uShape u /= Knife = Left "You can't smear with that!"
  | uCondiment u == Nothing = Left "This knife is too clean to smear with."
  | surface == Top && sobTop slice /= Nothing = Left "This surface was already smeared!"
  | surface == Bottom && sobBottom slice /= Nothing = Left "This surface was already smeared!"
  | otherwise = Right (smearedSlice, cleanUtensil)
  where
    smearedSlice
      | surface == Top = slice { sobTop = uCondiment u }
      | surface == Bottom = slice { sobBottom = uCondiment u }
    cleanUtensil = u { uCondiment = Nothing}

data Sandwich = Sandwich
  { swBottom :: SliceOfBread
  , swTop :: SliceOfBread
  , swPieces :: [(SliceOfBread, SliceOfBread)]
  }
  deriving (Show)

makeSandwich :: SliceOfBread -> SliceOfBread -> Either String Sandwich
makeSandwich bottom top
  | sobTop bottom == Nothing && sobBottom top == Nothing = Left "This sandwich might actually be a loaf."
  | sobTop top /= Nothing || sobBottom bottom /= Nothing = Left "This sandwich would be icky to hold."
  | otherwise = Right Sandwich { swBottom = bottom, swTop = top, swPieces = [(bottom, top)] }

-- A sandwich is always cut through all the pieces, doubling them all
cutSandwich :: Utensil -> Sandwich -> Either String Sandwich
cutSandwich u sw
  | uShape u == Fork || uShape u == Spoon = Left "You can't cut a sandwich with that!"
  | uCondiment u /= Nothing = Left "No! You'll get the edge all yucky with that knife."
  | otherwise = Right sw { swPieces = newPieces }
  where
    newPieces = concat [swPieces sw, swPieces sw]

main :: IO ()
main = do
  let knife = fetchUtensil Knife
  let pb = fetchCondimentJar PeanutButter
  let jelly = fetchCondimentJar Jelly

  -- First attempt. Didn't open the jar of peanut butter.
  either printError putStrLn $ do
    (pbKnife, emptyPB) <- knife `loadFrom` pb -- Problem
    return "Sandwich made!"

  -- Next attempt. Too plain.
  either printError putStrLn $ do
    (pbKnife, emptyPB) <- knife `loadFrom` openJar pb
    (jellyKnife, emptyJelly) <- knife `loadFrom` openJar jelly
    let bottomSlice = fetchSliceOfBread Sourdough
    let topSlice = fetchSliceOfBread WholeGrain
    sw <- makeSandwich bottomSlice topSlice
    return "Sandwich made!"

  -- Successful sandwich making!
  either printError putStrLn $ do
    (pbKnife, emptyPB) <- knife `loadFrom` openJar pb
    (jellyKnife, emptyJelly) <- knife `loadFrom` openJar jelly
    let bottomSlice = fetchSliceOfBread Sourdough
    let topSlice = fetchSliceOfBread WholeGrain
    (bottomSliceWithPB, cleanKnife) <- smearSliceOfBread pbKnife Top bottomSlice
    (topSliceWithJelly, cleanKnife) <- smearSliceOfBread jellyKnife Bottom topSlice
    sw <- makeSandwich bottomSliceWithPB topSliceWithJelly
    return "Sandwich made!"
  where
    printError :: String -> IO ()
    printError e = putStrLn ("Error: " ++ e)

To replace the new functions from the OOP-translated version, I included functions like fetchCondimentJar, which act similarly. The different naming convention (“fetch-” instead of “new-”) is because I started thinking of the instance-creating functions as being analogous to fetching something from the kitchen. When it’s time to get a condiment jar, we can use fetchCondimentJar to fetch one of the given condiments. Interestingly, these idiomatic functions still behave much like OOP constructors.

I also merged the previous Slice and Bread into a single type, SliceOfBread:

data SliceOfBread = SliceOfBread
  { sobFlavour :: BreadFlavour
  , sobTop :: Maybe Condiment
  , sobBottom :: Maybe Condiment
  }

A slice of bread has a top and bottom, both of which can be smeared with a condiment or nothing (hence the Maybe Condiment type). There’s no need for a separate type just for Slice.

Another difference is the type definition for Surface. Instead of using a record, a surface only needs to represent the top or bottom of a slice of bread, so a surface can be a sum type (a choice between multiple values):

data Surface = Top | Bottom

As for the Sandwich type and its shortcomings discussed above, it has been updated in this version.

data Sandwich = Sandwich
  { swBottom :: SliceOfBread
  , swTop :: SliceOfBread
  , swPieces :: [(SliceOfBread, SliceOfBread)]
  }

Now, a sandwich doesn’t have a built property because if an instance of a sandwich exists, it is because it was built. Still, it could be improved. After all, with this definition, it’s easy to make a sandwich that doesn’t make sense:

-- A sandwich whose top and bottom slices are sourdough,
-- but consists of a single piece whose slices are whole grain
impossibleSandwich = Sandwich
  { swBottom = fetchSliceOfBread Sourdough
  , swTop = fetchSliceOfBread Sourdough
  , swPieces = [(fetchSliceOfBread WholeGrain, fetchSliceOfBread WholeGrain)]
  }

No matter how much error handling we add along the way to making a sandwich, our definition of a sandwich makes it possible to skip the error checks and create an erroneous sandwich. Even if we force the user only to make a sandwich using the proper functions, we have to be sure we include checks for all the possible mistakes that could be made. Are we sure we didn’t miss one?

The other approach is to avoid possible errors altogether by using type-safe definitions, making it so a sandwich can only be made when its type is fulfilled. To relate this to the video, the kids are frustrated because their father is failing in ways they didn’t expect him to fail. He’s doing things they didn’t want him to do. On the computer, why would we program the ability to do things we don’t want to happen? We don’t want it to be possible to attempt to put a knife in a closed jar, so we shouldn’t make a function where that can happen. Type systems can help us resolve this. Stay tuned for Part II…

See the full Haskell code on GitHub: https://github.com/SlimTim10/simple-sandwich

Read More