Intro
The term "monad" often arises in discussions about functional programming concepts and techniques. A monad is a design pattern used to structure code in a functional style while dealing with side effects, asynchronous operations, or computations that may fail.
In C#, the most common example of a monad is the Task<T> type, which represents an asynchronous operation. The Task<T> type allows you to work with asynchronous code in a composable and predictable manner, similar to how monads operate in functional programming languages like Haskell.
The result pattern often involves the use of the Result<T> type or its variations. The Result<T> type represents the outcome of an operation that can either succeed with a value of type T or fail with an error — allowing you to handle both cases in a unified manner, without resorting to exceptions.
Error Types
My approach to designing the result pattern comes with the following principles:
- The functional side of the code should be an extension, not a rule
- The main paradigm should remain OOP — these structures should not be aware of the Result structure
Result<>should store both the value and the error, but the error can have various forms — be as explicit as possible- Unhandled errors should be captured and returned in the
ErrorCaptureobject - Logic should be able to throw an exception that is easily converted into a known error via the
ErrorExceptiontype
Error classes:
public abstract record Error;
public sealed record ErrorCapture : Error
{
public required Exception Exception { get; init; }
}
public class ErrorException : Exception
{
public required Error Error { get; init; }
}
public sealed record ErrorChain : Error
{
public List<Error> Errors { get; } = [];
}Result Types
The abstract non-generic Result type contains the base error class and static helpers for building result objects. The ErrorChain method converts Result<TMethod1> to Result<TMethod2> — propagating errors through a chain without caring about the value's generic type:
public abstract record Result
{
public Error? Error { get; init; }
public static Result<T> Ok<T>(T value)
=> new Result<T> { Value = value };
public static Result<TOut> ErrorChain<TIn, TOut>(Result<TIn> result)
{
if (!result.IsError())
throw new Exception("The provided result is not an error");
Error inError = result.Error!;
ErrorChain outError = new ErrorChain();
if (inError is not ErrorChain inErrorChain)
{
outError.Errors.Add(inError);
}
else
{
outError.Errors.AddRange(inErrorChain.Errors);
}
return new Result<TOut>
{
Error = outError
};
}
public bool IsError() => this.Error is not null;
}The typed Result<T> class stores the actual value:
public record Result<T> : Result
{
private readonly T? _value;
public T Value
{
get
{
if (IsError())
{
throw new Exception("Cannot get a value of an errored result");
}
return _value ?? throw new Exception("The result not initialized");
}
init => _value = value;
}
}The execution logic is carried by MonadExecute — it captures exceptions from the action and unwraps them if they contain a known ErrorException type:
public static class MonadExecute
{
public static Result<TRet> Run<T, TRes, TRet>(this T input, Func<T, TRes> action, Func<T, TRes, TRet> returner)
{
try
{
TRes res = action(input);
return Result.Ok(returner(input, res));
}
catch (Exception e)
{
return new Result<TRet>()
{
Error = new ErrorCapture()
{
Exception = e
}
};
}
}
public static Result<TOut> Chain<TIn, TRes, TOut>(this Result<TIn> input, Func<TIn, TRes> action, Func<TIn, TRes, TOut> returner)
{
if (input.IsError())
return Result.ErrorChain<TIn, TOut>(input);
try
{
TRes res = action(input.Value);
return Result.Ok(returner(input.Value, res));
}
catch (ErrorException ee)
{
return new Result<TOut>()
{
Error = ee.Error
};
}
catch (Exception e)
{
return new Result<TOut>()
{
Error = new ErrorCapture()
{
Exception = e
}
};
}
}
}Business Logic Classes
Imagine we have the following business logic. The key point is that these classes are completely unaware of any Result structure — they throw ErrorException for known domain errors and plain exceptions for unexpected failures:
public record GameErrorDuplicatePlayer(string PlayerName) : Error;
public class GameSettings
{
private readonly string _chatName;
private GameMap? _map;
private readonly List<GamePlayer> _players = new();
public GameSettings(string chatName)
{
_chatName = chatName;
}
public GamePlayer AddPlayer(string playerName)
{
if (_players.Any(x => x.Name == playerName))
{
throw new ErrorException()
{
Error = new GameErrorDuplicatePlayer(playerName)
};
}
var player = new GamePlayer(playerName);
_players.Add(player);
return player;
}
public GamePlayer? GetPlayer(string playerName)
{
return _players.Find(x => x.Name == playerName);
}
public GameMap AddMap(string mapName)
{
if (this._map is not null)
{
throw new Exception("The map was already loaded");
}
_map = new GameMap(mapName);
return _map;
}
}
public class GamePlayer
{
public string Name;
public GamePlayer(string name) { Name = name; }
}
public class GameMap
{
public string Name;
public GameMap(string name) { Name = name; }
}Usage: Explicit
One approach is to define a service layer whose methods explicitly return Result<T> instead of domain types. The monadic structure is explicit — the service layer owns it.
Advantage: the monadic contract is clear and enforced at the service boundary.
Disadvantage: more boilerplate — each business operation needs a corresponding service method wrapper.
public static class GameManager
{
public static Result<GameSettings> CreateGame(string chatName)
{
return Result.Ok(new GameSettings(chatName));
}
public static Result<GameSettings> AddPlayer(this Result<GameSettings> game, string playerName)
{
return MonadExecute.Chain(game, g =>
{
g.AddPlayer(playerName);
return g;
});
}
public static Result<GameSettings> AddMap(this Result<GameSettings> game, string mapName)
{
return MonadExecute.Chain(game, g =>
{
g.AddMap(mapName);
return g;
});
}
}Result<GameSettings> game = GameManager
.CreateGame("combat")
.AddPlayer("john")
.AddPlayer("john") // duplicate — captured as GameErrorDuplicatePlayer
.AddPlayer("doe")
.AddMap("europe");Usage: Extensions
Unlike the previous approach, extension methods convert any operation into a result inline. There's no separate service layer — callers chain directly on business objects using Run and Chain.
Advantage: lighter to implement — no extra service class needed.
Disadvantage: every operation is wrapped in Chain lambdas, which can reduce readability.
Result<GameSettings> game2 = new GameSettings("combat")
.Run(g => g.AddPlayer("john"), (g, _) => g)
.Chain(g => g.AddPlayer("john"), (g, _) => g)
.Chain(g => g.AddMap("europe"), (g, _) => g);