C# & .NET (C# 14, .NET 10 LTS) Module 12 – Delegates, lambdas, events and functional style
Functional techniques in C#
Write pure functions over immutable records, join them into pipelines with delegates and C# 14 extension members, and return results instead of throwing.
What you will learn
- Write pure functions, and change immutable records and lists with with and SetItem
- Compose functions into pipelines with delegates, partial application and an extension member
- Model expected failures with a Result type, and chain or collect them with Bind and Combine
- Separate a pure core from a program's side effects, and judge how far to take functional style in C#
Before you start
What you’ll learn · Before you start
What you will learn
- Write pure functions, and change immutable records and lists with with and SetItem
- Compose functions into pipelines with delegates, partial application and an extension member
- Model expected failures with a Result type, and chain or collect them with Bind and Combine
- Separate a pure core from a program's side effects, and judge how far to take functional style in C#
Before you start
On this page
A checkout priced the same cart twice and charged Rs 679, then Rs 656.1. Its function had changed the cart it was given and remembered a discount in a static field, so the second call saw a different world. Functional techniques remove that kind of bug: functions that only read their input, data that never changes, and small functions joined into bigger ones.
You will make a function pure, change immutable data by making new values, and join pricing rules into one function that you can read from left to right.
A pure function returns the same result for the same input and changes nothing
Microsoft’s guide to pure functions gives them two properties: no side effects, so nothing outside the function
changes, and consistency, so the same input always gives the same output. purity.cs prices one cart with an impure
function and with a pure one:
// The same checkout total worked out twice. The impure version changes the lines it is given and a static flag, so
// the same call gives another answer the second time; the pure one only reads its input. The prices, the 10 % offer
// and the Rs 50 first-order discount are made up for this lesson.
using System.Collections.Immutable;
var mutable = new List<MutableLine> { new("Tea 250 g", 2, 180m), new("Rice 5 kg", 1, 450m) };
Console.WriteLine($"impure: Rs {Impure.Total(mutable)}, then Rs {Impure.Total(mutable)} for the same cart");
Console.WriteLine($" the cart's tea now costs Rs {mutable[0].Price} a pack");
ImmutableList<Line> cart = [new("Tea 250 g", 2, 180m), new("Rice 5 kg", 1, 450m)];
Console.WriteLine($"pure: Rs {Pure.Total(cart, firstOrder: true)}, then Rs {Pure.Total(cart, firstOrder: true)} for the same cart");
// A change is a new value: with makes a new line, SetItem a new list. The old cart is as it was.
ImmutableList<Line> moreTea = cart.SetItem(0, cart[0] with { Quantity = 3 });
Console.WriteLine($" 3 packs of tea: Rs {Pure.Total(moreTea, firstOrder: true)}; the old cart still has {cart[0].Quantity} and costs Rs {Pure.Total(cart, firstOrder: true)}");
// An expression, not a statement: a switch expression picks the GST rate, and a throw expression rejects the rest.
Console.WriteLine($"GST on essentials: {Pure.GstPercent("essentials")} %, on standard goods: {Pure.GstPercent("standard")} %");
record Line(string Name, int Quantity, decimal Price);
class MutableLine(string name, int quantity, decimal price)
{
public string Name { get; } = name;
public int Quantity { get; } = quantity;
public decimal Price { get; set; } = price;
}
static class Impure
{
private static bool s_firstOrderUsed;
// Takes 10 % off by changing each line's price, and gives Rs 50 off the first order it ever sees.
public static decimal Total(List<MutableLine> lines)
{
foreach (MutableLine line in lines)
{
line.Price = line.Price * 90 / 100;
}
decimal total = lines.Sum(line => line.Quantity * line.Price);
if (!s_firstOrderUsed)
{
s_firstOrderUsed = true;
total -= 50m;
}
return total;
}
}
static class Pure
{
// Reads its arguments, changes nothing, and returns the same total for the same arguments.
public static decimal Total(IReadOnlyList<Line> lines, bool firstOrder)
{
decimal subtotal = lines.Sum(line => line.Quantity * line.Price);
decimal afterOffer = subtotal * 90 / 100;
return firstOrder ? afterOffer - 50m : afterOffer;
}
public static decimal GstPercent(string category) => category switch
{
"essentials" => 5m,
"standard" => 18m,
_ => throw new ArgumentOutOfRangeException(nameof(category), category, "no GST rate for this category"),
};
} Output
impure: Rs 679, then Rs 656.1 for the same cart the cart's tea now costs Rs 145.8 a pack pure: Rs 679, then Rs 679 for the same cart 3 packs of tea: Rs 841; the old cart still has 2 and costs Rs 679 GST on essentials: 5 %, on standard goods: 18 %
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run purity.cs
Impure.Total took 10 % off by changing each line’s price, and gave Rs 50 off the first order by setting a static
flag. Both are side effect: A change a function makes outside itself while it runs: writing a field, changing an argument, printing, saving a file. A function with side effects can give another result the next time it is called., and both showed: the second call found the tea at Rs 162,
took 10 % off again and gave no first-order discount, Rs 656.1. Step through the two calls:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first call reads the cart. The cart holds 2 packs of tea at Rs 180 and a bag of rice at Rs 450: Rs 810 in all. The static flag says no first order has been seen yet.
- It changes the prices. To take 10 % off, the function writes the new price into each line: tea Rs 162, rice Rs 405. The cart the caller passed in has changed.
- It sets the flag. This is the first order, so it takes Rs 50 off and sets the flag to true, for every call that comes after.
- The first total. 2 × 162 + 405 = 729, minus 50: the first call returned Rs 679.
- The second call, another cart. The same call, with the same cart variable, now reads tea at Rs 162 and takes 10 % off again: Rs 145.8 and Rs 364.5.
- The flag says no discount. The flag is still true, so there is no Rs 50 this time: 2 × 145.8 + 364.5 = Rs 656.1. Same call, same variable, another answer.
A pure function: A function that has no side effects and always returns the same result for the same arguments, because it depends on nothing but them: no clock, no shared field, no input it reads by itself. gets everything it needs as arguments, firstOrder included, and
returned Rs 679 both times. Pure functions are easy to test, since a test needs only the arguments, and safe to run
in parallel, since nothing is shared. Programs still read clocks and write files; a common design keeps those side
effects in a thin shell around a pure core.
Immutable data makes purity the easy path. The cart is an ImmutableList<Line> of records: cart[0] with { Quantity = 3 }
made a new line, and SetItem a new list, priced at Rs 841, while the old cart still had 2 packs and cost Rs 679.
ImmutableArray<T> wraps one plain array, which every change copies, and a builder takes many changes in place before
it freezes them into a new immutable collection. Expressions help too: GstPercent is one switch expression that
returns a rate or throws, with nothing assigned along the way. The two versions of Total, side by side:
| Behaviour | Impure.Total |
Pure.Total |
|---|---|---|
| reads | the lines and a static flag | only its arguments |
| changes | every line’s price, the flag | nothing |
| the same call twice | Rs 679, then Rs 656.1 | Rs 679, then Rs 679 |
Checkpoint
Check: Pure functions and immutable data
Functions are values: pass them, return them, join them
A higher-order function: A function that takes another function as an argument or returns one, such as a method with a Func parameter, or a method that returns a lambda. takes or returns another function, and compose.cs
uses both kinds, then joins functions with a C# 14 extension member:
// Functions as values: a method that takes a function, methods that return functions, and a C# 14 extension member,
// Then, that joins two functions into one. The prices and rates are made up for this lesson.
List<Line> cart = [new("Tea 250 g", 2, 180m), new("Rice 5 kg", 1, 450m)];
// A function that takes a function: Apply runs any rule on every line.
Console.WriteLine($"each line at 10 % off: {string.Join(", ", Apply(cart, PercentOff(10)))}");
// Functions that return functions: PercentOff(10) and AddGst(5) fix one argument now and wait for the amount.
Func<decimal, decimal> festival = PercentOff(10);
Func<decimal, decimal> gst = AddGst(5);
Console.WriteLine($"festival(810) = {festival(810m)}, gst(729) = {gst(729m)}");
// Then joins functions: each step's result is the next step's input, read left to right.
Func<IReadOnlyList<Line>, decimal> subtotal = Subtotal;
Func<IReadOnlyList<Line>, decimal> checkout = subtotal.Then(festival).Then(gst).Then(RoundToRupee);
Console.WriteLine($"checkout(cart) = Rs {checkout(cart)}");
// The same steps in another order are another function, with another result.
Func<IReadOnlyList<Line>, decimal> roundFirst = subtotal.Then(RoundToRupee).Then(gst).Then(festival);
Console.WriteLine($"rounding first = Rs {roundFirst(cart)}");
static IEnumerable<decimal> Apply(IEnumerable<Line> lines, Func<decimal, decimal> rule) =>
lines.Select(line => rule(line.Quantity * line.Price));
static decimal Subtotal(IReadOnlyList<Line> lines) => lines.Sum(line => line.Quantity * line.Price);
static Func<decimal, decimal> PercentOff(int percent) => amount => amount * (100 - percent) / 100;
static Func<decimal, decimal> AddGst(int percent) => amount => amount + amount * percent / 100;
static decimal RoundToRupee(decimal amount) => Math.Round(amount, 0, MidpointRounding.AwayFromZero);
record Line(string Name, int Quantity, decimal Price);
static class FunctionExtensions
{
// A C# 14 extension block: Then becomes a member of every Func<T, TResult>.
extension<T, TResult>(Func<T, TResult> first)
{
public Func<T, TNext> Then<TNext>(Func<TResult, TNext> next) => value => next(first(value));
}
} Output
each line at 10 % off: 324, 405 festival(810) = 729, gst(729) = 765.45 checkout(cart) = Rs 765 rounding first = Rs 765.45
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run compose.cs
Apply takes a rule and ran it on each line: 324 and 405. PercentOff(10) returns a function that remembers the 10
and waits for an amount, so festival(810) gave 729: fixing some arguments now and the rest later is
partial application: Fixing some of a function's arguments now and the rest later. In C# a method returns a lambda that remembers the fixed arguments in a closure, as PercentOff(10) returns a rule that waits for an amount., done here with a closure. Then is declared once in an
extension block for every Func<T, TResult>, and f.Then(g) makes the function that runs f and passes its result
to g. Joined into checkout, the subtotal, the offer, GST and rounding gave Rs 765. Watch the cart go through the
four steps:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The cart goes in. checkout is one Func that takes the cart. Its first function, Subtotal, adds the lines: 2 × 180 + 450 is 810.
- Then the offer. Then passes 810 to festival, the function PercentOff(10) returned, which remembers its 10: 810 × 90 / 100 is 729.
- Then GST. gst adds the 5 % it was made with: 729 + 36.45 is 765.45.
- Then rounding. RoundToRupee rounds 765.45 to Rs 765, and checkout(cart) returns it. Each function did one thing and changed nothing outside itself.
function composition: Joining two functions into one, so that the result of the first becomes the argument of the second: f then g is the function x => g(f(x)). makes the order visible and changeable: roundFirst used
the same four functions with rounding first and gave Rs 765.45, because nothing rounded the result of GST and the
offer. The same style covers failures: instead of throwing, a function can return a small Result value that holds
either its value or an error, so the caller has to look before using it. A chain of such results stops at the first
failure, while checks that stand alone can be collected, so that a form reports all its errors at once.
Key idea
Pass, return and join small pure functions, and a pipeline reads from left to right, can be tested one step at a time, and changes order only when you change it.
Checkpoint
Check: Functions as values, partial application and composition
Expected failures as values: a Result type with Map and Bind, measured against exceptions Pro: opens with a Pro, Premium or Ultimate pass
A sign-up form fails often, and every one of its failures is expected. The runs below carry those failures as values, report all of them at once, and measure what an exception per failure would cost instead.
In this part:
- A small Result type, chained with Map and Bind
- Collecting every error instead of stopping at the first
- What an exception per failure costs, measured
- Exercise: a KYC form that reports every error
4 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A functional core in an imperative shell, and where functional C# stops Pro: opens with a Pro, Premium or Ultimate pass
Pure functions pay off most when the code that reads clocks, databases and consoles stays at the edges. The last runs show that split, then the parts of functional style that C# leaves to you and what they cost.
In this part:
- A functional core with the side effects at the edge
- What immutable data costs, measured
- Where C# stops, and what the functional style costs
3 runnable examples (C#) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a pure checkout and an immutable cart
pricing.cs checks two functions that return functions, a pure Checkout built from them, and a WithQuantity that
changes an immutable cart by returning a new one. All eight checks fail on the starter:
// Practice: write the four methods of Pricing and Carts, then run this file until all eight checks pass. What each
// must do is in the comment above it. The prices and rates are made up for this exercise.
using System.Collections.Immutable;
ImmutableList<Line> cart = [new("Tea 250 g", 2, 180m), new("Rice 5 kg", 1, 450m)];
(string Check, Func<string> Run, string Expected)[] checks =
[
("PercentOff(10) takes 10 % off", () => Pricing.PercentOff(10)(1000m).ToString(), "900"),
("AddGst(18) adds 18 %", () => Pricing.AddGst(18)(1000m).ToString(), "1180"),
("Checkout applies the offer, then GST, then rounds", () => Pricing.Checkout(cart, offerPercent: 10, gstPercent: 5).ToString(), "765"),
("Checkout gives the same total twice", () => $"{Pricing.Checkout(cart, 10, 5)} and {Pricing.Checkout(cart, 10, 5)}", "765 and 765"),
("Checkout leaves the cart as it was", () => { Pricing.Checkout(cart, 10, 5); return $"{cart.Count} lines, {cart[0].Quantity} packs of tea"; }, "2 lines, 2 packs of tea"),
("WithQuantity returns a changed cart", () => Carts.WithQuantity(cart, "Tea 250 g", 3)[0].Quantity.ToString(), "3"),
("WithQuantity leaves the old cart as it was", () => { Carts.WithQuantity(cart, "Tea 250 g", 3); return cart[0].Quantity.ToString(); }, "2"),
("an unknown item gives back the same cart", () => ReferenceEquals(Carts.WithQuantity(cart, "Salt 1 kg", 1), cart) ? "same cart" : "another cart", "same cart"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
record Line(string Name, int Quantity, decimal Price);
static class Pricing
{
// Returns a function that takes percent per cent off an amount.
public static Func<decimal, decimal> PercentOff(int percent) => throw new NotImplementedException();
// Returns a function that adds percent per cent of GST to an amount.
public static Func<decimal, decimal> AddGst(int percent) => throw new NotImplementedException();
// The subtotal of the lines, then the offer, then GST, then rounded to the rupee (midpoints away from zero).
// Pure: it reads its arguments and changes nothing.
public static decimal Checkout(IReadOnlyList<Line> lines, int offerPercent, int gstPercent) => throw new NotImplementedException();
}
static class Carts
{
// Returns a cart in which the line with this name has the new quantity; the cart passed in does not change. When no
// line has this name, returns the very same cart.
public static ImmutableList<Line> WithQuantity(ImmutableList<Line> cart, string name, int quantity) => throw new NotImplementedException();
} Output (exit status 1)
PercentOff(10) takes 10 % off: FAIL, throws NotImplementedException instead of 900 AddGst(18) adds 18 %: FAIL, throws NotImplementedException instead of 1180 Checkout applies the offer, then GST, then rounds: FAIL, throws NotImplementedException instead of 765 Checkout gives the same total twice: FAIL, throws NotImplementedException instead of 765 and 765 Checkout leaves the cart as it was: FAIL, throws NotImplementedException instead of 2 lines, 2 packs of tea WithQuantity returns a changed cart: FAIL, throws NotImplementedException instead of 3 WithQuantity leaves the old cart as it was: FAIL, throws NotImplementedException instead of 2 an unknown item gives back the same cart: FAIL, throws NotImplementedException instead of same cart 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run pricing.cs
Write the pricing functions
Copy pricing.cs. Return lambdas from PercentOff and AddGst. In Checkout, sum the lines, then apply the offer,
GST and Math.Round(amount, 0, MidpointRounding.AwayFromZero). In WithQuantity, find the line’s index with
FindIndex, return cart itself when it is −1, and otherwise cart.SetItem(index, cart[index] with { Quantity = quantity }).
Run dotnet run pricing.cs until it prints 8 of 8 checks pass.
Then check yourself on pure functions and composition:
Practice that adapts to you
Practise this lesson
6 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a pure function, and why is it easier to test?
A pure function changes nothing outside itself, not its arguments, a field or a file, and gives the same result for
the same arguments, because it depends on nothing else, not the clock and not a static setting. Its test needs only
arguments and an expected result: no shared state to set up, no mocks, no order between tests, and the same outcome on
every run. Nothing is shared, so it is also safe to call from several threads at once. Records, with expressions and
immutable collections make such functions practical in C#.
-
Mid to senior
The cart page shows Rs 679 for an order and the invoice, priced a moment later, shows Rs 656.10. Both call ApplyOffers(cart) on the same cart object. How do you work out why, and how do you redesign the pricing so that it cannot happen again?
-
Mid to senior
A sign-up API throws ValidationException for every bad field. During a wave of bot sign-ups it handles 5,000 invalid requests a second and its CPU use and garbage collections climb. Would you switch to a Result type, and what changes for the callers and for the cost?
-
Senior to staff
A 3,000-line OrderService reads the clock, the database and configuration inside its pricing and shipping rules, and has almost no tests. How would you move it toward a functional core with an imperative shell, step by step, without stopping feature work?
Key takeaways
- A pure function changes nothing outside itself and gives the same result for the same arguments, so it is easy to test and safe to run in parallel.
- Records,
withand immutable collections change data by making new values, so old values stay valid. - Functions are values: pass them, return them with some arguments fixed, and join them with
Theninto a pipeline whose order is visible.
Terms in this lesson
- function composition
- Joining two functions into one, so that the result of the first becomes the argument of the second: f then g is the function x => g(f(x)).
- higher-order function
- A function that takes another function as an argument or returns one, such as a method with a Func parameter, or a method that returns a lambda.
- partial application
- Fixing some of a function's arguments now and the rest later. In C# a method returns a lambda that remembers the fixed arguments in a closure, as PercentOff(10) returns a rule that waits for an amount.
- pure function
- A function that has no side effects and always returns the same result for the same arguments, because it depends on nothing but them: no clock, no shared field, no input it reads by itself.
- side effect
- A change a function makes outside itself while it runs: writing a field, changing an argument, printing, saving a file. A function with side effects can give another result the next time it is called.
References
- Refactor into pure functions (Microsoft Learn)
- C# record types (Microsoft Learn)
- ImmutableList<T> class (Microsoft Learn)
- ImmutableArray<T> struct (Microsoft Learn)
- switch expression (Microsoft Learn)
- What's new in C# 14: extension members (Microsoft Learn)
- Lambda expressions and anonymous functions (Microsoft Learn)
- Best practices for exceptions (Microsoft Learn)
- Functions in F#: pipelines, composition and partial application (Microsoft Learn)
- Recursive functions in F#: tail recursion (Microsoft Learn)
- Results in F# (Microsoft Learn)
- Language Integrated Query (LINQ) (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress