Quantcast
Channel: NoRedInk
Viewing all articles
Browse latest Browse all 193

🌉 Bridging a typed and an untyped world

$
0
0

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.

  1. First, we set the data in key1 to be a User
  2. We then replaced it with a Post.
  3. We fetch the data from key1 (a Post) into maybeUser.
  4. The compiler thinks maybeUser is of type Maybe User, because we compare it with Maybe User in Expect.Equal.
  5. At runtime, the generated code from the FromJSON instance will then fail to decode the Post’s json-serialization into User.
  6. 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! ❤️


Viewing all articles
Browse latest Browse all 193

Trending Articles