Monad transformer
Web applications, layered effects
__ __ ___ _ _ _ ____
| \/ |/ _ \| \ | | / \ | _ \
| |\/| | | | | \| | / _ \ | | | |
| | | | |_| | |\ |/ ___ \| |_| |
|_|__|_|\___/|_| \_/_/_ \_\____/ _____ ___ ____ __ __ _____ ____ ____
|_ _| _ \ / \ | \ | / ___|| ___/ _ \| _ \| \/ | ____| _ \/ ___|
| | | |_) | / _ \ | \| \___ \| |_ | | | | |_) | |\/| | _| | |_) \___ \
| | | _ < / ___ \| |\ |___) | _|| |_| | _ <| | | | |___| _ < ___) |
|_| |_| \_\/_/ \_\_| \_|____/|_| \___/|_| \_\_| |_|_____|_| \_\____/
Seems like monads aren't just mysterious burrito-like containers floating around in code. But just when you thought you could relax with your newfound categorical wisdom, sipping tea while composing Kleisli arrows like a mathematical maestro... Real-world programming isn't satisfied with just one monad at a time. Oh no, that would be too simple! Your applications want to:
- Read configuration files AND handle errors
- Manage state AND perform async operations
- Log everything AND work with nullable values
- Parse JSON AND validate data AND track metrics
Welcome to the world where monads have commitment issues and refuse to work alone! Maybe, Either, IO, State, Reader, Writer, List... They are useful, but they are useless by themselves. Think of it this way: if regular monads are like skilled individual musicians. Sure, a solo violin is beautiful, but sometimes you need violins AND cellos AND trumpets AND timpani all working together in harmony to create something truly magnificent (or in our case, to build a web application that doesn't crash when someone enters "potato" in the age field). We need a symphony orchestra.
But how do we go from "I understand Kleisli categories" to "I can stack monads like a functional programming pancake chef"? How do we compose not just functions, but entire computational contexts? We need to grasp a new concept: compositional effect management. Because it opens up the door to skills like:
- Stack monads like a pro (without them falling over)
- Lift operations between layers (not literally)
- Navigate transformed stacks with grace (and without getting lost)
- Build applications that combine multiple effects naturally
Monad transformers #
Remember from our Kleisli category adventure that each monad creates its own category. It also creates a practical problem: monads don't naturally compose. If you have a function that returns Maybe String and another that returns IO String, you can't easily combine them into something that handles both potential failure and IO effects. Each monad lives in its own categorical world.
-- These don't play nicely together
parseConfig :: FilePath -> Maybe Config
readFile :: FilePath -> IO String
-- How do we combine them into: FilePath -> ??? Config
-- We want both "might fail" AND "has side effects"
Layered Composition #
Monad transformers solve this by creating layered monads - think of them as nested computational contexts where each layer adds a specific effect. Instead of trying to merge monads horizontally (which is impossible), we stack them vertically.
A monad transformer is a type constructor that:
- Takes a monad as a parameter
- Returns a new monad that combines its own effects with the inner monad's effects
- Provides a way to "lift" operations from the inner monad to the combined monad
-- MaybeT is a monad transformer
-- It adds "might fail" effects to any monad
newtype MaybeT m a = MaybeT (m (Maybe a))
-- Now we can combine Maybe with IO:
type MaybeIO a = MaybeT IO a
-- This represents computations that can fail AND perform IO
Russian Dolls #
Think of monad transformers like Russian nesting dolls:
- The outermost doll is your transformer (e.g.,
MaybeT) - The inner doll is the base monad (e.g.,
IO) - Each doll adds its own "personality" (computational effect)
- You can stack as many dolls as you need
┌─────────────────────────────────┐
│ MaybeT (Maybe effects) │
│ ┌───────────────────────────┐ │
│ │ StateT (State effects) │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ IO (Side effects) │ │ │
│ │ │ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Common Transformers #
Each transformer brings specific abilities to the team:
- MaybeT: Adds "might fail" powers (like Maybe, but stackable)
- EitherT/ExceptT: Adds "error handling" powers with custom error types
- StateT: Adds "mutable state" powers
- ReaderT: Adds "shared environment" powers (dependency injection)
- WriterT: Adds "logging/accumulation" powers
- ContT: Adds "continuation" powers (advanced wizardry)
Lifting #
The magic happens with lifting - the ability to take an operation from an inner monad and use it in the combined transformer stack:
-- Base operation in IO
print :: String -> IO ()
-- Lifted to work in MaybeT IO
liftIO . print :: String -> MaybeT IO ()
-- Now you can use IO operations within a "might fail" context
It's like having a universal adapter that lets you use a compatible electrical device in a new outlet - lifting lets you use operations from an inner monad inside the larger transformer stack.
Web Application #
Imagine building a web application with the endpoint:
type AppM = ReaderT Config (StateT AppState (ExceptT ApiError IO))
handleRequest :: Request -> AppM Response
handleRequest req = do
config <- ask -- ReaderT: get configuration
modify incrementRequestCount -- StateT: update application state
result <- liftIO (callDatabase req) -- IO: perform side effect
when (null result) $
throwError NotFound -- ExceptT: handle errors
return (Response result)
This single function combines four different computational effects in a natural, composable way!
The Categorical Perspective #
From a category theory standpoint, monad transformers preserve the categorical structure while adding new capabilities. Each transformer stack still forms a valid monad with:
- A return/pure operation that works across all layers
- A bind operation that correctly threads effects through all layers
- Associativity and identity laws that hold for the entire stack
It's like building a skyscraper where each floor follows the same architectural principles, but the whole building has capabilities no single floor could provide.
Formal definition #
A monad transformer is a type constructor T such that:
- For any monad
M,T Mis also a monad - There exists a natural transformation
lift :: M a -> T M athat preserves monadic structure - The lifting operation satisfies specific laws
In type theory notation:
T :: (* -> *) -> (* -> *) -- Takes a monad, returns a monad
lift :: Monad M => M a -> T M a
The Lifting Laws #
The lift operation must satisfy these laws to ensure the categorical structure remains intact:
- Lift preserves return:
lift . return = return - Lift preserves bind:
lift (m >>= f) = lift m >>= (lift . f) - Lift is a natural transformation:
fmap f . lift = lift . fmap f
These laws ensure that when you lift operations from the inner monad, they behave exactly as expected in the transformer context.
Monad Transformer Instance Structure #
Every monad transformer must provide instances for the fundamental type classes:
instance Monad m => Functor (T m) where
-- fmap for the transformer
instance Monad m => Applicative (T m) where
-- pure and <*> for the transformer
instance Monad m => Monad (T m) where
-- return and >>= for the transformer
instance MonadTrans T where
-- lift :: Monad m => m a -> T m a
Example - MaybeT #
Let's examine MaybeT as a concrete example:
-- Definition
newtype MaybeT m a = MaybeT { runMaybeT :: m (Maybe a) }
-- Functor instance
instance Monad m => Functor (MaybeT m) where
fmap f (MaybeT ma) = MaybeT $ fmap (fmap f) ma
-- Applicative instance
instance Monad m => Applicative (MaybeT m) where
pure a = MaybeT (return (Just a))
MaybeT mf <*> MaybeT ma = MaybeT $ do
maybeF <- mf
case maybeF of
Nothing -> return Nothing
Just f -> do
maybeA <- ma
return (fmap f maybeA)
-- Monad instance
instance Monad m => Monad (MaybeT m) where
return = pure
MaybeT ma >>= f = MaybeT $ do
a' <- ma -- Extract from inner monad
case a' of
Nothing -> return Nothing -- Propagate failure
Just a -> runMaybeT (f a) -- Apply function and continue
-- MonadTrans instance
instance MonadTrans MaybeT where
lift ma = MaybeT (fmap Just ma)
Category Theory Perspective #
From a categorical viewpoint, monad transformers create a hierarchy of categories:
- Base Category: The original category
C - First Kleisli Category:
Kl(M)for the inner monadM - Composed Kleisli Category:
Kl(T M)for the transformer stack
Kl(T M) is the Kleisli category of the transformed monad. The lift operation embeds computations from Kl(M) into Kl(T M), so existing inner-monad computations can be reused in the larger effect stack while preserving the monad laws of the transformed stack.
Transformer Stack Composition #
When multiple transformers are stacked, the formal structure becomes:
type AppStack = MaybeT (StateT AppState (ReaderT Config IO))
This creates a category where morphisms have the type:
A -> MaybeT (StateT AppState (ReaderT Config IO)) B
Each layer in the stack contributes its own effect, and the monad laws ensure that sequencing is well behaved for the chosen order of layers.
The MonadTrans Type Class #
Remember how we introduced functor as a mapping between two categories? Since functors operate on regular categories, monads have their own structure for similar operations.
The MonadTrans type class formalizes the lifting operation:
class MonadTrans t where
lift :: Monad m => m a -> t m a
This is more than just a convenience function. For each inner monad m, lift acts like a monad morphism from m into t m: it preserves return and >>=.
In categorical language, it gives a structure-preserving embedding of the inner Kleisli category into the transformed Kleisli category.
Laws and Coherence Conditions #
Beyond the basic lifting laws, transformer stacks rely on a few practical coherence expectations:
- Associativity of sequencing: Once a stack is fixed,
>>=composes computations associatively. - Layered lifting: Lifting through multiple layers is explicit, for example
lift . liftfor two transformer layers. - Order sensitivity: Transformers do not generally commute. Swapping two layers can change the observable behavior.
These conditions ensure that a chosen stack has consistent semantics. They do not say that every rearrangement of the stack is equivalent.
Formal Relationship to Kleisli Categories #
Each transformer T induces a transformation between Kleisli categories:
F_T : Kl(M) -> Kl(T M)
Where F_T maps:
- Objects:
A -> A(objects remain the same) - Morphisms:
(A -> M B) -> (A -> T M B)via lifting
This creates a systematic way to "upgrade" any computation in the inner monad to work in the transformer context, preserving all the categorical structure.
Working Construction #
Monad transformers satisfy a universal property: for a monad M, the type T M is again a monad, and lift lets computations in M participate in the larger stack without changing their meaning.
The transformer pattern is useful because it gives a lawful, reusable way to add one effect layer to an existing monad.
Examples #
Let's build a simple user management system that needs to:
- Read configuration (ReaderT)
- Handle errors gracefully (ExceptT/EitherT)
- Perform IO operations
- Log operations (WriterT)
This realistic example shows how transformer stacks solve real-world problems.
{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.Except
import Control.Monad.Reader
import Control.Monad.Writer
import Data.Text (Text)
import qualified Data.Text as T
-- Domain types
data User = User
{ userId :: Int,
userName :: Text,
userEmail :: Text
}
deriving (Show, Eq)
data Config = Config
{ dbConnection :: Text,
maxRetries :: Int,
logLevel :: Text
}
deriving (Show)
data AppError
= UserNotFound Int
| DatabaseError Text
| ValidationError Text
deriving (Show, Eq)
type LogEntry = Text
-- Our transformer stack: ReaderT + ExceptT + WriterT + IO
type AppM = ReaderT Config (ExceptT AppError (WriterT [LogEntry] IO))
-- Helper function to run our app
runApp :: Config -> AppM a -> IO (Either AppError a, [LogEntry])
runApp config action =
runWriterT $ runExceptT $ runReaderT action config
-- Core business operations
validateUser :: User -> AppM User
validateUser user = do
tell ["Validating user: " <> userName user]
_ <- ask
if T.length (userName user) < 3
then throwError (ValidationError "Username too short")
else do
tell ["User validation passed"]
return user
saveUser :: User -> AppM User
saveUser user = do
config <- ask
tell ["Saving user to: " <> dbConnection config]
-- Simulate potential database failure
liftIO $ putStrLn $ "Connecting to: " ++ T.unpack (dbConnection config)
if userId user == 999
then throwError (DatabaseError "Database connection failed")
else do
tell ["User saved successfully"]
return user
findUser :: Int -> AppM User
findUser uid = do
tell ["Looking up user with ID: " <> T.pack (show uid)]
-- Simulate database lookup
if uid == 1
then return $ User 1 "alice" "alice@example.com"
else throwError (UserNotFound uid)
-- Complex operation combining multiple effects
processUser :: Int -> Text -> Text -> AppM User
processUser uid name email = do
tell ["Starting user processing"]
_ <- ask
let newUser = User uid name email
-- Chain operations that might fail
validatedUser <- validateUser newUser
savedUser <- saveUser validatedUser
tell ["User processing completed successfully"]
return savedUser
updateExistingUser :: Int -> Text -> AppM User
updateExistingUser uid newName = do
tell ["Updating user: " <> T.pack (show uid)]
-- Find existing user (might fail)
existingUser <- findUser uid
-- Update and save (might fail)
let updatedUser = existingUser {userName = newName}
validatedUser <- validateUser updatedUser
saveUser validatedUser
-- Example usage
main :: IO ()
main = do
let config = Config "postgresql://localhost/mydb" 3 "INFO"
putStrLn "=== Creating new user ==="
(result1, logs1) <- runApp config $ processUser 42 "bob" "bob@example.com"
putStrLn "Logs:"
mapM_ (putStrLn . (" " ++) . T.unpack) logs1
putStrLn $ "Result: " ++ show result1
putStrLn "\n=== Updating existing user ==="
(result2, logs2) <- runApp config $ updateExistingUser 1 "alice_updated"
putStrLn "Logs:"
mapM_ (putStrLn . (" " ++) . T.unpack) logs2
putStrLn $ "Result: " ++ show result2
putStrLn "\n=== Error case ==="
(result3, logs3) <- runApp config $ processUser 999 "baduser" "bad@example.com"
putStrLn "Logs:"
mapM_ (putStrLn . (" " ++) . T.unpack) logs3
putStrLn $ "Result: " ++ show result3
// Domain types
interface User {
id: number;
name: string;
email: string;
}
interface Config {
dbConnection: string;
maxRetries: number;
logLevel: string;
}
type AppError =
| { type: 'UserNotFound'; id: number }
| { type: 'DatabaseError'; message: string }
| { type: 'ValidationError'; message: string };
type LogEntry = string;
// Result type for error handling
interface Result<T, E> {
map<U>(f: (value: T) => U): Result<U, E>;
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E>;
mapError<F>(f: (error: E) => F): Result<T, F>;
}
// Static factory functions
const Result = {
ok<T, E>(value: T): Result<T, E> {
return new Ok(value);
},
error<T, E>(error: E): Result<T, E> {
return new Err(error);
}
};
class Ok<T, E> implements Result<T, E> {
constructor(private value: T) {}
map<U>(f: (value: T) => U): Result<U, E> {
return new Ok(f(this.value));
}
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E> {
return f(this.value);
}
mapError<F>(f: (error: E) => F): Result<T, F> {
return new Ok(this.value);
}
unwrap(): T { return this.value; }
toString(): string { return `Ok(${this.value})`; }
}
class Err<T, E> implements Result<T, E> {
constructor(private error: E) {}
map<U>(f: (value: T) => U): Result<U, E> {
return new Err(this.error);
}
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E> {
return new Err(this.error);
}
mapError<F>(f: (error: E) => F): Result<T, F> {
return new Err(f(this.error));
}
getError(): E { return this.error; }
toString(): string { return `Err(${JSON.stringify(this.error)})`; }
}
// Our monad transformer: ReaderT + ExceptT + WriterT + Promise
class AppM<T> {
constructor(
private computation: (config: Config) => Promise<Result<[T, LogEntry[]], AppError>>
) {}
// Run the computation
async run(config: Config): Promise<Result<[T, LogEntry[]], AppError>> {
return this.computation(config);
}
// Functor: map over the success value
map<U>(f: (value: T) => U): AppM<U> {
return new AppM(async (config) => {
const result = await this.computation(config);
return result.map(([value, logs]) => [f(value), logs]);
});
}
// Monad: flatMap for chaining operations
flatMap<U>(f: (value: T) => AppM<U>): AppM<U> {
return new AppM(async (config) => {
const result = await this.computation(config);
if (result instanceof Err) {
return result as any;
}
const [value, logs1] = (result as Ok<[T, LogEntry[]], AppError>).unwrap();
const nextResult = await f(value).computation(config);
return nextResult.map(([nextValue, logs2]) => [nextValue, [...logs1, ...logs2]]);
});
}
// Static constructors
static pure<T>(value: T): AppM<T> {
return new AppM(async () => Result.ok([value, []]));
}
static ask(): AppM<Config> {
return new AppM(async (config) => Result.ok([config, []]));
}
static tell(message: LogEntry): AppM<void> {
return new AppM(async () => Result.ok([undefined, [message]]));
}
static throwError<T>(error: AppError): AppM<T> {
return new AppM(async () => Result.error(error));
}
static liftIO<T>(operation: () => Promise<T>): AppM<T> {
return new AppM(async () => {
try {
const result = await operation();
return Result.ok([result, []]);
} catch (error) {
return Result.error({ type: 'DatabaseError', message: String(error) });
}
});
}
}
// Business operations
const validateUser = (user: User): AppM<User> => {
return AppM.tell(`Validating user: ${user.name}`)
.flatMap(() => AppM.ask())
.flatMap(config => {
if (user.name.length < 3) {
return AppM.throwError({ type: 'ValidationError', message: 'Username too short' });
} else {
return AppM.tell('User validation passed')
.flatMap(() => AppM.pure(user));
}
});
};
const saveUser = (user: User): AppM<User> => {
return AppM.ask()
.flatMap(config =>
AppM.tell(`Saving user to: ${config.dbConnection}`)
.flatMap(() => AppM.liftIO(async () => {
console.log(`Connecting to: ${config.dbConnection}`);
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 100));
return user;
}))
.flatMap(() => {
if (user.id === 999) {
return AppM.throwError({ type: 'DatabaseError', message: 'Database connection failed' });
} else {
return AppM.tell('User saved successfully')
.flatMap(() => AppM.pure(user));
}
})
);
};
const findUser = (id: number): AppM<User> => {
return AppM.tell(`Looking up user with ID: ${id}`)
.flatMap(() => {
if (id === 1) {
return AppM.pure({ id: 1, name: 'alice', email: 'alice@example.com' });
} else {
return AppM.throwError({ type: 'UserNotFound', id });
}
});
};
const processUser = (id: number, name: string, email: string): AppM<User> => {
return AppM.tell('Starting user processing')
.flatMap(() => {
const newUser: User = { id, name, email };
return validateUser(newUser)
.flatMap(saveUser)
.flatMap(user =>
AppM.tell('User processing completed successfully')
.flatMap(() => AppM.pure(user))
);
});
};
const updateExistingUser = (id: number, newName: string): AppM<User> => {
return AppM.tell(`Updating user: ${id}`)
.flatMap(() => findUser(id))
.flatMap(existingUser => {
const updatedUser = { ...existingUser, name: newName };
return validateUser(updatedUser).flatMap(saveUser);
});
};
// Example usage
async function main() {
const config: Config = {
dbConnection: 'postgresql://localhost/mydb',
maxRetries: 3,
logLevel: 'INFO'
};
console.log('=== Creating new user ===');
const result1 = await processUser(42, 'bob', 'bob@example.com').run(config);
if (result1 instanceof Ok) {
const [user, logs] = result1.unwrap();
console.log('Logs:');
logs.forEach(log => console.log(` ${log}`));
console.log(`Result: ${JSON.stringify(user)}`);
} else {
console.log(`Error: ${result1}`);
}
console.log('\n=== Updating existing user ===');
const result2 = await updateExistingUser(1, 'alice_updated').run(config);
if (result2 instanceof Ok) {
const [user, logs] = result2.unwrap();
console.log('Logs:');
logs.forEach(log => console.log(` ${log}`));
console.log(`Result: ${JSON.stringify(user)}`);
} else {
console.log(`Error: ${result2}`);
}
console.log('\n=== Error case ===');
const result3 = await processUser(999, 'baduser', 'bad@example.com').run(config);
if (result3 instanceof Ok) {
const [user, logs] = result3.unwrap();
console.log('Logs:');
logs.forEach(log => console.log(` ${log}`));
console.log(`Result: ${JSON.stringify(user)}`);
} else {
console.log('Logs from error case:');
console.log(`Error: ${result3}`);
}
}
// Run the example
main().catch(console.error);
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
// Domain types
public record User(int Id, string Name, string Email);
public record Config(string DbConnection, int MaxRetries, string LogLevel);
public abstract record AppError
{
public record UserNotFound(int Id) : AppError;
public record DatabaseError(string Message) : AppError;
public record ValidationError(string Message) : AppError;
}
public record LogEntry(string Message);
// Result type for error handling
public interface IResult<T, E>
{
IResult<U, E> Map<U>(Func<T, U> f);
IResult<U, E> FlatMap<U>(Func<T, IResult<U, E>> f);
IResult<T, F> MapError<F>(Func<E, F> f);
}
// Static factory class for Result
public static class Result
{
public static IResult<T, E> Ok<T, E>(T value) => new Ok<T, E>(value);
public static IResult<T, E> Error<T, E>(E error) => new Error<T, E>(error);
}
public class Ok<T, E> : IResult<T, E>
{
public T Value { get; }
public Ok(T value) { Value = value; }
public IResult<U, E> Map<U>(Func<T, U> f) => new Ok<U, E>(f(Value));
public IResult<U, E> FlatMap<U>(Func<T, IResult<U, E>> f) => f(Value);
public IResult<T, F> MapError<F>(Func<E, F> f) => new Ok<T, F>(Value);
public override string ToString() => $"Ok({Value})";
}
public class Error<T, E> : IResult<T, E>
{
public E ErrorValue { get; }
public Error(E error) { ErrorValue = error; }
public IResult<U, E> Map<U>(Func<T, U> f) => new Error<U, E>(ErrorValue);
public IResult<U, E> FlatMap<U>(Func<T, IResult<U, E>> f) => new Error<U, E>(ErrorValue);
public IResult<T, F> MapError<F>(Func<E, F> f) => new Error<T, F>(f(ErrorValue));
public override string ToString() => $"Error({ErrorValue})";
}
// Our monad transformer: ReaderT + ExceptT + WriterT + Task
public class AppM<T>
{
private readonly Func<Config, Task<IResult<(T Value, List<LogEntry> Logs), AppError>>> computation;
public AppM(Func<Config, Task<IResult<(T, List<LogEntry>), AppError>>> computation)
{
this.computation = computation;
}
// Run the computation
public Task<IResult<(T Value, List<LogEntry> Logs), AppError>> Run(Config config)
{
return computation(config);
}
// Functor: map over the success value
public AppM<U> Map<U>(Func<T, U> f)
{
return new AppM<U>(async config =>
{
var result = await computation(config);
return result.Map(tuple => (f(tuple.Value), tuple.Logs));
});
}
// Monad: flatMap for chaining operations
public AppM<U> FlatMap<U>(Func<T, AppM<U>> f)
{
return new AppM<U>(async config =>
{
var result = await computation(config);
if (result is Error<(T, List<LogEntry>), AppError> error)
return new Error<(U, List<LogEntry>), AppError>(error.ErrorValue);
var (value, logs1) = ((Ok<(T, List<LogEntry>), AppError>)result).Value;
var nextResult = await f(value).computation(config);
return nextResult.Map(tuple => (tuple.Value, logs1.Concat(tuple.Logs).ToList()));
});
}
// Static constructors
public static AppM<T> Pure(T value)
{
return new AppM<T>(_ => Task.FromResult(Result.Ok<(T, List<LogEntry>), AppError>((value, new List<LogEntry>()))));
}
public static AppM<Config> Ask()
{
return new AppM<Config>(config => Task.FromResult(Result.Ok<(Config, List<LogEntry>), AppError>((config, new List<LogEntry>()))));
}
public static AppM<Unit> Tell(LogEntry entry)
{
return new AppM<Unit>(_ => Task.FromResult(Result.Ok<(Unit, List<LogEntry>), AppError>((new Unit(), new List<LogEntry> { entry }))));
}
public static AppM<TResult> ThrowError<TResult>(AppError error)
{
return new AppM<TResult>(_ => Task.FromResult(Result.Error<(TResult, List<LogEntry>), AppError>(error)));
}
public static AppM<TResult> LiftIO<TResult>(Func<Task<TResult>> operation)
{
return new AppM<TResult>(async _ =>
{
try
{
var result = await operation();
return Result.Ok<(TResult, List<LogEntry>), AppError>((result, new List<LogEntry>()));
}
catch (Exception ex)
{
return Result.Error<(TResult, List<LogEntry>), AppError>(new AppError.DatabaseError(ex.Message));
}
});
}
}
public record Unit();
// LINQ support for AppM
public static class AppMExtensions
{
public static AppM<TResult> Select<TSource, TResult>(this AppM<TSource> source, Func<TSource, TResult> selector)
{
return source.Map(selector);
}
public static AppM<TResult> SelectMany<TSource, TResult>(this AppM<TSource> source, Func<TSource, AppM<TResult>> selector)
{
return source.FlatMap(selector);
}
public static AppM<TFinal> SelectMany<TSource, TMiddle, TFinal>(this AppM<TSource> source, Func<TSource, AppM<TMiddle>> selector, Func<TSource, TMiddle, TFinal> resultSelector)
{
return source.FlatMap(t => selector(t).Map(u => resultSelector(t, u)));
}
}
// Business operations
public static class UserOperations
{
public static AppM<User> ValidateUser(User user)
{
return
from _ in AppM<Unit>.Tell(new LogEntry($"Validating user: {user.Name}"))
from config in AppM<Config>.Ask()
from result in user.Name.Length < 3
? AppM<User>.ThrowError<User>(new AppError.ValidationError("Username too short"))
: from __ in AppM<Unit>.Tell(new LogEntry("User validation passed"))
select user
select result;
}
public static AppM<User> SaveUser(User user)
{
return
from config in AppM<Config>.Ask()
from _ in AppM<Unit>.Tell(new LogEntry($"Saving user to: {config.DbConnection}"))
from __ in AppM<Unit>.LiftIO(async () =>
{
Console.WriteLine($"Connecting to: {config.DbConnection}");
await Task.Delay(100); // Simulate async operation
return new Unit();
})
from result in user.Id == 999
? AppM<User>.ThrowError<User>(new AppError.DatabaseError("Database connection failed"))
: from ___ in AppM<Unit>.Tell(new LogEntry("User saved successfully"))
select user
select result;
}
public static AppM<User> FindUser(int id)
{
return
from _ in AppM<Unit>.Tell(new LogEntry($"Looking up user with ID: {id}"))
from user in id == 1
? AppM<User>.Pure(new User(1, "alice", "alice@example.com"))
: AppM<User>.ThrowError<User>(new AppError.UserNotFound(id))
select user;
}
public static AppM<User> ProcessUser(int id, string name, string email)
{
return
from _ in AppM<Unit>.Tell(new LogEntry("Starting user processing"))
from validatedUser in ValidateUser(new User(id, name, email))
from savedUser in SaveUser(validatedUser)
from __ in AppM<Unit>.Tell(new LogEntry("User processing completed successfully"))
select savedUser;
}
public static AppM<User> UpdateExistingUser(int id, string newName)
{
return
from _ in AppM<Unit>.Tell(new LogEntry($"Updating user: {id}"))
from existingUser in FindUser(id)
from updatedUser in ValidateUser(existingUser with { Name = newName })
from savedUser in SaveUser(updatedUser)
select savedUser;
}
}
// Example usage
public class Program
{
public static async Task Main(string[] args)
{
var config = new Config("postgresql://localhost/mydb", 3, "INFO");
Console.WriteLine("=== Creating new user ===");
var result1 = await UserOperations.ProcessUser(42, "bob", "bob@example.com").Run(config);
PrintResult(result1);
Console.WriteLine("\n=== Updating existing user ===");
var result2 = await UserOperations.UpdateExistingUser(1, "alice_updated").Run(config);
PrintResult(result2);
Console.WriteLine("\n=== Error case ===");
var result3 = await UserOperations.ProcessUser(999, "baduser", "bad@example.com").Run(config);
PrintResult(result3);
}
private static void PrintResult<T>(IResult<(T Value, List<LogEntry> Logs), AppError> result)
{
switch (result)
{
case Ok<(T, List<LogEntry>), AppError> ok:
var (value, logs) = ok.Value;
Console.WriteLine("Logs:");
foreach (var log in logs)
Console.WriteLine($" {log.Message}");
Console.WriteLine($"Result: {value}");
break;
case Error<(T, List<LogEntry>), AppError> error:
Console.WriteLine($"Error: {error.ErrorValue}");
break;
}
}
}
-
Layered Effects: Each example demonstrates how multiple computational effects (configuration, errors, logging, IO) compose naturally through transformer stacks.
-
Natural Composition: Operations chain together seamlessly using monadic bind (
>>=,flatMap, LINQ query syntax), hiding the complexity of effect management. -
Error Propagation: Failures at any step automatically propagate through the entire computation without explicit error handling at each step.
-
Effect Separation: Business logic remains clean and focused, while effects are handled by the transformer infrastructure.
-
Type Safety: The type system ensures all effects are properly handled and no effect can be accidentally ignored.
Triangle Monad transformer example #
Scenario: A geometric transformation system that:
- Performs complex triangle transformations (might fail)
- Logs each transformation step
- Maintains a transformation history state
- Handles errors gracefully
Base Types:
Objects: Triangles in the plane
T1: Equilateral triangle T2: Right triangle
△ |\
/A\ | \
/ | \ | \
/B___C\ |___\
T3: Isosceles triangle T4: Scalene triangle
△ △
/|\ /|\
/ | \ / | \
/__|__\ /__|__\
Individual Monads:
Maybe: Transformations that might failWriter [String]: Logging transformation stepsState History: Tracking transformation sequence
Problem: How do we combine all three effects in one operation?
Single Monad Limitations #
Using just Maybe:
T1 --safeRotate--> Maybe(T1)
△ ⟨ △ ⟩
⟨ /|\ ⟩ or Nothing
⟨/___\⟩
Using just Writer:
T1 --loggedScale--> Writer([String], T1)
△ ("Scaling by 2.0", △ )
/|\
/ | \
/__|__\
Using just State:
T1 --statefulRotate--> State(History, T1)
△ (History++[Rotation], △)
/|\
/ | \
/__|__\
But we want all three effects together!
Transformer Stack Solution #
We create a transformer stack: MaybeT (WriterT [String] (State History))
MaybeT Layer: Handle potential failures
┌─────────────────────────────────────┐
│ WriterT Layer: Log operations │
│ ┌───────────────────────────────┐ │
│ │ State Layer: Track history │ │
│ │ │ │
│ │ Base computation │ │
│ │ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Type Signature:
type TransformM = MaybeT (WriterT [String] (State History))
triangleOp :: Triangle -> TransformM Triangle
Transformer Composition in Action #
Let's trace a complex transformation: rotateAndScale:
Step 1: Input triangle
△ (T1: Equilateral)
/A\
/ | \
/B___C\
Step 2: Apply rotation (might fail)
rotateBy90 :: Triangle -> TransformM Triangle
Transformer Stack Processing:
┌─────────────────────────────────────┐
│ MaybeT: Check if rotation valid │
│ ┌───────────────────────────────┐ │
│ │ WriterT: Log "Rotating by 90°"│ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: Add rotation │ │ │
│ │ │ to history │ │ │
│ │ │ │ │ │
│ │ │ Success: rotated │ │ │
│ │ │ △ │ │ │
│ │ │ /|\ │ │ │
│ │ │ / | \ │ │ │
│ │ │ /__|__\ │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Step 3: Apply scaling (might fail)
scaleBy2 :: Triangle -> TransformM Triangle
Transformer Stack Processing:
┌─────────────────────────────────────┐
│ MaybeT: Check if scale valid │
│ ┌───────────────────────────────┐ │
│ │ WriterT: Log "Scaling by 2.0" │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: Add scaling │ │ │
│ │ │ to history │ │ │
│ │ │ │ │ │
│ │ │ Success: scaled │ │ │
│ │ │ △ | | |
| | | /|\ │ │ │
│ │ │ / | \ │ │ │
│ │ │ /__|__\ │ │ │
│ │ │ (2x size) │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Error Propagation Through Layers #
When a transformation fails in the middle of a chain:
Complex operation: rotate -> scale -> reflect
Step 1: Rotate (Success)
┌─────────────────────────────────────┐
│ MaybeT: Some(rotated_triangle) │
│ ┌───────────────────────────────┐ │
│ │ WriterT: ["Rotating by 45°"] │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: [Rotation(45°)] │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Step 2: Scale (Failure - invalid scale factor)
┌─────────────────────────────────────┐
│ MaybeT: Nothing (PROPAGATES UP!) │
│ ┌───────────────────────────────┐ │
│ │ WriterT: ["Rotating by 45°", │ │
│ │ "Scale failed"] │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: [Rotation(45°)] │ │ │
│ │ │ (No scale added) │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Step 3: Reflect (Skipped due to failure)
Final Result: Nothing
But we still have logs and partial state!
Lifting Operations Between Layers #
Each operation needs to work at the right layer:
Low-level operations:
1. State operations (innermost):
addToHistory :: Operation -> State History ()
Direct access to state:
┌─────────────────────────┐
│ State: [operations...] │
└─────────────────────────┘
2. Writer operations (middle):
logOperation :: String -> WriterT [String] (State History) ()
Lifted once through MaybeT:
┌─────────────────────────────────────┐
│ MaybeT: Some(()) │
│ ┌───────────────────────────────┐ │
│ │ WriterT: [log messages...] │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: [operations...] │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
3. Maybe operations (outermost):
validateTriangle :: Triangle -> Maybe Triangle
Introduced at the MaybeT layer, with the inner WriterT/State layer supplying neutral log and state behavior:
┌─────────────────────────────────────┐
│ MaybeT: Some(triangle) or Nothing │
│ ┌───────────────────────────────┐ │
│ │ WriterT: [] │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ State: unchanged │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
4. Natural Composition
All transformer effects compose naturally:
Input Validate Log Rotate
△ -----> Some(△) -----> ["Start"] -----> Some(△) ---->
/|\
/ | \
/__|__\
Log Scale Output
---> ["Start", "Rotation OK"] --> Some(△) -----> Some(△)
/|\ /|\
/ | \ / | \
/__|__\ /__|__\
(rotated) (scale)
If any step fails:
△ -> Some(△) -> ["Start"] -> None -> ["Start", "Rotation fail"] -> None
State history: [Rotation(45)] or [] if rotation failed
Final logs: Full operation trace regardless of success/failure
Visualizing Monad Transformers #
A monad transformer stack is not just a "wrapper around a wrapper" - it's a carefully constructed categorical structure where each layer adds precisely one computational effect:
Basic Monad Structure: Transformer Stack Structure:
M a MaybeT (State String) a
│ ╱│╲
│ ╱ │ ╲
└─── Single effect ╱ │ ╲
Maybe │ State String
╱ │ ╲
╱ │ ╲
Failure │ Stateful
Handling │ Computation
╱│╲
╱ │ ╲
╱ │ ╲
Value │ String
└─────┘
Combined Effect:
Stateful + Nullable
Each transformer layer follows a precise categorical pattern:
1. Inner Monad (Foundation):
State String a
┌──────────────────────┐
│ String → (a, String) │
└──────────────────────┘
Pure stateful computation
2. Add Maybe Layer:
MaybeT (State String) a
┌─────────────────────────┐
│ State String (Maybe a) │
└─────────────────────────┘
┌────────────────────────────┐
│ String → (Maybe a, String) │
└────────────────────────────┘
Stateful + nullable computation
3. Add ReaderT Layer:
ReaderT Config (MaybeT (State String)) a
┌───────────────────────────────────────┐
│ Config → MaybeT (State String) a │
└───────────────────────────────────────┘
┌───────────────────────────────────────┐
│ Config → State String (Maybe a) │
└───────────────────────────────────────┘
┌───────────────────────────────────────┐
│ Config → String → (Maybe a, String) │
└───────────────────────────────────────┘
Configuration + stateful + nullable
Lifting preserves the monadic structure while adding layers:
Original Computation: After Lifting:
State String a MaybeT (State String) a
┌──────────────────────┐ ┌─────────────────────────┐
│ String → (a, String) │ lift -> │ State String (Maybe a) │
└──────────────────────┘ └─────────────────────────┘
The lifting operation:
┌─────────────────┐ ┌─────────────────────────┐
│ m a │ lift -> │ t m a │
└─────────────────┘ └─────────────────────────┘
Inner monad Transformer of inner monad
Lifting preserves:
- Monadic laws
- Computational structure
- Effect semantics
But adds:
- Additional effect layer
- Compositional structure
- Transformer capabilities
Visual lift for State → MaybeT State:
State computation: Lifted computation:
s₀ --------> (a, s₁) s₀ -------> (Just a, s₁)
│ │
│ Original state transition │ Same transition, wrapped in Maybe
│ │
└── Pure stateful └── Stateful + nullable
The "Just" wrapper adds the Maybe layer while preserving
the underlying State computation structure.
Stack Unwrapping Process:
1. Start with transformed computation:
MaybeT (State String) Int
┌─────────────────────────┐
│ State String (Maybe Int)│
└─────────────────────────┘
2. Run the MaybeT layer:
runMaybeT :: MaybeT (State String) Int → State String (Maybe Int)
┌──────────────────────────────┐
│ String → (Maybe Int, String) │
└──────────────────────────────┘
3. Run the State layer:
runState :: State String (Maybe Int) → String → (Maybe Int, String)
┌─────────────────────────┐
│ (Maybe Int, String) │
└─────────────────────────┘
Complete unwrapping:
┌─────────────────────────────────────┐
│ MaybeT (State String) Int │
│ ↓ runMaybeT │
│ State String (Maybe Int) │
│ ↓ runState initialState │
│ (Maybe Int, String) │
└─────────────────────────────────────┘
Monad transformers obey strict categorical laws:
1. Lift Identity Law:
lift ∘ return = return
Original: Lifted:
return a lift (return a)
┌───────┐ ┌─────────────┐
│ a │ ≡ │ return a │ <- Same structure
└───────┘ └─────────────┘
Base Transformed
2. Lift Composition Law:
lift (m >>= f) = lift m >>= (lift ∘ f)
Sequential: Lifted Sequential:
m -----> a -----> f a lift m -----> a -----> lift (f a)
│ │ │ │ │ │
│ bind f │ │ bind (lift . f) │
│ │ │ │ │ │
└────────┼─────────┘ └─────────────┼──────────┘
Result Result
≡ ≡
3. Associativity in Transformer Stack:
(h ∘ g) ∘ f = h ∘ (g ∘ f)
Stack: ReaderT (MaybeT (State s))
Operations compose associatively:
┌──────────────────────────────────────┐
│ Config → s → (Maybe a, s) │
└──────────────────────────────────────┘
↓ (h ∘ g) ∘ f
┌─────────────────────────────────┐
│ Result │
└─────────────────────────────────┘
≡
┌──────────────────────────────────────┐
│ Config → s → (Maybe a, s) │
└──────────────────────────────────────┘
↓ h ∘ (g ∘ f)
┌─────────────────────────────────┐
│ Result │
└─────────────────────────────────┘
Different transformer orders create different computational behaviors:
MaybeT (State s) vs StateT s Maybe:
1. MaybeT (State s) a:
State s (Maybe a)
s₀ ---> (Maybe a, s₁)
│
├─ Success path: s₀ ---> (Just a, s₁)
└─ Failure path: s₀ ---> (Nothing, s₁)
A failing computation can still return a final state, so state changes made before failure can be observed.
2. StateT s Maybe a:
s → Maybe (a, s)
│
├─ Success path: s₀ ---> Just (a, s₁)
└─ Failure path: s₀ ---> Nothing
A failing computation returns no final state, so state changes made before failure are discarded.
Visual Comparison:
┌─────────────────────────────────────┐
│ MaybeT (State s): │
│ ┌─────┐ ┌────────────┐ │
│ │ s₀ │--->│(Maybe a,s₁)│ │
│ └─────┘ └────────────┘ │
│ │ │ │
│ │ ├─ Just a, s₁ │
│ │ └─ Nothing, s₁ │
│ │ │
│ └─ Failure still carries state │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ StateT s Maybe: │
│ ┌─────┐ ┌──────────┐ │
│ │ s₀ │--->│Maybe(a,s)│ │
│ └─────┘ └──────────┘ │
│ │ │ │
│ │ ├─ Just (a, s₁) │
│ │ └─ Nothing │
│ │ │
│ └─ Failure discards state │
└─────────────────────────────────────┘
Consider a web application stack:
Web Application Effect Stack:
ExceptT ApiError (ReaderT Config (State AppState)) Result
┌─────────────────────────────────────────┐
│ ExceptT │ ← Error handling
│ ┌────────────────────────────────────┐ │
│ │ ReaderT │ │ ← Configuration
│ │ ┌───────────────────────────────┐ │ │
│ │ │ State │ │ │ ← Application state
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Result │ │ │ │ ← Business logic
│ │ │ └───────────────────────────┘ │ │ │
│ │ └───────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
Unwrapped Type:
Config → AppState → (Either ApiError Result, AppState)
Effect Flow:
1. Receive configuration (ReaderT)
2. Access current app state (State)
3. Execute business logic
4. Return either a successful result or an API error, along with the final state
Data Flow Visualization:
Config ──────┐
|---> Business Logic ---> (Either ApiError Result, AppState)
AppState ────┘
Error Paths:
┌─ Success: Config × AppState ---> (Right Result, NewAppState)
└─ Failure: Config × AppState ---> (Left ApiError, NewAppState)
Informally, monad transformers form a structured composition:
Monads with Transformer Constructors:
Objects: M₁, M₂, M₃, ... (monads)
Transformers: type constructors T that map a monad M to a new monad T M
Base Monads: Transformed Monads:
Maybe --T--> MaybeT M
State s --T--> StateT s M
Reader r --T--> ReaderT r M
Except e --T--> ExceptT e M
Composition Structure:
M ---T₁---> T₁ M ---T₂---> T₂(T₁ M) ---T₃---> T₃(T₂(T₁ M))
Identity ---StateT s---> StateT s Identity ≅ State s
---MaybeT-----> MaybeT (StateT s Identity)
---ReaderT r--> ReaderT r (MaybeT (StateT s Identity))
Conclusion #
Monad transformers provide a principled way to compose effects while maintaining properties that make monadic programming reliable and composable.
Instead of ad-hoc effect combination, transformers give us:
- Structured composition: Each layer adds exactly one effect
- Predictable behavior: Category laws ensure correctness
- Composable abstractions: lift operations preserve structure
- Clear semantics: The type signature reveals the computational story
When you write ReaderT Config (MaybeT (State Int)) Result, you're not just nesting types - you're constructing a categorical object that precisely captures your computational requirements while maintaining the principles of reliable composition.
Source code #
Reference implementation (opens in a new tab)