Susan Potter
### snippets  ·  Created  ·  Updated

Reader: Dependency Injection Without the Ceremony

When code needs access to configuration or shared context, the naive approach passes it as a parameter to every function. This works, but it clutters signatures and makes refactoring tedious. Reader solves this by making the environment implicit in the computation itself.

The Problem: Parameter Drilling

Consider a REST client that needs credentials for every request:

type Credentials = { apiKey :: String, baseUrl :: String }

getUser :: Credentials -> UserId -> Effect User
getUser creds userId = ...

getPosts :: Credentials -> UserId -> Effect (Array Post)
getPosts creds userId = ...

getUserWithPosts :: Credentials -> UserId -> Effect { user :: User, posts :: Array Post }
getUserWithPosts creds userId = do
  user <- getUser creds userId
  posts <- getPosts creds userId
  pure { user, posts }

Every function takes Credentials as its first parameter. Every call site passes it along. Add a new function that needs credentials, and you thread the parameter through the entire call chain. This is parameter drilling.

The problem compounds. If getUser calls a helper function, that helper needs credentials too:

fetchJson :: Credentials -> String -> Effect Json
fetchJson creds path = ...

getUser :: Credentials -> UserId -> Effect User
getUser creds userId = do
  json <- fetchJson creds ("/users/" <> show userId)
  ...

The credentials flow through every layer, obscuring the actual logic.

Reader: Environment as Context

Reader wraps a computation that depends on an environment. Instead of taking the environment as a parameter, the computation receives it when run:

newtype Reader r a = Reader (r -> a)

runReader :: forall r a. Reader r a -> r -> a
runReader (Reader f) env = f env

A Reader r a is a function from environment r to result a. The environment is fixed for the entire computation. You supply it once at the top level.

Accessing the Environment

The ask function retrieves the environment:

ask :: forall r. Reader r r
ask = Reader identity

This gives your computation access to the entire environment. For accessing a specific field, use asks:

asks :: forall r a. (r -> a) -> Reader r a
asks f = Reader f

-- Usage: get just the API key
getApiKey :: Reader Credentials String
getApiKey = asks _.apiKey

Making Reader Composable

Reader becomes useful when computations compose. This requires Functor, Apply, and Monad instances.

instance Functor (Reader r) where
  map f (Reader g) = Reader (f <<< g)

instance Apply (Reader r) where
  apply (Reader f) (Reader a) = Reader \r -> f r (a r)

instance Applicative (Reader r) where
  pure a = Reader \_ -> a

instance Bind (Reader r) where
  bind (Reader m) f = Reader \r -> runReader (f (m r)) r

instance Monad (Reader r)

The Bind instance enables composition. When you sequence Reader computations with bind (or do-notation), each computation receives the same environment r.

Rewriting with Reader

The REST client becomes:

getUser :: UserId -> Reader Credentials (Effect User)
getUser userId = do
  creds <- ask
  pure (fetchUserEffect creds userId)

getPosts :: UserId -> Reader Credentials (Effect (Array Post))
getPosts userId = do
  creds <- ask
  pure (fetchPostsEffect creds userId)

getUserWithPosts :: UserId -> Reader Credentials (Effect { user :: User, posts :: Array Post })
getUserWithPosts userId = do
  userEffect <- getUser userId
  postsEffect <- getPosts userId
  pure do
    user <- userEffect
    posts <- postsEffect
    pure { user, posts }

The Credentials parameter disappears from every signature. Functions declare their dependency on the environment through the Reader Credentials type, but they don't pass it around.

Run the computation by supplying the environment once:

main :: Effect Unit
main = do
  let creds = { apiKey: "secret", baseUrl: "https://api.example.com" }
  let computation = getUserWithPosts (UserId 42)
  result <- runReader computation creds
  log (show result)

ReaderT: Combining with Other Effects

The previous example returns Reader Credentials (Effect User), a Reader containing an Effect. This works, but notice the nested do blocks in getUserWithPosts: one for Reader, one for Effect. The two layers don't compose. ReaderT solves this by fusing Reader and another monad into a single layer:

newtype ReaderT r m a = ReaderT (r -> m a)

runReaderT :: forall r m a. ReaderT r m a -> r -> m a
runReaderT (ReaderT f) env = f env

ReaderT r Effect a is a computation that reads from environment r and performs effects:

getUser :: UserId -> ReaderT Credentials Effect User
getUser userId = do
  creds <- askT
  liftEffect (fetchUserEffect creds userId)

getPosts :: UserId -> ReaderT Credentials Effect (Array Post)
getPosts userId = do
  creds <- askT
  liftEffect (fetchPostsEffect creds userId)

getUserWithPosts :: UserId -> ReaderT Credentials Effect { user :: User, posts :: Array Post }
getUserWithPosts userId = do
  user <- getUser userId
  posts <- getPosts userId
  pure { user, posts }

Effect and environment handling now compose in a single do block. Each function accesses askT when needed, and liftEffect bridges to the underlying Effect. In practice, libraries define a typeclass (MonadAsk) so both Reader and ReaderT share the same ask function.

Local: Modifying the Environment

Sometimes a subcomputation needs a modified environment. The local function transforms the environment for a scoped computation:

local :: forall r a. (r -> r) -> Reader r a -> Reader r a
local f (Reader m) = Reader (m <<< f)

This runs the inner computation with a transformed environment, without affecting the outer computation's view:

type Config = { logLevel :: LogLevel, apiKey :: String }

withDebugLogging :: forall a. Reader Config a -> Reader Config a
withDebugLogging = local (_ { logLevel = Debug })

-- Inner computation sees logLevel = Debug
-- Outer computation still sees original logLevel

When to Use Reader

Reader fits scenarios where:

  • Configuration is read-only and needed across many functions
  • You want explicit but unobtrusive dependency declaration
  • Test code needs to substitute different environments

Reader does not fit when:

  • The "environment" changes during computation (use State)
  • You need multiple independent configurations (use records or type classes)
  • The overhead of abstraction exceeds the benefit (small programs)

Full Code Listing

module Reader where

import Prelude

newtype Reader r a = Reader (r -> a)

runReader :: forall r a. Reader r a -> r -> a
runReader (Reader f) env = f env

ask :: forall r. Reader r r
ask = Reader identity

asks :: forall r a. (r -> a) -> Reader r a
asks = Reader

local :: forall r a. (r -> r) -> Reader r a -> Reader r a
local f (Reader m) = Reader (m <<< f)

instance Functor (Reader r) where
  map f (Reader g) = Reader (f <<< g)

instance Apply (Reader r) where
  apply (Reader f) (Reader a) = Reader \r -> f r (a r)

instance Applicative (Reader r) where
  pure a = Reader \_ -> a

instance Bind (Reader r) where
  bind (Reader m) f = Reader \r -> runReader (f (m r)) r

instance Monad (Reader r)

-- ReaderT transformer
newtype ReaderT r m a = ReaderT (r -> m a)

runReaderT :: forall r m a. ReaderT r m a -> r -> m a
runReaderT (ReaderT f) env = f env

askT :: forall r m. Applicative m => ReaderT r m r
askT = ReaderT pure

localT :: forall r m a. (r -> r) -> ReaderT r m a -> ReaderT r m a
localT f (ReaderT m) = ReaderT (m <<< f)

instance Functor m => Functor (ReaderT r m) where
  map f (ReaderT g) = ReaderT (map f <<< g)

instance Apply m => Apply (ReaderT r m) where
  apply (ReaderT f) (ReaderT a) = ReaderT \r -> f r <*> a r

instance Applicative m => Applicative (ReaderT r m) where
  pure a = ReaderT \_ -> pure a

instance Bind m => Bind (ReaderT r m) where
  bind (ReaderT m) f = ReaderT \r -> m r >>= \a -> runReaderT (f a) r

instance Monad m => Monad (ReaderT r m)

Reader is dependency injection reduced to its essence: a function waiting for its environment. The pattern threads configuration through computations without parameter drilling, and the types document which computations depend on which environments.