The Partial Options Monoid
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 xmakeOptions :: 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 parserpartialOptionsParser :: 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.