What Is Functional Programming? Declarative Thinking for C# and TypeScript Developers
Functional programming means building a computation as a combination of functions that take and return values, rather than as commands that change values step by step. Using Bookstore's checkout code that silently changes the cart, we show the difference between imperative and declarative code, first-class functions, a short history and the functional ideas you already use in LINQ, RxJS and Angular signals, 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 TypeScript examples with TypeScript 7.0.2, Node.js 26, RxJS 7.8.2 and Angular 22.2 (@angular/core 22.2.6).
Guess First: Does a Method That Computes a Total Change the Cart?
In Bookstore, the online bookstore used as the shared example across this site's series, the same class has computed the cart total at checkout for years. The rules are simple: lines with a quantity of zero don't count, the AUTUMN10 coupon takes 10 percent off until the end of November, members get 15 TL off, and if the cart contains a printed book and the discounted amount is below 300 TL, 49.90 TL shipping is added. Here is the code:
public class LegacyCheckout
{
public static decimal LastTotal;
public decimal CalculateTotal(List<LegacyCartLine> lines, string? couponCode, bool isMember)
{
// Clean up lines with zero quantity
for (int i = lines.Count - 1; i >= 0; i--)
{
if (lines[i].Quantity <= 0)
lines.RemoveAt(i);
}
decimal total = 0;
bool hasPrintedBooks = false;
foreach (LegacyCartLine line in lines)
{
total += line.UnitPrice * line.Quantity;
if (!line.IsEbook)
hasPrintedBooks = true;
}
if (couponCode == "AUTUMN10" && DateTime.Now < new DateTime(2026, 12, 1))
total = total * 0.90m;
if (isMember)
total = total - 15;
if (hasPrintedBooks && total < 300)
total = total + 49.90m;
total = Math.Round(total, 2, MidpointRounding.AwayFromZero);
Console.WriteLine($"[checkout] total={total}");
LastTotal = total;
return total;
}
}
LegacyCartLine is an ordinary class whose properties are all get; set;. The cart has three lines: Dune (189.50 TL, printed), Emma (59.90 TL, ebook) and The Hobbit with its quantity lowered to zero. How many lines does the code below print, and what is lines after?
List<LegacyCartLine> cart = SampleCarts.Legacy();
Console.WriteLine($"lines before: {cart.Count}");
decimal total = new LegacyCheckout().CalculateTotal(cart, "AUTUMN10", isMember: true);
Console.WriteLine($"total: {total}");
Console.WriteLine($"lines after: {cart.Count}");
Console.WriteLine($"LastTotal: {LegacyCheckout.LastTotal}");
Answer: The Hidden Work of a Total
$ dotnet run --project src/Bookstore.Fp.Demo -- 01-guess
lines before: 3
[checkout] total=259.36
total: 259.36
lines after: 2
LastTotal: 259.36
The series code lives in the Bookstore.Fp.slnx solution; the C# examples are in the src/Bookstore.Fp.Demo console project and run with the command on the first line of each output block.
The total is right: 90 percent of 249.40 TL is 224.46, the member discount makes it 209.46 and shipping brings it to 259.36. But the method's signature says "give me a cart and I'll give you a number", while its body does more than that. It deletes a line from the caller's cart (the cart page no longer shows The Hobbit), writes to the console, leaves the last total in a static field and ties the result to an input that isn't in the signature at all: the machine's clock. Call it with the same cart on 1 December and the coupon silently drops out, changing the number you tested.
No single line in this code is wrong; each one makes sense on its own. The problem is that to understand what the method does, you have to read the whole body and keep the order in your head. The total variable changes five times; to know its correct value you need to know which line you're on. Functional programming is a way of thinking that grew out of this problem: building a computation as a combination of functions that take values and return values, rather than as a sequence of steps that change values.
Throughout this series we'll change this class step by step. In this post we only set up the point of view: the difference between imperative and declarative, using functions as values, a short history and the functional tools you already use today.
Imperative and Declarative: "What" Instead of "How"
Imperative code tells the computer the steps: declare a variable, enter a loop, check a condition, update the variable. Declarative code describes the result: the sum of the amounts of lines with a quantity greater than zero. The declarative counterpart of the same rules looks like this:
public static class CheckoutMath
{
public static decimal Subtotal(IEnumerable<LegacyCartLine> lines) =>
lines.Where(line => line.Quantity > 0)
.Sum(line => line.UnitPrice * line.Quantity);
public static bool HasPrintedBooks(IEnumerable<LegacyCartLine> lines) =>
lines.Any(line => line.Quantity > 0 && !line.IsEbook);
public static decimal ShippingFee(decimal discountedTotal, bool hasPrintedBooks) =>
(hasPrintedBooks, discountedTotal) switch
{
(false, _) => 0m,
(true, >= 300m) => 0m,
_ => 49.90m
};
}
Subtotal doesn't delete lines; it filters out the ones it doesn't care about. Where and Sum are LINQ operators, the query feature C# got in 2007; we covered how they work in detail in the LINQ series, and what matters here is the shape. Each method is a single expression, updates no local variable, and its return value depends only on its parameters. You don't have to track line numbers while reading; to know what Subtotal returns, reading Subtotal is enough.
Declarative code isn't valuable because it's "shorter" than imperative code, but because it asks you to keep fewer things in your head. The loop counter, the intermediate state and the question "what was total when I got to this line" all go away. There's a cost too: a loop still runs behind Where and Sum, you just don't write it. We'll measure the performance impact of that difference in the last post of the series.
A Function Is a Value: First-Class Functions and Expression-Oriented Code
The second core idea of functional programming is that a function is a value: it can be assigned to a variable, passed as a parameter and returned from another function. This is called a first-class function. In C# the tools for it are delegates, Func and lambdas; we saw the mechanics in the C# Advanced series posts "Delegates, Func and Action in C#: Passing Methods Around as Values" and "Lambdas and Closures in C#: The Captured Variable Trap". Once the discount rules are written as values, the rest of the code becomes:
List<LegacyCartLine> cart = SampleCarts.Legacy();
decimal subtotal = CheckoutMath.Subtotal(cart);
bool hasPrinted = CheckoutMath.HasPrintedBooks(cart);
Console.WriteLine($"subtotal: {subtotal}, printed: {hasPrinted}, lines: {cart.Count}");
Func<decimal, decimal> autumnCoupon = amount => amount * 0.90m;
Func<decimal, decimal> memberDiscount = amount => amount - 15m;
decimal discounted = memberDiscount(autumnCoupon(subtotal));
decimal shipping = CheckoutMath.ShippingFee(discounted, hasPrinted);
decimal total = Math.Round(discounted + shipping, 2, MidpointRounding.AwayFromZero);
Console.WriteLine($"discounted: {discounted:0.00}, shipping: {shipping}, total: {total}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 01
subtotal: 249.40, printed: True, lines: 3
discounted: 209.46, shipping: 49.90, total: 259.36
Same result, but the cart still has its three lines. The discounts are now values: you can put them in a list, reorder them or take one out for a test. The expression memberDiscount(autumnCoupon(subtotal)) applies two functions one after the other; this is called composition, and it's the topic of the fourth post in the series. There we'll also see why the order of these two lines matters (swapping a percentage discount and a fixed-amount discount changes the result).
The switch expression in ShippingFee shows a third idea: expression-oriented code. In the legacy method, shipping was added to a variable by an if statement; here the switch itself produces a value. A statement says "do this", an expression says "this is". Expressions also require every branch to return a value: forget one and the compiler warns you. The syntax of patterns is in the C# Advanced series post "Pattern Matching in C# in Depth: Property, Positional and Tuple Patterns"; in this series we'll come back to pattern matching in the fifth post, where we model the shape of data with types.
A Short History: From Lambda Calculus to LINQ
None of these ideas are new. In the 1930s Alonzo Church developed the lambda calculus, which expresses computation purely through defining and applying functions. That's why an expression like x => x * 2 in C# is called a lambda. Lisp, developed by John McCarthy in 1958, became one of the first widely used languages to treat functions as data. In the 1970s ML, developed in Edinburgh by Robin Milner and his team and first created as the command language of a theorem prover, popularised type inference and algebraic data types; the type inference you see in TypeScript and with var in C# comes from this line. The first Haskell report was published in April 1990; the language is known for keeping side effects separate in its type system. F#, led by Don Syme at Microsoft Research, appeared in 2005 as the ML family's representative on .NET.
Two texts hold an important place in this history. In his 1977 Turing Award lecture, John Backus argued that programming was stuck in a style that updates variables step by step and proposed writing programs as combinations of functions. John Hughes's paper "Why Functional Programming Matters", published in 1989, explained the value of functional programming not with a list of things it avoids but with modularity: according to Hughes, higher-order functions and lazy evaluation are two powerful ways of connecting small pieces.
These ideas entered mainstream languages piece by piece, not all at once. LINQ, which arrived with C# 3.0 in November 2007, brought lambda expressions, extension methods and a way to pass queries around as values together. Then came pattern matching in C# 7 (2017), switch expressions in C# 8 (2019) and records in C# 9 (2020); C# 15 brings real sum types with union types (the eighth post in the series). On the TypeScript side, map/filter/reduce were already part of JavaScript; discriminated unions and readonly came to the type system. The idea of Reactive Extensions was born at Microsoft around 2009 as Rx.NET, was ported to JavaScript as RxJS and became the foundation of Angular's HTTP and forms APIs.
You Already Do Functional Programming
If you're a .NET and Angular developer, you use these ideas every day. Let's write the same cart calculation in TypeScript with three different tools:
import { computed, signal } from "@angular/core";
import { from, filter, map, reduce } from "rxjs";
import { sampleCart, type CartLine } from "./cart.ts";
const cart = sampleCart();
// 1) Array methods
const subtotal = cart
.filter((line) => line.quantity > 0)
.map((line) => line.unitPrice * line.quantity)
.reduce((sum, amount) => sum + amount, 0);
console.log("array subtotal:", subtotal.toFixed(2));
// 2) RxJS pipe
from(cart)
.pipe(
filter((line) => line.quantity > 0),
map((line) => line.unitPrice * line.quantity),
reduce((sum, amount) => sum + amount, 0),
)
.subscribe((total) => console.log("rxjs subtotal:", total.toFixed(2)));
// 3) Angular signals
const lines = signal<CartLine[]>(cart);
const signalSubtotal = computed(() =>
lines()
.filter((line) => line.quantity > 0)
.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0),
);
console.log("signal subtotal:", signalSubtotal().toFixed(2));
lines.update((current) => current.map((line) => (line.title === "Emma" ? { ...line, quantity: 2 } : line)));
console.log("after update:", signalSubtotal().toFixed(2));
$ npx tsc --noEmit
$ node src/01-already-fp.ts
array subtotal: 249.40
rxjs subtotal: 249.40
signal subtotal: 249.40
after update: 309.30
The TypeScript examples live in the project's ts folder; run npm install there once before the commands. Node.js 26 runs .ts files directly with type stripping; type checking is done separately by tsc --noEmit (no output means no errors). We used signals here without an Angular application, straight from the @angular/core package.
All three examples share the same pattern: data flows through a series of functions, each function takes the previous one's output, and none of them changes the source. filter and map take functions as callbacks; these are called higher-order functions. RxJS's pipe combines operators into a new function; that's composition. computed() defines a calculation that depends only on the signals it reads, and Angular keeps the result on the assumption that the calculation gives the same result for the same input. That assumption is called purity, and it's the topic of the third post.
Look at the last two lines: when we increased Emma's quantity, we didn't change the array in place; we produced a new array with map and a new object with spread. update saw the new reference and computed recalculated. What happens if you change the line in place, we'll see with real output in the second post, on immutability.
Functional Programming and OOP: Not Rivals
Functional programming is sometimes presented as the rival of object-oriented programming: "don't write classes, make everything a function." In practice the two answer different questions. OOP is concerned with placing behaviour and responsibilities within boundaries: which code can touch which data, how dependencies are swapped. Functional programming is concerned with the computation itself: how predictable the code that produces a result is, and how easily it can be combined and tested.
Modern C# is designed to use both together. In ASP.NET Core, services are classes built with dependency injection, but the price calculation of a request can be pure functions that those classes call and that can be tested on their own. This arrangement has a name: functional core, imperative shell. The calculation that makes the decisions is gathered in a core; work that touches the outside world, such as the database, the clock, logging and HTTP, stays in a thin shell. The Unit Test series post "Testable Design: Dependency Injection, Seams, Functional Core and Humble Object" covers this arrangement from the testing side; this series covers the "why" of the same arrangement and its composition.
Looking at LegacyCheckout this way, the split is clear: the rules that compute the total belong in the core; deleting lines, writing to the console, updating the static field and reading the clock belong in the shell, or nowhere.
What We'll Do With Bookstore in This Series
LegacyCheckout gets one step cleaner in each post:
- Second post:
LegacyCartLineand the cart become unchanging data; instead of deleting a line, a new cart is produced. We'll see why an array that ispushed in place doesn't update the UI in Angular. - Third post:
DateTime.Now, the console and the static field are pushed to the edge; the discount calculation becomes a pure function and is tested with xUnit[Theory]. - Fourth post: the discount rules are combined as a list of functions; the order of rules becomes testable.
- From the fifth post on: order status, "absent" values and errors are modelled with types (algebraic data types, Option, Result).
The goal of the series isn't to teach Haskell. It's to write code that surprises less and is easier to test, with the tools that already exist in C# and TypeScript, and to show where to stop.
Common Pitfalls
Thinking functional programming means "not writing for loops". Writing ForEach(x => total += x.Price) instead of foreach doesn't make code functional; you're still changing the same variable, just from inside a lambda. What counts isn't the absence of loops but the absence of state changes and hidden inputs. Conversely, a plain loop that updates a local variable inside a method can be entirely functional from the outside: if it doesn't change its input and gives the same result for the same input, the loop inside is an implementation detail.
Treating LINQ as functional everywhere. A lambda inside Select that calls the database, writes a log or adds to an outside list hides side effects under a declarative look. Combined with LINQ's deferred execution, where the query runs when it's enumerated rather than when it's defined, those side effects run when you don't expect them and as many times as you don't expect; the details are in the LINQ series post "LINQ Deferred Execution: When Does a Query Run? IEnumerable and IQueryable".
Seeing OOP as a rival and avoiding classes. Turning everything into static functions loses dependency injection, seams for testing and responsibility boundaries. The split isn't between classes and functions but between code that makes decisions and code that touches the outside world.
Trying to convert everything at once. Rather than making the whole codebase functional over a weekend, start by moving the calculation that produces the most bugs (price, tax, stock) into a pure core. This series also converts LegacyCheckout not with one big refactoring but one step per post.
A Small Experiment
★☆☆ Call LegacyCheckout.CalculateTotal twice in a row with the same cart object. Is the output of the second call and the value of lines after different from the first? Why?
★☆☆ Add a single-expression method called HasEbooks to CheckoutMath that returns whether the cart contains at least one ebook; use no loop and no local variable.
★★☆ In the TypeScript example, replace the lines.update(...) line with two lines that set Emma's quantity property directly to 2 and then call lines.set(lines()). What does after update print? We'll see the answer in the next post.
★★★ Mark each line of LegacyCheckout as "calculation" or "work that touches the outside world". Which lines are both? This split tells you where each line goes in the functional core, imperative shell arrangement.
In the next post we turn to the cart itself: immutability. We'll convert LegacyCartLine to a record and produce a new cart instead of deleting lines, and see which bugs shared mutable data leads to and why Angular signals expect a new reference.