Higher-Order Functions and Composition: map/filter/reduce, Currying and Building Pipelines
A higher-order function takes or returns functions; composition connects them into a new function, and its order is a business rule. We combine Bookstore's coupon and member discount as Func<decimal, decimal> rules with Then, Compose and Pipe, test that the order changes the result by 1.50 TL, and show fold, currying, a TypeScript pipe and the cost of closure allocations with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402), the tests with xUnit.net v3 4.0.3; the TypeScript examples with TypeScript 7.0.2, Node.js 26 and RxJS 7.8.2.
Guess First: The Order of Two Discounts
In Bookstore, the online bookstore used as the shared example across this site's series, the discount rules are now functions. PercentOff(0.10m) returns a Func<decimal, decimal> that takes 10 percent off an amount, AmountOff(15m) one that takes 15 TL off, and Then chains two functions one after the other. The marketing team says "apply the coupon and the member discount together", but doesn't say which comes first. For a 249.40 TL cart, do the two lines give the same result?
Func<decimal, decimal> couponThenMember = PriceRules.PercentOff(0.10m).Then(PriceRules.AmountOff(15m)).Then(PriceRules.RoundToKurus);
Func<decimal, decimal> memberThenCoupon = PriceRules.AmountOff(15m).Then(PriceRules.PercentOff(0.10m)).Then(PriceRules.RoundToKurus);
Console.WriteLine($"coupon then member: {couponThenMember(249.40m)}");
Console.WriteLine($"member then coupon: {memberThenCoupon(249.40m)}");
Answer: The Order of Composition Is a Business Rule
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-compose
coupon then member: 209.46
member then coupon: 210.96

The difference is 1.50 TL: 10 percent of 15 TL. If the coupon is applied first, the 10 percent is computed on 249.40; if the member discount comes first, on 234.40. Two percentage rules can swap places, because the order of multiplication doesn't change the result; once a fixed-amount rule is mixed in, they can't. In mathematical terms: function composition is not commutative in general.
In the first post's LegacyCheckout, this order was buried in the line order of the code; if someone swapped two if blocks, nobody would notice. Once rules are functions, the order becomes a visible value: it sits in a list, it can be tested, and the business can be asked "which comes first?". This post builds the tools for that: higher-order functions, fold, composition and currying.
Higher-Order Functions: Taking Functions, Returning Functions
A function that takes a function as a parameter or returns one as its result is called a higher-order function. The methods of PriceRules are of the second kind:
public static class PriceRules
{
public static Func<decimal, decimal> PercentOff(decimal rate) => amount => amount * (1 - rate);
public static Func<decimal, decimal> AmountOff(decimal discount) => amount => Math.Max(0m, amount - discount);
public static Func<decimal, decimal> AddVat(decimal rate) => amount => amount * (1 + rate);
public static readonly Func<decimal, decimal> RoundToKurus =
amount => Math.Round(amount, 2, MidpointRounding.AwayFromZero);
public static readonly Func<decimal, decimal> Identity = amount => amount;
public static Func<decimal, decimal> Combine(IEnumerable<Func<decimal, decimal>> rules) =>
rules.Aggregate(Identity, (combined, rule) => combined.Then(rule));
}
PercentOff takes a rate and produces a function that says "give me an amount and I'll return the discounted amount". The returned lambda captures rate through a closure; the capturing mechanics are in the C# Advanced series post "Lambdas and Closures in C#: The Captured Variable Trap". What matters here is that the rule's setting (the rate) and the rule's application (the amount) are split into two separate moments: you build the rule once when the application starts and then apply it to thousands of carts.
The first kind, higher-order functions that take functions, you already use every day. LINQ and JavaScript array methods are two namings of the same idea:
| C# LINQ | TypeScript / JavaScript | What it does |
|---|---|---|
Select | map | Transforms each element |
Where | filter | Keeps the ones that meet a condition |
Aggregate | reduce | Reduces all of them to a single value |
SelectMany | flatMap | Produces a sequence from each element and flattens it |
Any / All | some / every | Does at least one / do all meet the condition |
LINQ choosing names that come from SQL (Select, Where) instead of map/filter/reduce was a deliberate choice; it lets query syntax and method syntax use the same names. The operators are covered in detail in the LINQ series; this post looks at their common shape.
Fold: Every Loop Is an Aggregate
One row of the table is more general than the others: Aggregate, known in the functional programming literature as fold. It takes an initial value and a function that says "produce a new result from the result so far and the next element". Different questions about the cart can be written with the same pattern:
decimal subtotal = Lines.Aggregate(0m, (sum, line) => sum + line.LineTotal);
decimal maxPrice = Lines.Aggregate(decimal.MinValue, (max, line) => Math.Max(max, line.UnitPrice));
bool anyEbook = Lines.Aggregate(false, (found, line) => found || line.IsEbook);
List<string> titles = Lines.Aggregate(new List<string>(), (list, line) => line.Quantity > 0 ? [.. list, line.Title] : list);
Console.WriteLine($"subtotal: {subtotal}, max: {maxPrice}, ebook: {anyEbook}, titles: {string.Join(", ", titles)}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-fold
subtotal: 249.40, max: 189.50, ebook: True, titles: Dune, Emma
Sum, maximum, "any of them" and even filtering and transforming (the last line does the work of Where and Select together) are all folds. Graham Hutton's 1999 paper "A Tutorial on the Universality and Expressiveness of Fold" shows that a very wide family of functions over lists can be written with fold. What this means in practice: any code that keeps state in a variable and updates it in a loop can be turned into a fold that moves the state into the accumulator. That doesn't mean you should always turn loops into folds; named operators such as Sum, Max and Any express intent better. The value of knowing fold is having the tool at hand when you need an accumulation that has no name (such as summing the ISBNs in the cart grouped by publisher).
The [.. list, line.Title] in the last line produces a new list at each step; returning a new accumulator instead of changing it is the fold counterpart of the immutability idea from the second post. We'll talk about its cost for large lists in the Pitfalls section.
Composition: Then, Compose and Pipe
Composition means making one function's output the other's input and producing a new function from the two. C# has no operator for this in the language, but it can be written with a few lines of extension methods:
public static class FuncExtensions
{
// f.Then(g): first f, then g
public static Func<TIn, TOut> Then<TIn, TMid, TOut>(this Func<TIn, TMid> first, Func<TMid, TOut> second) =>
input => second(first(input));
// g.Compose(f): g after f, the mathematical order
public static Func<TIn, TOut> Compose<TIn, TMid, TOut>(this Func<TMid, TOut> second, Func<TIn, TMid> first) =>
input => second(first(input));
public static TOut Pipe<TIn, TOut>(this TIn value, Func<TIn, TOut> function) => function(value);
public static Func<T1, Func<T2, TOut>> Curry<T1, T2, TOut>(this Func<T1, T2, TOut> function) =>
first => second => function(first, second);
public static Func<T2, TOut> Partial<T1, T2, TOut>(this Func<T1, T2, TOut> function, T1 first) =>
second => function(first, second);
}
Then and Compose read the same operation from opposite directions. The mathematical notation g ∘ f means "g after f" and is read right to left; Compose follows that order. Then follows the direction code is read in, left to right. Pipe doesn't produce a function; it passes a value through a function, the counterpart of F#'s |> operator. All three give the same result:
Func<decimal, decimal> sameAsFirst = PriceRules.RoundToKurus.Compose(PriceRules.AmountOff(15m)).Compose(PriceRules.PercentOff(0.10m));
Console.WriteLine($"Compose, mathematical order: {sameAsFirst(249.40m)}");
decimal piped = 249.40m.Pipe(PriceRules.PercentOff(0.10m)).Pipe(PriceRules.AmountOff(15m)).Pipe(PriceRules.RoundToKurus);
Console.WriteLine($"Pipe: {piped}");
Compose, mathematical order: 209.46
Pipe: 209.46
Which one you pick depends on how your team reads code. Because C# is read left to right and top to bottom, Then or Pipe feels more natural in most teams; Compose looks familiar to people with a maths background but requires reading the order backwards. What matters is choosing one direction in a codebase.
Combining Rules as a List and Testing Them
PriceRules.Combine reduces a list of rules to a single function. Inside, there's a fold again: the initial value is the Identity function that does nothing, and the step is "Then the function combined so far with the next rule". An empty list returns Identity; that is, with no rules the amount doesn't change:
List<Func<decimal, decimal>> rules = [PriceRules.PercentOff(0.10m), PriceRules.AmountOff(15m), PriceRules.RoundToKurus];
Console.WriteLine($"Combine(list): {PriceRules.Combine(rules)(249.40m)}, Combine(empty): {PriceRules.Combine([])(249.40m)}");
Combine(list): 209.46, Combine(empty): 249.40
The rule order is now the order in the list, and it can be tested as a business rule:
[Theory]
[InlineData(249.40, 209.46)]
[InlineData(100.00, 75.00)]
[InlineData(10.00, 0.00)]
public void Coupon_is_applied_before_the_member_discount(decimal subtotal, decimal expected)
{
Func<decimal, decimal> checkoutRules = PriceRules.Combine(
[PriceRules.PercentOff(0.10m), PriceRules.AmountOff(15m), PriceRules.RoundToKurus]);
Assert.Equal(expected, checkoutRules(subtotal));
}
[Fact]
public void Rule_order_changes_the_result()
{
Func<decimal, decimal> couponFirst = PriceRules.PercentOff(0.10m).Then(PriceRules.AmountOff(15m));
Func<decimal, decimal> memberFirst = PriceRules.AmountOff(15m).Then(PriceRules.PercentOff(0.10m));
Assert.NotEqual(couponFirst(249.40m), memberFirst(249.40m));
}
[Theory]
[InlineData(0.00)]
[InlineData(249.40)]
public void Combining_no_rules_returns_the_amount_unchanged(decimal amount) =>
Assert.Equal(amount, PriceRules.Combine([])(amount));
$ dotnet test
Test run summary: Passed!
total: 13
failed: 0
succeeded: 13
skipped: 0
duration: 571ms
The 10 TL cart in the third row catches an edge case: after 10 percent, 9 TL is left, and when 15 TL is taken off, the Math.Max inside AmountOff keeps the amount at zero. Because the rule can be tested on its own, this boundary behaviour is verified without setting up the whole checkout flow. Adding a new rule is just as easy: adding VAT after the discounts means putting PriceRules.AddVat(0.10m) at the end of the list, and the order itself is a test row.
Currying and Partial Application
The two-stage signature of PercentOff (first the rate, then the amount) isn't a coincidence. Turning a two-parameter function into a chain that takes the first parameter and returns a function waiting for the second is called currying; the name comes from the logician Haskell Curry. Partial application means supplying some of the parameters now and getting a new function for the rest. The results may look the same, but the difference is this: currying changes the shape of the function, and partial application uses that shape to fix a value.
Func<decimal, decimal, decimal> applyRate = (rate, amount) => Math.Round(amount * (1 - rate), 2, MidpointRounding.AwayFromZero);
Func<decimal, Func<decimal, decimal>> curried = applyRate.Curry();
Func<decimal, decimal> autumn = curried(0.10m);
Func<decimal, decimal> welcome = applyRate.Partial(0.05m);
decimal[] prices = [189.50m, 59.90m, 149.90m];
Console.WriteLine($"autumn: {string.Join(", ", prices.Select(autumn))}");
Console.WriteLine($"welcome: {string.Join(", ", prices.Select(welcome))}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-curry
autumn: 170.55, 53.91, 134.91
welcome: 180.03, 56.91, 142.41
The line prices.Select(autumn) works because autumn takes a single parameter: that's exactly what Select expects. In Haskell and F#, every function is curried by default, so this happens on its own; in C# you need helpers such as Curry and Partial, or factory methods that return functions directly (like PercentOff). In practice the last option reads best: a named factory says more than an anonymous Curry call.
pipe in TypeScript and RxJS
In TypeScript, arrow functions make currying natural, and a pipe helper is easy to write with reduce. Type safety requires a separate overload for each length; libraries such as fp-ts and Effect take the same approach:
export function pipe<A>(value: A): A;
export function pipe<A, B>(value: A, f1: (a: A) => B): B;
export function pipe<A, B, C>(value: A, f1: (a: A) => B, f2: (b: B) => C): C;
export function pipe<A, B, C, D>(value: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D): D;
export function pipe(value: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
return fns.reduce((acc, fn) => fn(acc), value);
}
export const compose =
<A, B, C>(second: (b: B) => C, first: (a: A) => B) =>
(a: A): C =>
second(first(a));
import { from, pipe as rxPipe, filter, map, reduce } from "rxjs";
import { sampleCart, type CartLine } from "./cart.ts";
import { compose, pipe } from "./fp.ts";
type PriceRule = (amount: number) => number;
// Curried rule factories: take the setting, return a rule
const percentOff = (rate: number): PriceRule => (amount) => amount * (1 - rate);
const amountOff = (discount: number): PriceRule => (amount) => Math.max(0, amount - discount);
const roundToKurus: PriceRule = (amount) => Math.round(amount * 100) / 100;
const combine = (rules: readonly PriceRule[]): PriceRule => (amount) => rules.reduce((acc, rule) => rule(acc), amount);
console.log("pipe:", pipe(249.4, percentOff(0.1), amountOff(15), roundToKurus));
console.log("member first:", pipe(249.4, amountOff(15), percentOff(0.1), roundToKurus));
console.log("compose:", compose(roundToKurus, compose(amountOff(15), percentOff(0.1)))(249.4));
console.log("combine:", combine([percentOff(0.1), amountOff(15), roundToKurus])(249.4));
// A reusable RxJS operator is a composition too
const discountedSubtotal = rxPipe(
filter((line: CartLine) => line.quantity > 0),
map((line) => percentOff(0.1)(line.unitPrice * line.quantity)),
reduce((sum, amount) => sum + amount, 0),
);
from(sampleCart()).pipe(discountedSubtotal).subscribe((total) => console.log("rxjs:", roundToKurus(total)));
$ node src/04-composition.ts
pipe: 209.46
member first: 210.96
compose: 209.46
combine: 209.46
rxjs: 224.46
RxJS's standalone pipe function (imported here as rxPipe) combines operators into a new operator; .pipe(...) on an Observable is the counterpart of Pipe, passing a value through functions. When you write an RxJS chain, you are in fact doing composition; the operators themselves and writing custom operators are covered in the RxJS series post "Writing Custom RxJS Operators and Staying Readable: pipe Composition, Lint Rules and Debugging".
Common Pitfalls
Putting point-free style ahead of readability. Code written only by combining functions, without ever naming a parameter, is called point-free. prices.Select(autumn) is a good example of it; RoundToKurus.Compose(AmountOff(15m)).Compose(PercentOff(0.10m)), on the other hand, forces the reader to decode the chain from right to left. In a composition of more than three or four steps, naming intermediate results (discounted, withShipping) or writing a plain method means being able to set a breakpoint in the debugger and see a meaningful name in the stack trace. Composition is a tool; not every line needs to be turned into one.
Forgetting closure allocations on a hot path. Let's measure the difference between building a rule again for each element and building it once:
long before = GC.GetAllocatedBytesForCurrentThread();
foreach (decimal price in prices)
total += PriceRules.PercentOff(0.10m)(price);
long perItem = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
Func<decimal, decimal> autumn = PriceRules.PercentOff(0.10m);
foreach (decimal price in prices)
total += autumn(price);
long once = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
foreach (decimal price in prices)
total += price * 0.90m;
long plain = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"rule created per item: {perItem} bytes, created once: {once} bytes, no delegate: {plain} bytes ({total})");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-alloc
rule created per item: 96000 bytes, created once: 96 bytes, no delegate: 0 bytes (1621350.00)
Each PercentOff call allocates 96 bytes: the closure object holding the captured rate, and the delegate. For a thousand elements, 96 KB. The .NET 10 JIT can put delegates that don't escape the method on the stack (escape analysis), but here the delegate is returned from PercentOff, so it escapes and stays on the heap. The rule: build functions once, apply them many times. How escape analysis works will be covered in the Compilers and Programming Languages series.
Multiple enumeration with LINQ deferred execution. The function inside Select runs again each time the query is enumerated:
int priceLookups = 0;
IEnumerable<decimal> discounted = Lines
.Where(line => line.Quantity > 0)
.Select(line =>
{
priceLookups++;
return PriceRules.PercentOff(0.10m).Then(PriceRules.RoundToKurus)(line.LineTotal);
});
decimal sum = discounted.Sum();
decimal max = discounted.Max();
Console.WriteLine($"sum: {sum}, max: {max}, price lookups: {priceLookups}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-enum
sum: 224.46, max: 170.55, price lookups: 4
Four calculations for a two-line cart. Because the function is pure, the result is correct; the work is just done twice. Had the function called a database or a price service, twice as many requests would have been sent. If you'll use the result more than once, materialise it once with ToList() or ToArray(). The details are in the LINQ series post "LINQ Deferred Execution: When Does a Query Run? IEnumerable and IQueryable".
Calculating money with number in TypeScript. JavaScript's number is a binary floating-point number; 249.4 * 0.9 isn't exactly 224.46. In this example Math.round(x * 100) / 100 corrects the result, but surprises appear at half a kuruş and in large totals. For real price calculations, keep amounts as integer kuruş or leave the calculation to decimal on the backend.
A Small Experiment
★☆☆ Add PriceRules.AddVat(0.10m) to the start and to the end of the rule list and compare the results for 249.40 TL. Where does the difference come from? (Hint: is the 15 TL member discount taken off before or after VAT?)
★☆☆ Use Lines.Aggregate(...) to reduce the ISBNs of the printed books in the cart to a single comma-separated string. Write the same with string.Join and Where/Select; which reads better?
★★☆ Add a Curry overload for three-parameter functions to FuncExtensions and curry an "at least this much discount" rule shaped like (rate, minimum, amount) => ....
★★★ Change PriceRules.Combine so that it also returns each rule's name and the amount after it was applied, as a List<(string Rule, decimal Amount)>; the invoice screen will show this list. How do you do that while keeping the functions pure, that is, without calling Add on an outside list?
With the next post we move to the second block of the series: modelling data with types. We'll remove the impossible states created by an Order class that keeps its status in IsPaid, IsShipped and IsCancelled flags, using algebraic data types.