← Back to DevBytes

Functional Programming in C#

What is Functional Programming?

Functional programming (FP) is a programming paradigm centered on building software by composing pure functions, avoiding shared state, mutable data, and side effects. Instead of describing how a program operates step by step (imperative style), FP emphasizes what computations should be performed by transforming data through a pipeline of functions.

Key concepts include:

C# has steadily embraced functional features since version 3.0. With each release — especially C# 9 and beyond — the language has gained records, init-only properties, pattern matching enhancements, and more, making functional-style code increasingly natural to write.

Why Functional Programming Matters in C#

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Adopting FP principles in C# brings concrete benefits even if you work primarily in object-oriented codebases:

Modern C# applications — especially those using LINQ, reactive extensions, or domain-driven design — already benefit from functional patterns daily. Understanding the underlying principles lets you apply them intentionally rather than accidentally.

Core Functional Techniques in C#

1. Writing Pure Functions

A pure function has two properties: its return value depends solely on its input parameters, and it produces no observable side effects (no I/O, no mutation of external state, no exceptions thrown to alter control flow unpredictably).

// Impure: depends on DateTime.Now and logs to console (side effect)
public decimal CalculateDiscount(Order order)
{
    Console.WriteLine("Calculating discount...");
    if (DateTime.Now.DayOfWeek == DayOfWeek.Monday)
        return order.Total * 0.2m;
    return order.Total * 0.1m;
}

// Pure: takes a 'currentDay' parameter, returns a value, no side effects
public decimal CalculateDiscountPure(Order order, DayOfWeek currentDay)
{
    return currentDay == DayOfWeek.Monday 
        ? order.Total * 0.2m 
        : order.Total * 0.1m;
}

Notice how the pure version becomes trivially testable: you control the currentDay parameter, and no external logging interferes. Purity pushes I/O and randomness to the boundaries of your program — a pattern sometimes called the "functional core, imperative shell."

2. Embracing Immutability with Records

Introduced in C# 9, record types provide compiler-generated value equality and an immutable-by-default design. When you use positional records, all properties are init-only, meaning they can only be set during construction.

// Immutable record — properties are init-only
public record Person(string FirstName, string LastName, DateTime DateOfBirth);

// Usage: create new instances via 'with' expressions
var original = new Person("Jane", "Doe", new DateTime(1990, 5, 15));
var updated  = original with { LastName = "Smith" }; 
// original.LastName is still "Doe" — unchanged

For value types, readonly struct enforces immutability on the stack:

public readonly struct Vector2
{
    public double X { get; }
    public double Y { get; }
    
    public Vector2(double x, double y) => (X, Y) = (x, y);
    
    public Vector2 Scale(double factor) =>
        new Vector2(X * factor, Y * factor);
}

When you need mutable collections but want functional safety, reach for System.Collections.Immutable:

using System.Collections.Immutable;

var list = ImmutableList.Create(1, 2, 3);
var newList = list.Add(4);  // list remains [1,2,3]; newList is [1,2,3,4]

3. Higher-Order Functions and Delegates

C# treats functions as first-class citizens via delegate types like Func<T>, Action<T>, and custom delegate declarations. Higher-order functions either accept functions as parameters or return new functions.

// Takes a transformation function and applies it to a list
public static IEnumerable<TResult> Map<TSource, TResult>(
    IEnumerable<TSource> source,
    Func<TSource, TResult> transform)
{
    foreach (var item in source)
        yield return transform(item);
}

// Usage
var numbers = new[] { 1, 2, 3, 4, 5 };
var squares = Map(numbers, x => x * x);   // [1, 4, 9, 16, 25]

You can also return functions — a technique useful for creating configurable behavior:

// Returns a predicate that filters values above a threshold
public static Func<int, bool> GreaterThan(int threshold)
{
    return value => value > threshold;
}

var aboveTen = GreaterThan(10);
Console.WriteLine(aboveTen(15)); // True
Console.WriteLine(aboveTen(5));  // False

4. LINQ as a Functional Powerhouse

Language Integrated Query (LINQ) is perhaps the most visible functional API in C#. Methods like Select, Where, Aggregate, and SelectMany are higher-order functions that enable declarative data transformations without mutable loops.

var orders = new[]
{
    new { CustomerId = 1, Amount = 250.00m },
    new { CustomerId = 2, Amount = 120.00m },
    new { CustomerId = 1, Amount = 80.00m },
    new { CustomerId = 3, Amount = 310.00m },
};

var highValueByCustomer = orders
    .Where(o => o.Amount > 100m)
    .GroupBy(o => o.CustomerId)
    .Select(g => new 
    { 
        CustomerId = g.Key, 
        TotalSpent = g.Sum(o => o.Amount) 
    })
    .OrderByDescending(r => r.TotalSpent);

// Result: Customer 3 ($310), Customer 1 ($330), Customer 2 ($120)

Each LINQ method is pure (when operating on in-memory collections) and returns a new sequence, leaving the original untouched. Chaining them forms a processing pipeline — a hallmark of functional design.

5. Pattern Matching for Declarative Control Flow

C# pattern matching has evolved significantly. You can now match on types, properties, tuples, and even use relational patterns. This enables clean, expression-based branching without mutable state flags.

public static string DescribeShape(object shape) =>
    shape switch
    {
        Circle { Radius: > 100 } => "Large circle",
        Circle c => $"Circle with radius {c.Radius}",
        Rectangle { Width: var w, Height: var h } when w == h => 
            $"Square of side {w}",
        Rectangle r => $"Rectangle {r.Width} x {r.Height}",
        null => "Nothing",
        _ => "Unknown shape"
    };

// Records used for matching
public record Circle(double Radius);
public record Rectangle(double Width, double Height);

The switch expression is exhaustive (the compiler warns if cases are missed), returns a value directly, and works beautifully with record deconstruction.

6. The Option/Maybe Pattern for Null Safety

Functional languages avoid null by wrapping potentially absent values in an Option or Maybe type. You can implement this pattern in C# to eliminate null-reference exceptions at the type level.

public readonly struct Option<T>
{
    private readonly T _value;
    private readonly bool _hasValue;
    
    private Option(T value, bool hasValue) => 
        (_value, _hasValue) = (value, hasValue);
    
    public static Option<T> Some(T value) => 
        new Option<T>(value, true);
    
    public static Option<T> None => 
        new Option<T>(default!, false);
    
    public TResult Match<TResult>(
        Func<T, TResult> onSome, 
        Func<TResult> onNone) =>
        _hasValue ? onSome(_value) : onNone();
    
    public Option<TResult> Bind<TResult>(Func<T, Option<TResult>> func) =>
        _hasValue ? func(_value) : Option<TResult>.None;
}

// Usage example
public static Option<string> FindEmail(int userId)
{
    // Imagine a lookup; here we simulate a miss
    return Option<string>.None;
}

var result = FindEmail(42).Match(
    onSome: email => $"Found: {email}",
    onNone: () => "No email on file"
);
Console.WriteLine(result); // "No email on file"

The Bind method enables chaining operations that may fail, short-circuiting on the first None — this is the functional equivalent of the "null propagation" operator but made explicit and type-safe.

7. Railway-Oriented Programming with Result Types

Beyond null, functions often need to signal success or failure with richer error context. The Result pattern (also called Either) captures this cleanly, avoiding exceptions for expected failure paths.

public readonly struct Result<T>
{
    private readonly T _value;
    private readonly string _error;
    private readonly bool _isSuccess;
    
    private Result(T value, string error, bool isSuccess) =>
        (_value, _error, _isSuccess) = (value, error, isSuccess);
    
    public static Result<T> Success(T value) =>
        new Result<T>(value, null!, true);
    
    public static Result<T> Failure(string error) =>
        new Result<T>(default!, error, false);
    
    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<string, TResult> onFailure) =>
        _isSuccess ? onSuccess(_value) : onFailure(_error);
}

public static Result<decimal> Withdraw(decimal balance, decimal amount)
{
    if (amount > balance)
        return Result<decimal>.Failure("Insufficient funds");
    if (amount <= 0)
        return Result<decimal>.Failure("Amount must be positive");
    return Result<decimal>.Success(balance - amount);
}

var outcome = Withdraw(100m, 150m).Match(
    onSuccess: newBalance => $"New balance: {newBalance:C}",
    onFailure: error => $"Transaction failed: {error}"
);
Console.WriteLine(outcome); // "Transaction failed: Insufficient funds"

This approach keeps error handling explicit in the type system, making it impossible to accidentally ignore a failure case.

8. Expression-Bodied Members and Local Functions

C# allows methods, properties, and even constructors to be written as single expressions. Combined with local functions, you can define small, focused helpers that capture the functional spirit.

public static class TaxCalculator
{
    // Expression-bodied method
    public static decimal CalculateTax(decimal amount, decimal rate) =>
        amount * rate / 100m;
    
    // Higher-order function returning a configured calculator
    public static Func<decimal, decimal> ForRate(decimal rate) =>
        amount => amount * rate / 100m;
}

// Local function inside a method — pure and testable in isolation
public decimal ComputeTotal(IEnumerable<decimal> prices, decimal taxRate)
{
    decimal addTax(decimal price) => price + TaxCalculator.CalculateTax(price, taxRate);
    
    return prices.Select(addTax).Sum();
}

Best Practices for Functional C#

Practical Example: Building a Functional Pipeline

Below is a realistic example combining several techniques: immutable records, LINQ, pure functions, and a Result type — all working together to process an order pipeline.

// Immutable domain types
public record OrderItem(string Product, int Quantity, decimal Price);
public record Order(int Id, ImmutableList<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.Price);
}

// Pure validation functions
public static class OrderValidation
{
    public static Result<Order> ValidateItems(Order order)
    {
        if (order.Items.IsEmpty)
            return Result<Order>.Failure("Order must have at least one item");
        return Result<Order>.Success(order);
    }
    
    public static Result<Order> ValidateQuantities(Order order)
    {
        var invalid = order.Items.Where(i => i.Quantity <= 0).ToList();
        if (invalid.Any())
            return Result<Order>.Failure($"Invalid quantities for: {string.Join(", ", invalid.Select(i => i.Product))}");
        return Result<Order>.Success(order);
    }
}

// Pure pricing logic
public static class Pricing
{
    public static Order ApplyDiscount(this Order order, decimal discountPercent)
    {
        var discountedItems = order.Items.Select(item =>
            item with { Price = item.Price * (1 - discountPercent / 100m) }
        ).ToImmutableList();
        
        return order with { Items = discountedItems };
    }
}

// Composition function — chains validation and business logic
public static Result<Order> ProcessOrder(Order order, decimal discountPercent)
{
    return OrderValidation.ValidateItems(order)
        .Bind(OrderValidation.ValidateQuantities)
        .Match<Result<Order>>(
            onSuccess: validOrder =>
            {
                var discounted = validOrder.ApplyDiscount(discountPercent);
                return Result<Order>.Success(discounted);
            },
            onFailure: error => Result<Order>.Failure(error)
        );
}

// Usage
var order = new Order(101, ImmutableList.Create(
    new OrderItem("Widget", 2, 50.00m),
    new OrderItem("Gadget", -1, 30.00m)  // invalid quantity
));

var result = ProcessOrder(order, 10).Match(
    onSuccess: o => $"Order {o.Id} processed. Total: {o.Total:C}",
    onFailure: err => $"Failed: {err}"
);

Console.WriteLine(result); // "Failed: Invalid quantities for: Gadget"

This example demonstrates how functional principles lead to code where each piece is independently testable, the flow of data is visible, and error states are handled without exceptions or null checks scattered throughout the codebase.

Conclusion

Functional programming in C# is not an all-or-nothing proposition. The language gives you a rich toolkit — records, pattern matching, LINQ, immutability primitives, and first-class functions — that you can adopt incrementally. Start by extracting pure functions from your existing code, replacing mutable state with records, and modeling failures with simple Result or Option types. Over time, you will notice fewer null-reference exceptions, more confidence during refactoring, and a codebase that expresses business rules clearly. C# continues to close the gap between object-oriented and functional paradigms, making it an excellent language for developers who want the best of both worlds.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles