In this article we discuss how it is possible to use data
and ImplicitParams
to wire an application.
This is part of a series on alternatives to MTL for application design:
- Local Capabilities with MTL
- Some limits of MTL with records of functions
- Records of Functions and Implicit Parameters (this article)
Records of Functions
In previous articles we have discussed records of functions as an alternative to typeclasses for creating boundaries between components in an application. The main benefit is when a component may have multiple implementations, avoiding the need for orphan instances and lawless typeclasses.
For example, we can define records of functions Http
and Database
to describe our interactions with external services:
data Http m = Http
{ getUsers :: m [String]
, postUser :: String -> m ()
}
data Database m = Database
{ dbHistory :: m [String]
, dbAdd :: String -> m ()
}
We can write business logic using these records
doStuff :: Monad m => Http m -> Database m -> m ()
doStuff http db = do
users <- (http & getUsers)
void $ traverse (db & dbAdd) users
But the equivalent MTL is much cleaner:
doStuff_mtl :: (Monad m, Http m, Database m) => m ()
doStuff_mtl = do
users <- getUsers
void $ traverse dbAdd users
with no need to explicitly name or pass the http
and db
.
ImplicitParams
Growing lists of explicit parameters are even worse in larger codebases with many, deeply nested, record dependencies. One can lose sight of the business logic and be preoccupied passing records to all the functions that need them. What should be a one line change to add a new dependency (e.g. a Logger
) inevitably results in large diffs that have a high likelihood of conflicting with other work-in-flight.
Encodings have been proposed along the lines of Classy Lenses and registry
to reduce the boilerplate, but they require knowledge of TemplateHaskell
, lenses and generic programming. The compiler errors can be very confusing.
Here we consider an alternative way to reduce the boilerplate using a language extension designed to introduce dynamic scope: ImplicitParams
.
A function can have an implicit parameter by declaring a named constraint in the type signature. Names must begin with ?
.
Implicit parameters can be used like any other explicit parameter, but remember that the ?
is part of the name.
For example we can convert doStuff
to use implicit parameters
doStuff_ip' :: Monad m => (?http :: Http m, ?db :: Database m) => m ()
doStuff_ip' = do
users <- (?http & getUsers)
void $ traverse (?db & dbAdd) users
The callers of doStuff
do not need to pass Http
or Database
explicitly, the compiler will inject a value that is locally bound with the same name and type.
We can go further and push the implicit parameters to the functions on the record, e.g. redefine the records
data Http m = Http
{ _getUsers :: m [String]
, _postUser :: String -> m ()
}
data Database m = Database
{ _dbHistory :: m [String]
, _dbAdd :: String -> m ()
}
with forwarders
getUsers = _getUsers ?http
postUser = _postUser ?http
dbHistory = _dbHistory ?db
dbAdd = _dbAdd ?db
Allowing us to write code with records of functions that looks almost identical to the MTL
doStuff_ip :: Monad m => (?http :: Http m, ?db :: Database m) => m ()
doStuff_ip = do
users <- getUsers
void $ traverse dbAdd users
And, of great importance is what the errors look like. What if we forgot to put an implicit db
in the type signature? This is the error:
ā¢ Could not deduce: ?db::Database m arising from a use of ādbAdd'ā
from the context: (Monad m, ?http::Http m)
bound by the type signature for:
doStuff_ip :: forall (m :: * -> *).
(Monad m, ?http::Http m) =>
m ()
i.e. it says exactly what is missing, and where.
ImplicitParams
are effectively a way to have opt-in local coherence without the drawbacks of orphan instances. Typeclasses can be reserved for lawful classes exhibiting global coherence, and lawless or non-unique polymorphism can be achieved with data
and ImplicitParams
.
For anybody coming from Scala:
ImplicitParams
are like theimplicit
keyword when in parameter position, but without the implicit search space (i.e. not indef
orval
position).
We can turn an explicit value into an implicit one by naming it in a let
or where
clause
callStuff = do
http <- mkUsers HttpConfig
db <- mkDatabase DatabaseConfig
let ?http = http
?db = db
doStuff_ip
Unfortunately, it is not possible to assign an implicit parameter in a do
binding or a pattern match, it would be great to be able to write
callStuff = do
?http <- mkUsers HttpConfig
?db <- mkDatabase DatabaseConfig
doStuff_ip
perhaps this could be a future ghc
feature?
Finally, this article would not be complete without discussing the rough edges. In the reddit threads Whatās wrong with ImplicitParams? and What about the ImplicitParams Haskell Language Extension? some authors point out that multiple implicit parameters can be introduced with the same name and the compiler accepts it, and other such oddities. Although this extension is almost 20 years old and has proven itself to be production ready (e.g. it is used to implement HasCallStack
) its reach seems to be limited. I believe it is a little gem and deserves more attention!
Thanks for reading. Please share your experience with ImplicitParams
(positive or negative) in the comments below, and happy hacking!