Introduction
Functional programming is about composing small, focused functions into larger behaviors. While Rust does not have built-in function composition operators like Haskell's . or F#'s >>, it provides closures, trait objects, and generic functions that enable powerful composition patterns. This lesson covers practical techniques for composing functions in Rust.
Key Concepts
- Function composition: Combining two functions
fandginto a new functionhwhereh(x) = g(f(x)). - Higher-order functions: Functions that accept or return other functions.
map,filter, andfoldare all higher-order functions. - Pipe pattern: Passing data through a sequence of transformations, similar to Unix pipes.
Real World Context
Composition is how middleware stacks work in web frameworks (Actix, Axum), how validation chains operate, and how data pipelines are built. When you chain .map().filter().fold(), you are composing functions.
Deep Dive
Manual function composition
You can compose two functions using a closure:
rustfn compose<A, B, C>( f: impl Fn(A) -> B, g: impl Fn(B) -> C, ) -> impl Fn(A) -> C { move |x| g(f(x)) } let double = |x: i32| x * 2; let add_one = |x: i32| x + 1; let double_then_add = compose(double, add_one); assert_eq!(double_then_add(5), 11); // 5*2=10, 10+1=11
The compose function returns a new closure that applies f first, then g.
Pipe pattern with method chaining
Rust's iterator chains are essentially a pipe pattern:
rustlet result = vec![1, 2, 3, 4, 5] .into_iter() .map(|x| x * 2) // [2, 4, 6, 8, 10] .filter(|&x| x > 4) // [6, 8, 10] .map(|x| x.to_string()) // ["6", "8", "10"] .collect::<Vec<_>>();
Each step takes the output of the previous step as input, forming a pipeline.
Composing with fold
fold is the most powerful iterator consumer — many other methods can be expressed in terms of it:
rustlet transactions = vec![100.0, -50.0, 200.0, -75.0, 150.0]; // fold composes an accumulation function across all elements let (total, count) = transactions.iter() .fold((0.0_f64, 0_u32), |(sum, count), &amount| { (sum + amount, count + 1) }); let average = total / count as f64; println!("Total: {total}, Count: {count}, Average: {average}"); // Output: Total: 325, Count: 5, Average: 65
fold carries state (the accumulator) through each step — it is function composition with state.
Trait objects for dynamic dispatch
When you need to compose functions at runtime (unknown at compile time), use trait objects:
rusttype Transform = Box<dyn Fn(String) -> String>; fn build_pipeline(steps: Vec<Transform>) -> impl Fn(String) -> String { move |input| { steps.iter().fold(input, |acc, step| step(acc)) } } let pipeline = build_pipeline(vec![ Box::new(|s| s.trim().to_string()), Box::new(|s| s.to_lowercase()), Box::new(|s| s.replace(" ", "_")), ]); assert_eq!(pipeline(" Hello World ".into()), "hello_world");
This pattern is useful for plugin systems and configurable data transformations.
Common Pitfalls
- Over-abstracting composition — Rust's type system makes deeply generic composition verbose. Use concrete types when the abstraction does not pay for itself.
- Forgetting
movein returned closures — Composed closures that capture variables must usemoveif returned from a function. - Performance with
Box<dyn Fn>— Dynamic dispatch adds indirection. Use generics for performance-critical paths.
Best Practices
- Use iterator chains as your primary composition tool — They are idiomatic, zero-cost, and well-understood by all Rust developers.
- Reserve trait objects for runtime-configured pipelines — When the number or type of steps varies at runtime,
Box<dyn Fn>is appropriate. - Keep composed functions small — Each function in a composition chain should do one thing. This makes the pipeline readable and testable.
Summary
- Function composition combines small functions into larger behaviors.
- Iterator chains (map, filter, fold) are Rust's primary composition tool.
foldis the most powerful consumer — it carries state across the entire iteration.Box<dyn Fn>enables runtime-configurable pipelines.- Keep each step focused on a single transformation for readability.
Code Examples
// A composable validation pipeline
type ValidationResult = Result<String, String>;
type Validator = Box<dyn Fn(&str) -> ValidationResult>;
fn not_empty() -> Validator {
Box::new(|input| {
if input.is_empty() {
Err("Input cannot be empty".into())
} else {
Ok(input.to_string())
}
})
}
fn max_length(max: usize) -> Validator {
Box::new(move |input| {
if input.len() > max {
Err(format!("Input exceeds {max} characters"))
} else {
Ok(input.to_string())
}
})
}
fn validate(input: &str, validators: &[Validator]) -> ValidationResult {
validators.iter().try_fold(input.to_string(), |acc, v| v(&acc))
}
let rules = vec![not_empty(), max_length(50)];
assert!(validate("hello", &rules).is_ok());
assert!(validate("", &rules).is_err());