Functional Programming in C# — Complete Guide
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
Mutating shared state: Avoid modifying objects after they are created. Use
withexpressions and immutable collections.Using null instead of Option: Null references are the source of countless bugs. Use
Option<T>or nullable reference types.Side effects in LINQ queries: LINQ should be pure. Avoid writing to console, files, or databases inside
SelectorWhere.Excessive function composition: Deeply nested function calls are hard to read. Use method chaining or pipe operators.
Ignoring performance of immutable collections: Immutable collections have overhead. Use them where immutability matters, not everywhere.
Practice Questions
Implement a
Result<T>type that accumulates multiple error messages (like Validation).Create a pipe operator extension method that lets you chain functions:
value.Pipe(f1).Pipe(f2).Write a memoization helper that caches the results of pure functions using
ConcurrentDictionary.Challenge: Build a parser combinator library that composes small parsers into complex parsers using functional composition.
FAQ
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