The Partial Options Monoid

By IkamusumeFan (Own work) from https://en.wikipedia.org/wiki/Monoid_(category_theory)

Having an easy-to-extend pattern for configuration is important to keep an application clean and well-structured. The Partial Options Monoid pattern does just that.

The Problem

Let’s say you have a program and it has gotten to the point where just reading strings from the command line is not cutting it:

[retryCount, host] <- getArgs

You want to design a parse phase for your program that will create a nice Options type:

data Options = Options
{ oRetryCount :: Int
, oHost :: String
, oCharacterCode :: Maybe Char
} deriving (Show, Eq)

We would like to be able to create multiple versions of this record and combine them. For instance, we want to read arguments from the command line, but we also have defaults. In the future, we will even want read options from a file. Instead of doing this directly, we will create a separate record: the PartialOptions record.

Step 1: Make the Partial Options Type

The first step is to make a record similar to Options, but every field will be wrapped in a monoid, in this case Last.

data PartialOptions = PartialOptions
{ poRetryCount :: Last Int
, poHost :: Last String
, poCharacterCode :: Last (Maybe Char)
} deriving (Show, Eq)

We can then make a Monoid instance for our PartialOptions type:

instance Monoid PartialOptions where
mempty = PartialOptions mempty mempty mempty
mappend x y = PartialOptions
{ poRetryCount = poRetryCount x <> poRetryCount y
, poHost = poHost x <> poHost y
, poCharacterCode = poCharacterCode x <> poCharacterCode y
}

We will use mappend or <> to combine different configs.

Step 2: Convert the Partial Options to an Options

The PartialOptions are convenient because we can combine them. However, it is not the type we need to run our program. We need to convert from a PartialOptions to an Options. This process will fail if we have not specified all the options:

lastToEither :: String -> Last a -> Either String a
lastToEither errMsg (Last x) = maybe (Left errMsg) Right x
makeOptions :: PartialOptions -> Either String Options
makeOptions PartialOptions {..} = do
oRetryCount <- lastToEither "Missing retry count" poRetryCount
oHost <- lastToEither "Missing host" poHost
oCharacterCode <- lastToEither "Missing character code"
poCharacterCode
return Options {..}

Step 3: Make the Default Options

The first PartialOptions we will make are the defaults:

defaultPartialOptions :: PartialOptions
defaultPartialOptions = mempty
{ poRetryCount = pure 5
, poCharacterCode = pure $ Just 'c'
}

The important thing to notice is we have not specified the poHost option. This means it is a required option. If it is not specified somehow, the makeOptions function will fail.

Step 4: Write a Parser

I’ll give an example of making a command line parser with optparse-applicative.

lastOption :: Parser a -> Parser (Last a)
lastOption parser = fmap Last $ optional parser
partialOptionsParser :: Parser PartialOptions
partialOptionsParser
= PartialOptions
<$> lastOption (option auto (long "retry-count"))
<*> lastOption (option str (long "host"))
<*> lastOption
( fmap Just (option auto (long "character-code"))
<|> flag' Nothing (long "no-character-code")
)

We are being careful to make sure there is a way we can unset the poCharacterCode option or specify a new value.

Step 5: Combine the Partials

We can now parse and combine the results with our defaults:

parseOptions :: IO Options
parseOptions = do
cmdLineOptions <- execParser $ info partialOptionsParser mempty
let combinedOptions = defaultPartialOptions
<> cmdLineOptions
either die return $ mkOptions combinedOptions

Our program will use a strongly typed Options type:

run :: Options -> IO ()

So our main becomes:

main = run =<< parseOptions

Step Later: Write another Parser

At some point later, we could decide we want to also read options from a config file. Let’s write the code to read a YAML config file, assuming one exists:

instance FromJSON PartialOptions where
parseJSON = withObject "FromJSON PartialOptions" $ \obj -> do
poRetryCount <- Last <$> obj .:? "retry-count"
poHost <- Last <$> obj .:? "host"
poCharacterCode <- Last <$> obj .:? "character-code"
return PartialOptions {..}
readPartialOptions :: IO (Either String PartialOptions)
readPartialOptions = do
let configFilePath = "~/.our-app/config.yaml"
exists <- doesFileExist configFilePath
if exists then
either (Left . show) Right <$> decodeFileEither configFilePath
else
return $ Right mempty

We must also extend the parseOptions function:

parseOptions :: IO Options
parseOptions = do
cmdLineOptions <- execParser $ info partialOptionsParser mempty
fileOptions <- either die return =<< readPartialOptions
let combinedOptions = defaultPartialOptions
<> fileOptions
<> cmdLineOptions
either die return $ mkOptions combinedOptions

Set Yourself Up For Success

Parsing options is not the hardest problem. However, if you do not create a pattern the rest of your team can follow, your program can become a tangled mess of random file reads, environment variable lookups and unpredictable defaulting.

The Monoid class is a rock solid abstraction for combining options. You can start simple with a single configuration method, and later add additional methods at any time.

Starting with PartialOptions pattern early on is a great way to keep your application clean.

If you’re curious take a look at https://github.com/jfischoff/partial-options-monoid-pattern for a demo project that includes environment variable parsing.