Monadic Structures — Digitteck
Monadic Structures
dotnet·3 March 2024·4 min read

Monadic Structures

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 ErrorCapture object
  • Logic should be able to throw an exception that is easily converted into a known error via the ErrorException type

Error classes:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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.

csharp
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;
        });
    }
}
csharp
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.

csharp
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);

Tags

.NETC#FunctionalMonad
digitteck

© 2026 Digitteck