Functional Programming in C#: The Practical Parts You Will Actually Use
A warm, beginner-friendly guide to functional programming in C#: records, immutability, pattern matching, switch expressions, pure functions, and LINQ.
A tiffin box that nobody can secretly change
Imagine your mother packs a tiffin box for you in the morning. She seals it, and the food inside stays exactly the way she made it. If your friend wants different food, he does not open your box and swap the roti. He brings his own box.
This little rule keeps everyone safe. Nobody's lunch gets spoiled by accident. You always know what is inside your box because nothing changes after it is sealed.
Functional programming is built on that same simple idea. Instead of changing data after you make it, you create a new copy with the change. The old data stays safe and untouched. This sounds small, but it removes a huge number of bugs.
C# is not a pure functional language like Haskell or F#. It is mostly object-oriented. But over many versions it quietly picked up the best practical parts of functional programming. In this post we will learn those parts, one gentle step at a time, using plain C# you can use at work tomorrow.
What functional programming really means
Functional programming has a scary name, but the core ideas are friendly. Here are the three big ones.
- Immutability — once you make some data, you do not change it. You make a new copy.
- Pure functions — a function that only looks at its inputs and returns an output, touching nothing else.
- Expressions over statements — you describe what you want, not a long list of steps to mutate variables.
Let us see how object-oriented thinking and functional thinking differ side by side.
| Idea | Usual OOP style | Functional style |
|---|---|---|
| Data | Objects you change over time | Values you never change |
| Change | Edit the object in place | Make a new copy with the edit |
| Functions | May read and change shared state | Only use inputs, return output |
| Bugs | Hidden changes cause surprises | No hidden changes, fewer surprises |
| Testing | Need setup and mocks | Pass input, check output |
You do not have to pick one side forever. Good C# uses both. The trick is knowing which tool fits the job.
Immutability with records
Before records, making an unchangeable type in C# took a lot of typing. You wrote a class, made every property read-only, wrote a constructor, then wrote Equals, GetHashCode, and ToString by hand. It was boring and easy to get wrong.
C# 9 gave us records. A record is a type built for holding data that does not change. Look how short it is.
public record Student(string Name, int Age, string City);
// Make one
var asha = new Student("Asha", 14, "Pune");
// This prints a friendly line automatically:
// Student { Name = Asha, Age = 14, City = Pune }
Console.WriteLine(asha);That one line gives you a lot for free:
- The properties are read-only by default.
- Two records with the same values are treated as equal.
ToString()prints all the values nicely.- You get the
withexpression for safe copying.
The with expression: changing without changing
Say Asha has a birthday. We do not edit her record. We make a fresh copy with a new age, just like bringing a new tiffin box.
var asha = new Student("Asha", 14, "Pune");
// Asha turns 15. We do NOT touch the old record.
var olderAsha = asha with { Age = 15 };
Console.WriteLine(asha.Age); // 14 (unchanged)
Console.WriteLine(olderAsha.Age); // 15 (the new copy)The original asha is still 14. Nothing happened to it behind your back. This is called non-destructive mutation, which is a fancy way of saying "change by copying."
How a with expression works
Steps
Original record
Asha, Age 14
with { Age = 15 }
Ask for one change
Copy made
Same values, new age
New record returned
Original stays safe
Value equality in plain words
Normal classes compare by reference. Two different objects are "not equal" even if they hold the same data. Records compare by value, which is usually what you actually want.
var a = new Student("Ravi", 13, "Delhi");
var b = new Student("Ravi", 13, "Delhi");
Console.WriteLine(a == b); // True, because the values matchThat is very handy in tests and in caches, where you care about what the data is, not which object holds it.
Pure functions: the safest kind of code
A pure function follows two simple rules:
- Same input always gives the same output.
- It changes nothing outside itself. No saving files, no global variables, no clock, no database.
Here is an impure function and its pure twin.
// Impure: depends on the clock and on a field outside it
private decimal _discount;
public decimal PriceNow(decimal price)
{
if (DateTime.Now.Hour < 12) _discount = 0.1m; // hidden change!
return price - (price * _discount);
}
// Pure: everything it needs comes in as input
public static decimal PriceWith(decimal price, decimal discount)
{
return price - (price * discount);
}The pure version is a joy. To test it you just pass numbers in and check the number that comes out. No fake clock, no setup, no surprises. You can run it on a hundred threads at the same time and it will never get confused, because it never shares anything.
Real programs must do impure things sometimes. They must save data and talk to the network. The trick is to keep the impure parts at the edges and keep the core logic pure. That way most of your code stays easy to test and trust.
Pattern matching: asking about shape
Pattern matching lets you check what an object looks like and pull values out at the same time. It replaces long ladders of if and else with short, readable lines.
Start with the simple is check that also gives you a variable.
object value = "hello";
if (value is string text && text.Length > 3)
{
Console.WriteLine($"A long string: {text}");
}In one line we checked the type, named it text, and used it. No casting, no extra step.
Switch expressions
The switch expression is the star of functional C#. It looks at a value and returns a result based on the first pattern that matches. It is an expression, so it gives back a value you can store.
public static string Grade(int marks) => marks switch
{
>= 90 => "A",
>= 75 => "B",
>= 60 => "C",
>= 40 => "Pass",
_ => "Needs work" // _ means "anything else"
};Read it top to bottom. The first matching line wins. The _ at the end is the catch-all, like the default in an old switch. There is no break, no mutable variable, just a clear mapping from input to output.
You can match on the shape of records too.
public record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
_ => 0
};Here C# checks the type, unpacks the values, and computes the answer, all in a tidy block. Adding a new shape later means adding one new line.
Property patterns and tuples
You can match on properties inside an object, and even on several values at once using a tuple. This is great for rules that depend on more than one thing.
public static string Fare(string day, int age) => (day, age) switch
{
(_, < 5) => "Free",
("Sunday", _) => "Half price",
(_, >= 60) => "Senior discount",
_ => "Full price"
};This reads almost like an English rule sheet. Each line is one clear rule. The compiler even warns you if you forget a case, which catches bugs before they ship.
LINQ is functional programming you already know
Here is a happy secret. If you have ever used LINQ, you have already done functional programming. LINQ methods like Where, Select, and OrderBy take a small function as input and return a new sequence without changing the old one.
var students = new[]
{
new Student("Asha", 14, "Pune"),
new Student("Ravi", 13, "Delhi"),
new Student("Mira", 15, "Pune"),
};
var punaTeens = students
.Where(s => s.City == "Pune") // keep only Pune
.Select(s => s.Name) // take just the name
.OrderBy(name => name) // sort them
.ToList();
// Result: Asha, MiraNotice the style. We describe what we want, not a loop full of temporary lists. Each step takes data in and hands new data out. The original array is never touched. This is the functional pipeline idea, and it is everywhere in real C#.
A LINQ pipeline as a functional flow
Steps
All students
Starting data
Where
Keep Pune only
Select
Pick the name
OrderBy
Sort A to Z
Final list
New list, original safe
Putting the pieces together
These features shine brightest when you combine them. A common functional pattern is to model a result as either a success or a failure, then use pattern matching to handle each case. This avoids throwing exceptions for normal, expected outcomes.
public abstract record Result;
public record Ok(decimal Total) : Result;
public record Error(string Reason) : Result;
public static Result Checkout(decimal price, decimal discount)
{
if (discount < 0 || discount > 1)
return new Error("Discount must be between 0 and 1");
var total = price - (price * discount); // pure calculation
return new Ok(total);
}
// Handle both cases clearly
string message = Checkout(500m, 0.1m) switch
{
Ok ok => $"You pay {ok.Total}",
Error err => $"Sorry: {err.Reason}",
_ => "Unknown result"
};Look at what we used: records for safe data, a pure calculation, and a switch expression to handle every outcome. No hidden state, no surprise exceptions, and the compiler helps make sure we covered each case.
When to use functional style, and when not to
Functional style is a tool, not a religion. Here is a simple guide.
| Use functional style when... | Lean on classic style when... |
|---|---|
| Modeling plain data | Building services with behavior |
| Writing calculations and rules | Working with dependency injection |
| You want easy, safe tests | You need long-lived shared state |
| Transforming collections | Talking to files, network, or UI |
| You want fewer hidden bugs | The team is new to the ideas |
The healthiest C# code mixes both. Keep your data immutable with records. Keep your decisions in pure functions and switch expressions. Push the messy real-world work to the edges. The core of your app stays calm and predictable.
A small note on libraries: some popular .NET packages that people once reached for, like MediatR and MassTransit, have moved to a commercial license for newer versions. You do not need them to write functional C#. The features in this post are built right into the language and the standard library, so they are free and always available.
A quick word on newer C#
The language keeps adding helpful pieces. .NET 10 is the current long-term support release, and C# 14 has shipped with it. Looking ahead, C# 15 is bringing union types in the .NET 11 preview, which will make "this is either an Ok or an Error" even cleaner than the record trick we used above. The ideas stay the same. The tools just keep getting nicer.
Quick recap
- Functional programming is about not changing data after you make it. You make a new copy instead, like bringing a fresh tiffin box.
- Records give you immutable data with value equality, nice printing, and the safe
withcopy expression, all for free. - Pure functions depend only on their inputs and change nothing outside. They are simple to test and safe across threads.
- Pattern matching and switch expressions turn long if-else ladders into short, clear mappings from input to output.
- LINQ is functional programming you likely already use: small functions in, new sequences out, originals untouched.
- Mix functional and object-oriented styles. Keep the core pure, and push files, network, and database work to the edges.
- You do not need extra paid libraries. These features ship with C# and .NET.
References and further reading
- Pattern matching overview — C# (Microsoft Learn)
- Pattern matching using
isandswitchexpressions (Microsoft Learn) - The
switchexpression (Microsoft Learn) - Records — C# reference (Microsoft Learn)
- How To Apply Functional Programming In C# — Milan Jovanović
- Functional C# in Practice: Records, Immutability, and Pipelines — Developers Voice
Related Posts
C# 15 Union Types: The Easy Guide With Real Examples
Union types in C# 15 (.NET 11) let one method safely return one of many shapes. Learn how they work with simple, real-life examples, diagrams, and a clear comparison with OneOf.
C# init-only and required Properties: A Beginner's Guide
Learn C# init-only and required properties with simple analogies, diagrams, and code. Build safe, immutable objects that are filled correctly every time.
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
How to Apply Functional Programming in C#: A Beginner's Guide
Learn functional programming in C# the simple way: pure functions, immutability, records, LINQ, pattern matching, and composition with friendly examples.
How to Write Elegant Code With C# Switch Expressions
Learn C# switch expressions the easy way. Master pattern matching with type, relational, property, and list patterns using simple examples, diagrams, and tables.
C# yield return Statement: A Simple Guide With Real Examples
Learn the C# yield return statement the easy way. Understand iterators, lazy evaluation, and deferred execution with simple examples, diagrams, and tables.