Function Composition
Overview
Before storing user input, suppose we trim its edges, convert it to lowercase, and collapse repeated spaces. Nested calls such as collapse(lower(trim(s))) read in the opposite direction from their execution order, trim, lower, collapse. Adding or removing stages through configuration means rewriting that nesting for each case.
Function composition connects two functions to make one. In mathematical notation, (g β f)(x) = g(f(x)), the rightmost function f runs first. We will call each connected function a step; one step's output type must match the next step's input type.
Connecting trim, lower, and collapse produces a single Func<string, string>, a pipeline. The caller can receive and call that function without knowing which steps it contains.
Flow
The three steps inside the dashed box are joined with Then to form one Func<string, string>. An empty step list leaves the identity function, which returns its input unchanged.
History
Haskell's standard Prelude defines composition (.) as f . g = \ x -> f (g x) and declares it right-associative (infixr 9 .). As in the mathematical notation, the function on the right runs first. The same Prelude defines the identity function id.1
F# has composition >> and the pipeline operator |>. The function f runs first in f >> g, matching written order to execution order. Its documentation distinguishes >>, which takes two functions and returns a function, from |>, which takes a value and a function and returns a value, and gives the definition let (|>) x f = f x.2
At the program level, a Unix pipe connects one command's output to the next command's input. Ritchie dates its introduction into Unix to 1972, following M. D. McIlroy's proposal. This is an analogy to function composition: pipes are an operating-system facility for connecting process I/O streams.3
Why use it?
The composed result is still a function value. It can be stored, passed as an argument, or assembled at runtime from a configured list of steps, while each small step remains independently testable.
Composition is associative: f.Then(g).Then(h) and f.Then(g.Then(h)) give the same result for the same input, allowing a long list to be folded into one function from the front. The identity function changes nothing on either side and supplies the seed for an empty list. The Monoid article covers structures with associativity and an identity.
Defining a function from existing functions without naming its arguments, as in trim.Then(lower), is point-free style. A short combination can make the flow clear, but a long one has no names for intermediate values and can be harder to read.
A step that writes a file or changes shared state brings those effects into the pipeline. The same-input, same-output assumption then breaks, making isolated step checks insufficient to judge the whole; if a step throws, later steps do not run and the exception reaches the caller. Long chains also lack intermediate variables, making it harder to locate a failing step or inspect values at a breakpoint.
Example
C# lacks a composition operator corresponding to F# >>, so the example adds the Then extension to Func<A, B>. It returns x => g(f(x)) for f.Then(g); the compiler infers the type arguments and rejects combinations with mismatched output and input types.
using System;
Func<int, int> addOne = x => x + 1;
Func<int, int> timesTwo = x => x * 2;
Func<int, string> show = x => "<" + x + ">";
// Read nested calls from the inside out to follow execution order.
Console.WriteLine(show(timesTwo(addOne(2))));
// Then runs the functions in written order.
var addThenDouble = addOne.Then(timesTwo).Then(show);
Console.WriteLine(addThenDouble(2));
// Reordering produces a different function.
var doubleThenAdd = timesTwo.Then(addOne).Then(show);
Console.WriteLine(doubleThenAdd(2));
// show.Then(addOne) does not compile: show returns string, but addOne needs int.
static class FuncExtensions
{
// f.Then(g) is x => g(f(x)): f runs first.
public static Func<A, C> Then<A, B, C>(this Func<A, B> f, Func<B, C> g) => x => g(f(x));
}
<6>
<6>
<5>addThenDouble, assembled with Then, returns the same result as the nested calls. Reversing the order in doubleThenAdd changes it.
A broader example
A configuration string supplies the step names for a pipeline built at runtime. LINQ Select turns those names into functions, then Aggregate folds them with Then starting from the identity. Unknown names are rejected before composition, and the initial step, which fails on an empty string, demonstrates an exception in the middle.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
var catalog = new Dictionary<string, Func<string, string>>
{
["trim"] = s => s.Trim(),
["lower"] = s => s.ToLowerInvariant(),
["collapse"] = s => Regex.Replace(s, @"\s+", " "),
// Keep the first character, throwing for an empty string.
["initial"] = s => s[..1],
};
Func<string, string> identity = s => s;
Run("trim,lower,collapse", " Hello WORLD ");
Run("", " Hello WORLD ");
Run("trim,shout", " Hello ");
Run("trim,initial", " ");
// Regrouping leaves the result unchanged for the same input.
var left = catalog["trim"].Then(catalog["lower"]).Then(catalog["collapse"]);
var right = catalog["trim"].Then(catalog["lower"].Then(catalog["collapse"]));
Console.WriteLine("same result: " + (left(" A B ") == right(" A B ")));
void Run(string config, string input)
{
var names = config.Split(',', StringSplitOptions.RemoveEmptyEntries);
var unknown = names.Where(name => !catalog.ContainsKey(name)).ToArray();
if (unknown.Length > 0)
{
Console.WriteLine($"[{config}] unknown step: {string.Join(", ", unknown)}");
return;
}
// Append steps to the identity; an empty list leaves the identity unchanged.
var pipeline = names
.Select(name => catalog[name])
.Aggregate(identity, (composed, step) => composed.Then(step));
try
{
Console.WriteLine($"[{config}] \"{pipeline(input)}\"");
}
catch (ArgumentOutOfRangeException e)
{
// Outside the composed function, the failing step is not identified.
Console.WriteLine($"[{config}] failed: {e.GetType().Name}");
}
}
static class FuncExtensions
{
// f.Then(g) is x => g(f(x)): f runs first.
public static Func<A, C> Then<A, B, C>(this Func<A, B> f, Func<B, C> g) => x => g(f(x));
}
[trim,lower,collapse] "hello world"
[] " Hello WORLD "
[trim,shout] unknown step: shout
[trim,initial] failed: ArgumentOutOfRangeException
same result: TrueAn empty configuration returns its input through the identity function. With trim,initial, trim turns whitespace into an empty string and initial throws. The caller sees only the exception type: identifying the failing step would require named wrappers or a result type carrying failure information, neither of which is included here. The final comparison checks one input and is not a proof that the two functions are equal.
TypeScript example
TypeScript also defines then explicitly, folding the step list with reduce. JavaScript slice accepts an empty string without throwing, so initial throws RangeError itself to reproduce the C# failure.
type Step = (s: string) => string;
// then(f, g) is x => g(f(x)): f runs first.
const then = <A, B, C>(f: (a: A) => B, g: (b: B) => C) => (a: A): C => g(f(a));
const identity: Step = (s) => s;
const catalog = new Map<string, Step>([
["trim", (s) => s.trim()],
["lower", (s) => s.toLowerCase()],
["collapse", (s) => s.replace(/\s+/g, " ")],
// Keep the first character. Throw explicitly because slice accepts an empty string.
["initial", (s) => {
if (s.length === 0) throw new RangeError("empty input");
return s.slice(0, 1);
}],
]);
function run(config: string, input: string): void {
const names = config.split(",").filter((name) => name.length > 0);
const unknown = names.filter((name) => !catalog.has(name));
if (unknown.length > 0) {
console.log(`[${config}] unknown step: ${unknown.join(", ")}`);
return;
}
// Append steps to the identity; an empty list leaves the identity unchanged.
const pipeline = names
.map((name) => catalog.get(name)!)
.reduce((composed, step) => then(composed, step), identity);
try {
console.log(`[${config}] "${pipeline(input)}"`);
} catch (e) {
// Outside the composed function, the failing step is not identified.
if (!(e instanceof RangeError)) throw e;
console.log(`[${config}] failed: ${e.name}`);
}
}
run("trim,lower,collapse", " Hello WORLD ");
run("", " Hello WORLD ");
run("trim,shout", " Hello ");
run("trim,initial", " ");[trim,lower,collapse] "hello world"
[] " Hello WORLD "
[trim,shout] unknown step: shout
[trim,initial] failed: RangeErrorThe execution order and results match the C# example, and the exception still carries no step name.
See also
Example environment: each C# example is a separate .NET 10 console program; compile TypeScript for ES2022 or later, then run the JavaScript with Node.js.
Footnotes
- Simon Marlow (ed.). Haskell 2010 Language Report. Chapter 9, Standard Prelude. Definitions of composition (.) and identity id; the infixr 9 . declaration. β©
- Microsoft. Functions (F#). F# language reference, Pipelines and Function composition sections. β©
- Dennis M. Ritchie. The Evolution of the Unix Time-sharing System. Presented at the 1979 Language Design and Programming Methodology conference; reprinted in AT&T Bell Laboratories Technical Journal 63(6) (1984). Pipes section. β©