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.