Even if you work in the orderly, bug-free, spic-and-span, statically-typed worlds of Elm and Haskell (like we do at NoRedInk, did you know we’re hiring?), you still have to talk to the wild free-wheeling dynamically-typed world sometimes. Most recently: we were trying to bridge the gap between Haskell (🧐) and Redis(🤪). Here we’ll discuss two iterations of our Redis library for Haskell, nri-redis.
All examples in this code are in Haskell and use a few functions from NoRedInk’s prelude nri-prelude. Specifically, we will use |>
instead of &
, Debug.toString
instead of show
and a few functions from Expect
. Most of the example code could be real tests.
💬 Redis in Haskell
Let’s begin with a look at an earlier iteration of nri-redis (a wrapper around hedis). We are going to work with two functions get
and set
which have the following type signatures:
set :: Data.Aeson.ToJSON a => Text -> a -> Query ()
get :: Data.Aeson.ToJSON a => Text -> Query (Maybe a)
Let’s use this API for a blogging application that stored blog posts and users in Redis.
data User = User
{ name :: Text
-- maybe more fields later
}
deriving (Generic, Show, Eq)
data Post = Post
{ title :: Text
-- ...
}
deriving (Generic, Show)
Maybe you noticed that we derive Generic
for both types. We will store users and posts as JSON in Redis. Storing data as JSON in Redis is simple, and we only need an additional instance for decoding and encoding to JSON.
instance Data.Aeson.ToJSON User
instance Data.Aeson.FromJSON User
instance Data.Aeson.ToJSON Post
instance Data.Aeson.FromJSON Post
Now how do we write something to Redis?
Redis.set "user-1" User { name = "Luke" } -- create a query
|> Redis.query handler -- run the query
|> Expect.succeeds -- fail the test if the query fails
We use Redis.set
, which corresponds to set
. We can then execute the query using Redis.query
.
We can read the data back using a get
.
maybeUser <- Redis.get "user-1"
|> Redis.query handler
|> Expect.succeeds
Expect.equal (Just User {name = "Luke" }) maybeUser
🐛 What can go wrong?
Now that we know how to read and write to Redis let’s look at this example. Can you spot the error?
let key1 = "user-1"
let key2 = "post-1"
Redis.set key1 User { name = "Obi-wan Kenobi" }
|> Redis.query handler
|> Expect.succeeds
Redis.set key1 Post { title = "Using the force to wash your dishes" }
|> Redis.query handler
|> Expect.succeeds
maybeUser <- Redis.get key1
|> Redis.query handler
|> Expect.succeeds
Expect.equal (Just User {name = "Obi-wan Kenobi"}) maybeUser
-- !!! "Could not decode value in key: Error in $: parsing User(User) failed, key 'name' not found"
A runtime error?! in Haskell?! Say it ain’t so.
Maybe you spotted the bug: We are using key1
to set
the
post
instead of key2
.
- First, we set the data in
key1
to be aUser
- We then replaced it with a
Post
. - We fetch the data from
key1
(aPost
) intomaybeUser
. - The compiler thinks
maybeUser
is of typeMaybe User
, because we compare it withMaybe User
inExpect.Equal
. - At runtime, the generated code from the
FromJSON
instance will then fail to decode thePost
’s json-serialization intoUser
. - which will cause our program to crash.
This is not the only thing that can go wrong! Let’s consider the next example:
let users =
[ ("user-1", User { name = "Obi"})
, ("user-2", User { name = "Yoda"})
]
Redis.set "user-1" users
|> Redis.query handler
|> Expect.succeeds
maybeUser <-
Redis.get "user-1"
|> Redis.query handler
|> Expect.succeeds
Expect.equal (Just User { name = "Obi"}) maybeUser
-- !!! "Could not decode value in key: Error in $: parsing User(User) failed, expected Object, but encountered Array"
We called set with users
instead of a User
(or called Redis’s mset
on the list users
). Again, this compiles but fails at runtime when we assume that we receive one User
when we call get for this key.
🛡️ Can we make the bug impossible?
The previous examples showed how easy it was to write bugs with such a generic API—the program compiled in both cases but failed at runtime. The compiler couldn’t save us because set
and get
both accept any Text
as a key and only constrain the value to have an instance of To/FromJSON
.
-- reminder: the API that allowed all kinds of havoc
set :: Data.Aeson.ToJSON a => Text -> a -> Query ()
get :: Data.Aeson.ToJSON a => Text -> Query (Maybe a)
Want to set a User
and fetch a list of Posts
from the same key? This API will let you (and then fail loudly in production).
Ideally, we would get a compiler error that prevents mixing up keys or passing the wrong value when writing to Redis. We decided to use an Elm-ish approach, avoiding making the API too magic.
Instead of using commands directly, we introduced a new type called Redis.Api key value
. Its purpose is to bind a concrete type for key
and value
to commands.
Let’s try to make the same mistake we made earlier, using the wrong type, with our new Redis.Api
.
Let’s first create such an Api
.
newtype UserId = UserId Int
userApi :: Redis.Api UserId User
userApi = Redis.jsonApi (\userId -> "user-" ++ Debug.toString userId)
We bound UserId
to User
by adding a top-level definition and giving it a type signature.
Additionally, we created a newtype
instead of relying on Text
as the key. This will guarantee that we don’t call userApi
with a post
key.
Now to use this, we call functions by “passing” them the new userApi
.
Side note:Redis.Api
is a record, and we get the commands from it.
let users = [(UserId 1, User { name = "Obi"}), (UserId 2, User { name = "Yoda"})]
Redis.set userApi (UserId 1) users
|> Redis.query handler
|> Expect.succeeds
We catch the bug at compile time without writing a test (and well before causing a fire in production).
• Couldn't match expected type ‘User’
with actual type ‘[(UserId, User)]’
|
325 | Redis.set userApi key1 users
| ^^^^^
🤗 Making Tools Developer-Friendly
Our initial Redis API used a very generic type for keys and values (only constraint to implement To/FromJSON
). This optimized the library for versatility but made the resulting application code less maintainable and error-prone.
Having concrete types provides the compiler with more information and therefore allows for better error messages and better maintainability (e.g., less error-prone). The API we ended up with is a balance of simplicity and safety. It’s still possible to misuse the library and get the bugs we discussed, but it guides the user towards the intended usage.
You can’t always predict how your library’s users will use your tools, so leaving them open-ended has its upsides. But if you have a specific use-case, solving it specifically will help your developers avoid pitfalls and keep them more productive.
Christoph Hermann @stoeffel Engineer at NoRedInk.
Thanks to Michael Glass @michaelglass and Jasper Woudenberg @jwoudenberg for helping building this and their support writting this blogpost! ❤️