|
| 1 | +# Reader Monad |
| 2 | + |
| 3 | +The Reader monad is a powerful functional programming pattern for handling dependencies and configuration. It provides a clean way to access shared environment or configuration data throughout a computation without explicitly passing it around. |
| 4 | + |
| 5 | +## Core Concept |
| 6 | + |
| 7 | +A Reader monad is essentially a function that: |
| 8 | + |
| 9 | +1. Takes an environment/configuration as input |
| 10 | +2. Performs a computation using that environment |
| 11 | +3. Returns a result |
| 12 | + |
| 13 | +The magic happens when you compose multiple Readers together - each Reader in the chain has access to the same environment, making dependency injection simple and pure. |
| 14 | + |
| 15 | +## Key Benefits |
| 16 | + |
| 17 | +- **Dependency Injection**: Pass dependencies around implicitly without global state |
| 18 | +- **Testability**: Easily swap environments for testing |
| 19 | +- **Composability**: Combine small, focused Readers into complex operations |
| 20 | +- **Type Safety**: Environment requirements are encoded in the type system |
| 21 | +- **Pure Functional**: No side effects or hidden dependencies |
| 22 | +- **Lazy Evaluation**: Operations are only run when the final Reader is executed |
| 23 | + |
| 24 | +## Basic Usage |
| 25 | + |
| 26 | +```typescript |
| 27 | +import { reader, asks } from 'typescript-monads' |
| 28 | + |
| 29 | +// Create a Reader that extracts a value from the environment |
| 30 | +const getApiUrl = asks<AppConfig, string>(config => config.apiUrl) |
| 31 | + |
| 32 | +// Create a Reader that uses the environment to format a URL |
| 33 | +const getFullUrl = reader<AppConfig, string>(config => |
| 34 | + `${config.apiUrl}/users?token=${config.authToken}` |
| 35 | +) |
| 36 | + |
| 37 | +// Run the Reader with a configuration |
| 38 | +const url = getFullUrl.run({ |
| 39 | + apiUrl: 'https://api.example.com', |
| 40 | + authToken: '12345' |
| 41 | +}) // "https://api.example.com/users?token=12345" |
| 42 | +``` |
| 43 | + |
| 44 | +## Core Operations |
| 45 | + |
| 46 | +### Creation |
| 47 | + |
| 48 | +```typescript |
| 49 | +// Create from a function that uses the environment |
| 50 | +const greeting = reader<{name: string}, string>(env => `Hello, ${env.name}!`) |
| 51 | + |
| 52 | +// Create a Reader that always returns a constant value (ignores environment) |
| 53 | +const constant = readerOf<any, number>(42) |
| 54 | + |
| 55 | +// Create a Reader that returns the entire environment |
| 56 | +const getEnv = ask<AppConfig>() |
| 57 | + |
| 58 | +// Create a Reader that extracts a specific value from the environment |
| 59 | +const getTimeout = asks<AppConfig, number>(config => config.timeout) |
| 60 | +``` |
| 61 | + |
| 62 | +### Transformation |
| 63 | + |
| 64 | +```typescript |
| 65 | +// Map the output value |
| 66 | +const getApiUrl = asks<Config, string>(c => c.apiUrl) |
| 67 | +const getApiUrlUpper = getApiUrl.map(url => url.toUpperCase()) |
| 68 | + |
| 69 | +// Chain Readers |
| 70 | +const getUser = asks<Config, User>(c => c.currentUser) |
| 71 | +const getPermissions = (user: User) => |
| 72 | + asks<Config, string[]>(c => c.permissionsDb.getPermissionsFor(user.id)) |
| 73 | + |
| 74 | +// Combined operation: get user and their permissions |
| 75 | +const getUserPermissions = getUser.flatMap(getPermissions) |
| 76 | +``` |
| 77 | + |
| 78 | +### Environment Manipulation |
| 79 | + |
| 80 | +```typescript |
| 81 | +// Create a Reader that works with a specific config type |
| 82 | +const getDatabaseUrl = reader<DbConfig, string>(db => |
| 83 | + `postgres://${db.host}:${db.port}/${db.name}` |
| 84 | +) |
| 85 | + |
| 86 | +// Adapt it to work with a different environment type |
| 87 | +const getDbFromAppConfig = getDatabaseUrl.local<AppConfig>(app => app.database) |
| 88 | + |
| 89 | +// Now it works with AppConfig |
| 90 | +const url = getDbFromAppConfig.run({ |
| 91 | + database: { host: 'localhost', port: 5432, name: 'myapp' }, |
| 92 | + // other app config... |
| 93 | +}) |
| 94 | +``` |
| 95 | + |
| 96 | +### Combining Readers |
| 97 | + |
| 98 | +```typescript |
| 99 | +// Combine multiple Readers into an array of results |
| 100 | +const getName = asks<User, string>(u => u.name) |
| 101 | +const getAge = asks<User, number>(u => u.age) |
| 102 | +const getEmail = asks<User, string>(u => u.email) |
| 103 | + |
| 104 | +const getUserInfo = sequence([getName, getAge, getEmail]) |
| 105 | +// getUserInfo.run(user) returns [name, age, email] |
| 106 | + |
| 107 | +// Combine multiple Readers with a mapping function |
| 108 | +const getUserSummary = combine( |
| 109 | + [getName, getAge, getEmail], |
| 110 | + (name, age, email) => `${name} (${age}) - ${email}` |
| 111 | +) |
| 112 | +// getUserSummary.run(user) returns "Alice (30) - alice@example.com" |
| 113 | + |
| 114 | +// Combine two Readers with a binary function |
| 115 | +const greeting = asks<Config, string>(c => c.greeting) |
| 116 | +const username = asks<Config, string>(c => c.username) |
| 117 | + |
| 118 | +const personalizedGreeting = greeting.zipWith( |
| 119 | + username, |
| 120 | + (greet, name) => `${greet}, ${name}!` |
| 121 | +) |
| 122 | +// personalizedGreeting.run({greeting: "Hello", username: "Bob"}) returns "Hello, Bob!" |
| 123 | +``` |
| 124 | + |
| 125 | +## Advanced Features |
| 126 | + |
| 127 | +### Side Effects |
| 128 | + |
| 129 | +```typescript |
| 130 | +// Execute a side effect without changing the Reader value |
| 131 | +const loggedApiUrl = getApiUrl.tap(url => console.log(`Using API URL: ${url}`)) |
| 132 | + |
| 133 | +// Chain Readers for sequencing operations |
| 134 | +const logAndGetUser = loggerReader.andThen(getUserReader) |
| 135 | +``` |
| 136 | + |
| 137 | +### Environment-Aware Transformations |
| 138 | + |
| 139 | +```typescript |
| 140 | +// Transform using both environment and current value |
| 141 | +const getTemplate = asks<MessageConfig, string>(c => c.template) |
| 142 | +const getMessage = getTemplate.withEnv( |
| 143 | + (config, template) => template.replace('{user}', config.currentUser) |
| 144 | +) |
| 145 | +``` |
| 146 | + |
| 147 | +### Filtering and Multiple Transformations |
| 148 | + |
| 149 | +```typescript |
| 150 | +// Filter values based on a predicate |
| 151 | +const getAge = asks<Person, number>(p => p.age) |
| 152 | +const getValidAge = getAge.filter( |
| 153 | + age => age >= 0 && age <= 120, |
| 154 | + 0 // Default for invalid ages |
| 155 | +) |
| 156 | + |
| 157 | +// Apply multiple transformations to the same value |
| 158 | +const getUserStats = getUser.fanout( |
| 159 | + user => user.loginCount, |
| 160 | + user => user.lastActive, |
| 161 | + user => user.preferences.theme |
| 162 | +) |
| 163 | +// Returns [loginCount, lastActive, theme] |
| 164 | +``` |
| 165 | + |
| 166 | +### Async Integration and Performance |
| 167 | + |
| 168 | +```typescript |
| 169 | +// Convert a Reader to a Promise-returning function |
| 170 | +const processConfig = asks<Config, Result>(c => computeResult(c)) |
| 171 | +const processAsync = processConfig.toPromise() |
| 172 | + |
| 173 | +// Later in async code |
| 174 | +const result = await processAsync(myConfig) |
| 175 | + |
| 176 | +// Cache expensive Reader operations |
| 177 | +const expensiveReader = reader<Config, Result>(c => expensiveComputation(c)).memoize() |
| 178 | +``` |
| 179 | + |
| 180 | +## Real-World Examples |
| 181 | + |
| 182 | +### Dependency Injection |
| 183 | + |
| 184 | +```typescript |
| 185 | +// Define dependencies interface |
| 186 | +interface AppDependencies { |
| 187 | + logger: Logger |
| 188 | + database: Database |
| 189 | + apiClient: ApiClient |
| 190 | +} |
| 191 | + |
| 192 | +// Create Readers for each dependency |
| 193 | +const getLogger = asks<AppDependencies, Logger>(deps => deps.logger) |
| 194 | +const getDb = asks<AppDependencies, Database>(deps => deps.database) |
| 195 | +const getApiClient = asks<AppDependencies, ApiClient>(deps => deps.apiClient) |
| 196 | + |
| 197 | +// Create business logic using dependencies |
| 198 | +const getUserById = (id: string) => combine( |
| 199 | + [getDb, getLogger], |
| 200 | + (db, logger) => { |
| 201 | + logger.info(`Fetching user ${id}`) |
| 202 | + return db.users.findById(id) |
| 203 | + } |
| 204 | +) |
| 205 | + |
| 206 | +// Configure the real dependencies |
| 207 | +const dependencies: AppDependencies = { |
| 208 | + logger: new ConsoleLogger(), |
| 209 | + database: new PostgresDatabase(dbConfig), |
| 210 | + apiClient: new HttpApiClient(apiConfig) |
| 211 | +} |
| 212 | + |
| 213 | +// Run the Reader with the dependencies |
| 214 | +const user = getUserById('123').run(dependencies) |
| 215 | +``` |
| 216 | + |
| 217 | +### Configuration Management |
| 218 | + |
| 219 | +```typescript |
| 220 | +// Different sections of configuration |
| 221 | +const getDbConfig = asks<AppConfig, DbConfig>(c => c.database) |
| 222 | +const getApiConfig = asks<AppConfig, ApiConfig>(c => c.api) |
| 223 | +const getEnvironment = asks<AppConfig, string>(c => c.environment) |
| 224 | + |
| 225 | +// Create environment-specific database URL |
| 226 | +const getDatabaseUrl = combine( |
| 227 | + [getDbConfig, getEnvironment], |
| 228 | + (db, env) => { |
| 229 | + const { host, port, name, user, password } = db |
| 230 | + const dbName = env === 'test' ? `${name}_test` : name |
| 231 | + return `postgres://${user}:${password}@${host}:${port}/${dbName}` |
| 232 | + } |
| 233 | +) |
| 234 | +``` |
| 235 | + |
| 236 | +## Benefits Over Direct Approaches |
| 237 | + |
| 238 | +| Problem | Traditional Approach | Reader Monad Solution | |
| 239 | +|---------|---------------------|------------------------| |
| 240 | +| Dependency Injection | Constructor injection, service locators | Implicit dependencies via the environment | |
| 241 | +| Configuration | Passing config objects, globals | Environment access in pure functions | |
| 242 | +| Testing | Mocking, dependency overrides | Simply passing different environments | |
| 243 | +| Composition | Complex chaining with explicit parameters | Clean composition with flatMap and combine | |
| 244 | +| Reuse | Duplicating config parameters | Single environment shared by multiple Readers | |
0 commit comments