Functional Design Patterns
Option, Result, railway-oriented pipelines, and other functional patterns translated into idiomatic C#
C# is not a "functional-first" language, but every meaningful functional pattern has a clean translation into modern C#. This page shows the patterns experienced functional developers reach for most often, expressed in the idioms a C# reviewer would actually accept.
Pattern 1: Option / Maybe
A null is a runtime time bomb: the type system swears the value
is there, the runtime says otherwise. The Option type makes the
"might-not-be-there"-ness part of the type.
C# has had nullable reference types (string?) since C# 8 and
nullable value types (int?) since C# 2. Combined with the null
operators, these give you most of what an Option<T> provides
elsewhere.
The ?. operator is exactly Option.Map. The ?? operator is
Option.GetValueOrDefault. The compiler will not let you call a
method on a nullable without acknowledging the null case. This is
the functional pattern, in C# clothing.
Pattern 2: Result / Either
Operations can fail. Throwing an exception conflates control flow
with failure handling. A Result<T, TError> makes success and
failure both first-class.
Notice: the failure cases never throw. The chain short-circuits
through Bind, carrying the first error along. This is the heart of
railway-oriented programming.
Pattern 3: Railway-oriented programming
A pipeline of operations can succeed or fail at any step. Picture it as two parallel tracks:
Each step either stays on the success track (continues to the next
step) or switches to the failure track (carries the original error
straight to the end). Bind is the switch operator that performs
this routing.
The benefits are huge:
- No
try/catchclutter. - Errors are values you can inspect and combine.
- Function signatures truthfully advertise failure.
- Easy to test: just feed an
Errin and watch it propagate.
Pattern 4: Currying and partial application
A curried function takes one argument at a time. Partial application fixes some arguments early and returns a function asking for the rest.
This is the trick behind the configurable pipeline helpers in the previous chapter. Instead of passing a separator to every call, you build a specialized helper once and reuse it.
Pattern 5: Pipeline as data — discriminated unions
Modeling states of the world with a discriminated union (a sealed hierarchy of records) replaces brittle bool/enum flags. With C# pattern matching, branching on them is exhaustive and clear.
If you add a new payment kind, the compiler can warn you about
non-exhaustive switch expressions — a small but lovely FP-style
safety net inside a C# program.
Pattern 6: Combinators
A combinator builds a complex thing from simpler ones. We met one in
the previous page (And for predicates). Here's a tiny validation
combinator library.
All is a combinator — it makes a bigger rule out of smaller rules.
Each individual rule stays focused. The library composes naturally
because every rule has the same type signature.
Choosing patterns wisely
These patterns are tools, not laws. In real C# code:
- Use
string?and?.for everyday "might be missing" cases. Don't import an Option library just to wrap two values. - Use
Result<T, E>when failure is expected and frequent (input parsing, validation, external calls). For "this should never happen" cases, exceptions are still appropriate. - Use discriminated unions when you have a closed set of states.
- Use combinators when you're writing a small DSL.
When chosen carefully, these patterns make C# code dramatically more expressive without abandoning the strengths of the OO model.
In railway-oriented programming, what is the central role of Bind (also called FlatMap or SelectMany)?
It runs all steps in parallel and collects every error at once.
It catches exceptions inside each step and converts them to errors.
It chains an operation that returns a Result onto a previous Result, automatically short-circuiting on the first error.
It converts a synchronous result into an async one.
Generic Abstractions
How `IEnumerable<T>` became a universal language for sequences — and how to write your own generic, type-safe pipeline helpers
Side-Effect Management
Functional core, imperative shell — pushing IO to the edges so the heart of your program stays pure, testable, and easy to reason about