Dataslope logoDataslope

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.

Code Block
C# 13

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.

Code Block
C# 13

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/catch clutter.
  • Errors are values you can inspect and combine.
  • Function signatures truthfully advertise failure.
  • Easy to test: just feed an Err in 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.

Code Block
C# 13

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.

Code Block
C# 13

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.

Challenge
C# 13

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.

QuestionSelect one

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.

On this page