Skip to content

Functional Programming in C# — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Functional Programming in C#. We cover key concepts, practical examples, and best practices to help you master this topic.

Hook

C# is primarily an object-oriented language, but modern C# embraces functional programming concepts. Immutability, pure functions, and compositional patterns lead to code that is easier to reason about, test, and parallelize. Understanding functional programming makes you a more versatile .NET developer.

Learning Path

graph LR
  A[Functional C#] --> B[Immutability]
  A --> C[Higher-Order Functions]
  B --> D[Records]
  B --> E[Immutable Collections]
  C --> F[Function Composition]
  style A fill:#4a90d9,color:#fff
  style B fill:#4a90d9,color:#fff
  style C fill:#4a90d9,color:#fff
  style D fill:#4a90d9,color:#fff
  style E fill:#4a90d9,color:#fff
  style F fill:#4a90d9,color:#fff

Immutability

Immutable objects cannot change after creation, eliminating entire categories of bugs.

public record Person(string Name, int Age, Address Address);

public record Address(string Street, string City, string Country);

// `with` expressions create modified copies
var person = new Person("Alice", 30, new Address("123 Main St", "NYC", "USA"));

var updated = person with
{
    Age = 31,
    Address = person.Address with { City = "Boston" }
};

Console.WriteLine($"Original: {person.Name}, {person.Age}");
Console.WriteLine($"Updated: {updated.Name}, {updated.Age}");
Console.WriteLine($"Same person? {ReferenceEquals(person, updated)}");

Output:

Original: Alice, 30
Updated: Alice, 31
Same person? False

Immutable Collections

Use System.Collections.Immutable for thread-safe, unchangeable collections.

using System.Collections.Immutable;

var builder = ImmutableArray.CreateBuilder<int>();
builder.AddRange(new[] { 1, 2, 3, 4, 5 });
ImmutableArray<int> immutable = builder.ToImmutable();

// All operations return new collections
var added = immutable.Add(6);
var removed = immutable.Remove(3);
var replaced = immutable.SetItem(0, 10);

Console.WriteLine($"Original: {string.Join(", ", immutable)}");
Console.WriteLine($"Added: {string.Join(", ", added)}");
Console.WriteLine($"Removed: {string.Join(", ", removed)}");

// Immutable Dictionary
var dict = ImmutableDictionary<string, int>.Empty
    .Add("one", 1)
    .Add("two", 2);

var updatedDict = dict.SetItem("two", 22);
Console.WriteLine($"Original 'two': {dict["two"]}");
Console.WriteLine($"Updated 'two': {updatedDict["two"]}");

Higher-Order Functions

Functions that take or return other functions.

public static class HigherOrder
{
    // Function that returns a function
    public static Func<int, int> CreateMultiplier(int factor)
    {
        return x => x * factor;
    }

    // Function that takes a function
    public static Func<T, T> Compose<T>(params Func<T, T>[] functions)
    {
        return input =>
        {
            var result = input;
            foreach (var func in functions)
                result = func(result);
            return result;
        };
    }

    // Partial application
    public static Func<T2, TResult> Partial<T1, T2, TResult>(
        Func<T1, T2, TResult> func, T1 arg1)
    {
        return arg2 => func(arg1, arg2);
    }
}

// Usage
var double_ = HigherOrder.CreateMultiplier(2);
var triple = HigherOrder.CreateMultiplier(3);

Console.WriteLine($"Double 5: {double_(5)}");
Console.WriteLine($"Triple 5: {triple(5)}");

var addThenMultiply = HigherOrder.Compose(
    (int x) => x + 5,
    x => x * 2
);
Console.WriteLine($"Add 5 then double 10: {addThenMultiply(10)}");

Option Type (Maybe)

Represent values that may or may not exist, avoiding null.

public abstract record Option<T>
{
    public record Some(T Value) : Option<T>;
    public record None() : Option<T>;
}

public static class OptionExtensions
{
    public static Option<T> ToOption<T>(this T? value) =>
        value is null ? new Option<T>.None() : new Option<T>.Some(value);

    public static Option<TResult> Map<T, TResult>(
        this Option<T> option, Func<T, TResult> map) =>
        option switch
        {
            Option<T>.Some some => new Option<TResult>.Some(map(some.Value)),
            _ => new Option<TResult>.None()
        };

    public static T Reduce<T>(this Option<T> option, T defaultValue) =>
        option is Option<T>.Some some ? some.Value : defaultValue;
}

// Usage
Option<int> Divide(int a, int b) =>
    b == 0 ? new Option<int>.None() : new Option<int>.Some(a / b);

var result = Divide(10, 2)
    .Map(x => x * 3)
    .Map(x => x + 1);

Console.WriteLine($"Result: {result.Reduce(-1)}");

var failed = Divide(10, 0);
Console.WriteLine($"Failed: {failed.Reduce(-1)}");

Railway-Oriented Programming

Chain operations that may fail using a Result type.

public abstract record Result<T>
{
    public record Success(T Value) : Result<T>;
    public record Failure(string Error) : Result<T>;
}

public static class ResultExtensions
{
    public static Result<TResult> Bind<T, TResult>(
        this Result<T> result, Func<T, Result<TResult>> bind) =>
        result switch
        {
            Result<T>.Success s => bind(s.Value),
            Result<T>.Failure f => new Result<TResult>.Failure(f.Error),
            _ => throw new InvalidOperationException()
        };

    public static Result<TResult> Map<T, TResult>(
        this Result<T> result, Func<T, TResult> map) =>
        result is Result<T>.Success s
            ? new Result<TResult>.Success(map(s.Value))
            : new Result<TResult>.Failure(((Result<T>.Failure)result).Error);

    public static T Reduce<T>(this Result<T> result, T defaultValue) =>
        result is Result<T>.Success s ? s.Value : defaultValue;
}

// Usage
Result<int> ParseInt(string s) =>
    int.TryParse(s, out var n)
        ? new Result<int>.Success(n)
        : new Result<int>.Failure($"Cannot parse '{s}'");

Result<int> Divide(int a, int b) =>
    b == 0
        ? new Result<int>.Failure("Division by zero")
        : new Result<int>.Success(a / b);

var pipeline = ParseInt("100")
    .Bind(n => Divide(n, 5))
    .Map(n => n * 2);

Console.WriteLine($"Pipeline result: {pipeline.Reduce(-1)}");

var failedPipeline = ParseInt("abc")
    .Bind(n => Divide(n, 5))
    .Map(n => n * 2);

var failure = (Result<int>.Failure)failedPipeline;
Console.WriteLine($"Pipeline error: {failure.Error}");

LINQ as Functional Programming

LINQ embodies functional programming principles.

var numbers = Enumerable.Range(1, 20);

var result = numbers
    .Where(n => n % 2 == 0)            // Filter
    .Select(n => n * n)                 // Map
    .Aggregate((a, b) => a + b);        // Fold/Reduce

Console.WriteLine($"Sum of squares of evens: {result}");

// Declarative vs imperative
// Imperative
var sum = 0;
for (int i = 1; i <= 20; i++)
    if (i % 2 == 0)
        sum += i * i;

Common Mistakes

  1. Mutating shared state: Avoid modifying objects after they are created. Use with expressions and immutable collections.

  2. Using null instead of Option: Null references are the source of countless bugs. Use Option<T> or nullable reference types.

  3. Side effects in LINQ queries: LINQ should be pure. Avoid writing to console, files, or databases inside Select or Where.

  4. Excessive function composition: Deeply nested function calls are hard to read. Use method chaining or pipe operators.

  5. Ignoring performance of immutable collections: Immutable collections have overhead. Use them where immutability matters, not everywhere.

Practice Questions

  1. Implement a Result<T> type that accumulates multiple error messages (like Validation).

  2. Create a pipe operator extension method that lets you chain functions: value.Pipe(f1).Pipe(f2).

  3. Write a memoization helper that caches the results of pure functions using ConcurrentDictionary.

  4. Challenge: Build a parser combinator library that composes small parsers into complex parsers using functional composition.

FAQ

Is C# a functional programming language?

No, C# is multi-paradigm with strong OOP roots. However, it supports many functional concepts well, especially since C# 9 and the introduction of records.

Should I always use immutable objects?

Immutability is beneficial for data transfer objects, configuration, and shared state. For high-performance scenarios with frequent changes, mutable objects may be more efficient.

What is the difference between Map and Bind?

Map transforms a value inside a container (Option -> Option). Bind (flatMap) transforms and may change the structure (e.g., single value to multiple values).

How do I handle exceptions in functional code?

Use Result types instead of exceptions for expected failures. Exceptions should represent unexpected, unrecoverable errors.

Can I use functional programming in production C#?

Yes. Many production systems use functional patterns (immutability, Option types, railway-oriented programming) alongside OOP. The key is finding the right balance.

Mini Project: Functional Calculator

Build a calculator using railway-oriented programming.

using System;

public abstract record Result<T>
{
    public record Success(T Value) : Result<T>;
    public record Failure(string Error) : Result<T>;
}

public static class Calculator
{
    public static Result<double> Evaluate(string expression)
    {
        return ParseExpression(expression)
            .Bind(Compute);
    }

    private static Result<(double, char, double)> ParseExpression(string expr)
    {
        var parts = expr.Split(' ');
        if (parts.Length != 3)
            return new Result<double>.Failure(
                "Invalid format. Use: number operator number");

        if (!double.TryParse(parts[0], out var a))
            return new Result<double>.Failure($"Cannot parse '{parts[0]}'");

        if (!double.TryParse(parts[2], out var b))
            return new Result<double>.Failure($"Cannot parse '{parts[2]}'");

        if (parts[1] is not "+" and not "-" and not "*" and not "/")
            return new Result<double>.Failure($"Unknown operator '{parts[1]}'");

        return new Result<(double, char, double)>.Success((a, parts[1][0], b));
    }

    private static Result<double> Compute((double a, char op, double b) input)
    {
        return input.op switch
        {
            '+' => Ok(input.a + input.b),
            '-' => Ok(input.a - input.b),
            '*' => Ok(input.a * input.b),
            '/' => input.b == 0
                ? new Result<double>.Failure("Division by zero")
                : Ok(input.a / input.b),
            _ => new Result<double>.Failure("Unknown operator")
        };
    }

    private static Result<double>.Success Ok(double value) =>
        new(value);
}

// Usage
string[] expressions = { "10 + 5", "100 / 0", "42 * 3", "abc + def", "20 / 4" };

foreach (var expr in expressions)
{
    var result = Calculator.Evaluate(expr);
    var output = result switch
    {
        Result<double>.Success s => $"= {s.Value}",
        Result<double>.Failure f => $"Error: {f.Error}",
        _ => "Unknown"
    };
    Console.WriteLine($"{expr} {output}");
}

Output:

10 + 5 = 15
100 / 0 Error: Division by zero
42 * 3 = 126
abc + def Error: Cannot parse 'abc'
20 / 4 = 5

Functional programming in C# is a powerful addition to your coding toolkit. By combining OOP and functional patterns, you build .NET applications that are more robust, testable, and maintainable. The key is knowing when to apply each paradigm.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro