Skip to main content
SEMastery
.NET Corebeginner

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.

12 min readUpdated May 17, 2026

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.

  1. Immutability — once you make some data, you do not change it. You make a new copy.
  2. Pure functions — a function that only looks at its inputs and returns an output, touching nothing else.
  3. 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.

IdeaUsual OOP styleFunctional style
DataObjects you change over timeValues you never change
ChangeEdit the object in placeMake a new copy with the edit
FunctionsMay read and change shared stateOnly use inputs, return output
BugsHidden changes cause surprisesNo hidden changes, fewer surprises
TestingNeed setup and mocksPass 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.

The mental shift: stop editing data, start making fresh copies

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 with expression 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

Original record
with { Age = 15 }
Copy made
New record returned

Steps

1

Original record

Asha, Age 14

2

with { Age = 15 }

Ask for one change

3

Copy made

Same values, new age

4

New record returned

Original stays safe

The original record is never touched; a copy carries the change

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 match

That 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:

  1. Same input always gives the same output.
  2. 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.

A pure function is a clean box: same input in, same output out, nothing leaks

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.

A switch expression checks each pattern from top to bottom and returns the first match

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, Mira

Notice 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

All students
Where city = Pune
Select name
OrderBy
Final list

Steps

1

All students

Starting data

2

Where

Keep Pune only

3

Select

Pick the name

4

OrderBy

Sort A to Z

5

Final list

New list, original safe

Data flows through small pure steps and a fresh result comes out

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 dataBuilding services with behavior
Writing calculations and rulesWorking with dependency injection
You want easy, safe testsYou need long-lived shared state
Transforming collectionsTalking to files, network, or UI
You want fewer hidden bugsThe 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 with copy 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

Related Posts