MonadReader: Environment Access
"The answer to the Great Question... of Life, the Universe and Everything... is... Forty-two."
-- Douglas Adams, The Hitchhiker's Guide to the Galaxy
The computer Deep Thought spent seven and a half million years computing an answer, but nobody remembered the question. With MonadReader, the question is always available: your environment is there whenever you ask() for it, and every function in the chain sees the same one.
- How
MonadReaderabstracts read-only environment access independently of any transformer stack - Using
ask,reader, andlocalto work with configuration in polymorphic code - What the MonadReader laws mean in plain Java terms
- When environment access is the right capability to reach for
What MonadReader Does
MonadReader<F, R> gives your computation access to a shared, read-only environment of type R. Think of it as a global configuration object that every step can read but nobody can change. The "read-only" part is important: if you need to update the environment between steps, you want MonadState instead.
The interface provides four operations:
┌──────────────────────────────────────────────────────────────┐
│ MonadReader<F, R> (extends Monad<F>) │
│ │
│ ask() → Kind<F, R> │
│ "Give me the whole environment" │
│ │
│ reader(f) → Kind<F, A> where f : R → A │
│ "Extract one field from the environment" │
│ │
│ asks(f) → Kind<F, A> (alias for reader) │
│ │
│ local(f, ma) → Kind<F, A> where f : R → R │
│ "Run ma with a temporarily modified │
│ environment, then restore the original" │
└──────────────────────────────────────────────────────────────┘
ask: Retrieve the Whole Environment
ask() is the simplest operation: it returns the entire environment as a value inside the monad. If your environment is an AppConfig record, ask() gives you the whole AppConfig.
record AppConfig(String dbUrl, int maxRetries, boolean debugMode) {}
<F extends WitnessArity<TypeArity.Unary>> Kind<F, AppConfig>
getConfig(MonadReader<F, AppConfig> env) {
return env.ask(); // Returns the entire AppConfig
}
reader / asks: Extract a Single Field
Usually you don't need the whole environment; you need one field from it. reader(f) applies a function to the environment and returns the result. It is equivalent to map(f, ask()), but reads more clearly:
<F extends WitnessArity<TypeArity.Unary>> Kind<F, String>
connectionString(MonadReader<F, AppConfig> env) {
return env.reader(config ->
config.dbUrl() + "?retries=" + config.maxRetries());
}
asks(f) is an alias for reader(f). Both exist because Haskell uses both names, and different developers find one or the other more natural.
local: Temporarily Modify the Environment
local(f, ma) runs a sub-computation ma with a modified environment. The modification is temporary: other computations in the chain still see the original environment.
This is useful when a sub-system needs slightly different configuration. For example, you might force debug mode on for a diagnostic sub-query while the rest of the pipeline runs in production mode:
<F extends WitnessArity<TypeArity.Unary>> Kind<F, String>
debugQuery(MonadReader<F, AppConfig> env) {
// Run this specific sub-computation with debug mode forced on
return env.local(
config -> new AppConfig(config.dbUrl(), config.maxRetries(), true),
env.reader(config -> "debug=" + config.debugMode()));
}
// Always returns "debug=true", regardless of the original config's debugMode
The original AppConfig is not affected. After local completes, subsequent operations see the unchanged environment.
Environment flow with local():
AppConfig(url, 3, false) ← original
│
├──▶ reader(...) ← sees (url, 3, false)
│
├──▶ local(forceDebug, ...) ← temporarily (url, 3, true)
│ │
│ └──▶ reader(...) ← sees (url, 3, true)
│
└──▶ reader(...) ← sees (url, 3, false) again
A Complete Example
Here is a polymorphic function that builds a database connection string from configuration, with an optional debug suffix:
record AppConfig(String dbUrl, int maxRetries, boolean debugMode) {}
// Works with ANY monad that provides reader capability
<F extends WitnessArity<TypeArity.Unary>> Kind<F, String>
buildConnectionString(MonadReader<F, AppConfig> env) {
return For.from(env, env.ask())
.yield(config -> {
String base = config.dbUrl() + "?retries=" + config.maxRetries();
return config.debugMode() ? base + "&debug=true" : base;
});
}
// Use with ReaderT over Id (for testing)
ReaderTMonadReader<IdKind.Witness, AppConfig> testEnv =
new ReaderTMonadReader<>(idMonad);
Kind<ReaderTKind.Witness<IdKind.Witness, AppConfig>, String> result =
buildConnectionString(testEnv);
// Use with ReaderT over CompletableFuture (for production)
ReaderTMonadReader<CompletableFutureKind.Witness, AppConfig> prodEnv =
new ReaderTMonadReader<>(futureMonad);
Kind<ReaderTKind.Witness<CompletableFutureKind.Witness, AppConfig>, String> asyncResult =
buildConnectionString(prodEnv);
The function buildConnectionString was written once. It runs synchronously in tests (over Id) and asynchronously in production (over CompletableFuture) with no code changes.
The MonadReader Laws
Every MonadReader implementation must satisfy four laws. These are not abstract formalities; they are guarantees about how the environment behaves. If an implementation violates them, code that depends on MonadReader will produce surprising results.
The laws use Haskell-style notation (>> means "sequence, discarding the first result"; >>= means flatMap). Here is what each law means in Java terms:
Law 1: Ask Is Idempotent
ask >> ask ≡ ask
In Java: Reading the environment and throwing away the result, then reading it again, is the same as just reading it once. The environment does not change between reads.
// These produce the same result:
For.from(env, env.ask())
.from(_ -> env.ask())
.yield((_, second) -> second)
env.ask()
This guarantees that the environment is stable. No matter how many times you ask(), you get the same value. There are no hidden side effects that alter the environment between calls.
Law 2: Local-Ask Coherence
local(f, ask()) ≡ map(f, ask())
In Java: Modifying the environment with f and then reading it is the same as reading the original environment and then applying f to the result.
// These produce the same result:
env.local(config -> config.withDebug(true), env.ask())
env.map(config -> config.withDebug(true), env.ask())
This means local does exactly what it claims: it applies a function to the environment before the sub-computation sees it. There are no additional effects.
Law 3: Local Composition
local(f, local(g, ma)) ≡ local(g.compose(f), ma)
In Java: Nesting two local calls is the same as combining their functions into one local. The environment modifications compose.
// These produce the same result:
env.local(addRetries, env.local(enableDebug, computation))
env.local(enableDebug.compose(addRetries), computation)
This means you can refactor nested local calls into a single one without changing behaviour. Environment modifications are just function composition.
Law 4: Local Identity
local(identity, ma) ≡ ma
In Java: Modifying the environment with the identity function (a function that returns its input unchanged) has no effect.
// These produce the same result:
env.local(config -> config, computation)
computation
This is the "do nothing" baseline. If local with identity changed behaviour, something would be fundamentally wrong with the implementation.
The Concrete Instance: ReaderTMonadReader
ReaderTMonadReader<F, R> is the standard implementation of MonadReader for the ReaderT transformer. It extends ReaderTMonad<F, R> (inheriting of, map, flatMap, ap) and adds the environment-access operations:
ReaderTMonadReader<IdKind.Witness, AppConfig> readerInstance =
new ReaderTMonadReader<>(idMonad);
// Now use it as a MonadReader:
Kind<ReaderTKind.Witness<IdKind.Witness, AppConfig>, String> result =
readerInstance.reader(AppConfig::dbUrl);
- Mutating the environment object:
MonadReaderpasses the sameRto every operation. IfRis mutable and you modify it, every subsequentask()sees the mutation. Always use immutable records for your environment type. - Using MonadReader when you need state changes: If the "configuration" changes between steps, you need MonadState. The "Reader" in
MonadReadermeans read-only. - Confusing
localwith persistent modification:localis scoped. After the sub-computation finishes, the original environment is restored. It does not permanently alter anything.
- ReaderT -- The concrete transformer behind
MonadReader - MonadState -- When you need read-write state
- Reader Monad -- The non-transformer version
Previous: MTL Capabilities Next: MonadState