Function Composition
Composition is what makes Haskell code feel like Haskell. You take a small function, then another, then glue them together with . until the pipeline does the whole job. When done well, the result reads like a recipe. When done badly, it reads like a license plate.
The mechanics are trivial; the judgment is everything. This page covers the operator itself, the "point-free" style it enables, when point-free helps and when it hurts, the left-to-right alternatives >>> and <<<, and the readability tradeoffs you'll keep relitigating with your team.
The . Operator, Properly
(.) :: (b -> c) -> (a -> b) -> (a -> c)
(f . g) x = f (g x)
It's right-associative and reads right to left: f . g . h is f . (g . h), and applied to x it's f (g (h x)). The function on the right runs first.
A small example:
import Data.Char (toUpper)
shout :: String -> String
shout = map toUpper . reverse
-- shout "hello" == "OLLEH"
reverse runs first, then map toUpper. This is the same as:
shout :: String -> String
shout s = map toUpper (reverse s)
The composed version omits the explicit s. That omission has a name.
Point-Free Style
"Point-free" means defining a function without naming its arguments ("points"). It's a stylistic choice, not a correctness one.
-- Pointful
sumOfSquares :: [Int] -> Int
sumOfSquares xs = sum (map (^ 2) xs)
-- Point-free
sumOfSquares :: [Int] -> Int
sumOfSquares = sum . map (^ 2)
Both produce the same code after compilation. The point-free version is shorter and emphasizes the pipeline structure. For simple two-or-three-step pipelines, this is almost always the better form.
The transformation from pointful to point-free is called eta reduction. The rule is: if f x = g x, then f = g. You can drop the trailing argument on both sides.
-- Step 1: pointful
isAdult xs = filter (\x -> age x >= 18) xs
-- Step 2: drop xs (eta reduction)
isAdult = filter (\x -> age x >= 18)
-- Step 3: rewrite the lambda using composition and a section
isAdult = filter ((>= 18) . age)
GHC will sometimes warn about an "eta-reducible" function via hlint. The warning is a suggestion, not a rule.
When to Use Point-Free
Point-free shines when:
- The pipeline has 2-4 stages and each stage is a recognized standard function.
- The composed function is the answer to "what is this thing fundamentally?" rather than "what does it do step by step."
- The argument has an obvious shape and naming it adds no information (
xsfor a list,sfor a string).
-- Good point-free
trim :: String -> String
trim = dropWhile isSpace . dropWhileEnd isSpace
countWords :: String -> Int
countWords = length . words
normalizeEmails :: [String] -> [String]
normalizeEmails = map (map toLower . dropWhile (== ' '))
These are single-purpose functions where the pipeline is the definition.
When to Avoid Point-Free
Point-free hurts when:
- The pipeline involves more than three or four steps and a reader has to mentally simulate types at each stage.
- You're passing a function to multiple positions and need
flip,curry,uncurry, or(.)chains to shuffle arguments. - The function has multiple arguments and dropping them makes the call site ambiguous.
The infamous example:
-- "Point-free Hell"
weirdSum :: [Int] -> [Int] -> Int
weirdSum = (sum .) . zipWith (+)
That's \xs ys -> sum (zipWith (+) xs ys). The (sum .) . zipWith (+) form is technically correct, but it requires understanding that ((sum .) . f) x y = sum (f x y), which most readers will work out only with effort. Just write the arguments:
weirdSum :: [Int] -> [Int] -> Int
weirdSum xs ys = sum (zipWith (+) xs ys)
A good rule: if you'd reach for a tool like pointfree.io to derive the form, you've probably crossed the line.
Eta Reduction in Practice
Eta reduction interacts with type inference and laziness in subtle ways. The most common gotcha:
-- This works
greet :: String -> IO ()
greet name = putStrLn ("Hello, " ++ name)
-- This also works
greet :: String -> IO ()
greet = putStrLn . ("Hello, " ++)
But sometimes eta reducing changes when work happens, especially with monomorphism restrictions or when the function involves let-bindings:
-- expensive is computed once per call to f
f :: Int -> Int
f x = let expensive = sum [1..1000000]
in expensive + x
-- After eta reduction, expensive is computed once, period
-- (memoized in the closure)
f :: Int -> Int
f = let expensive = sum [1..1000000]
in (+ expensive)
This is occasionally a feature (memoization for free) and occasionally a footgun (unexpected sharing causing space leaks). When in doubt, leave the argument in.
>>> and <<< from Control.Category
Control.Category provides left-to-right and right-to-left composition operators:
import Control.Category ((>>>), (<<<))
-- (>>>) is reverse composition: f >>> g == g . f
-- (<<<) is the same as . from Prelude
-- Right-to-left (Prelude .)
shout :: String -> String
shout = map toUpper . reverse
-- Left-to-right (>>>)
shout :: String -> String
shout = reverse >>> map toUpper
>>> reads like a Unix pipe. Some teams prefer it for that reason. The Mercury codebase uses both, and Tweag's blog has argued for >>> in pipeline-heavy code because it matches reading order in English.
There's a complication: importing Control.Category traditionally clashed with Prelude.id and Prelude.. Modern Haskell handles this with import Control.Category ((>>>)) to take just the operator. If you want Control.Category.id and (.) to override Prelude (relevant for arrow-style code), use:
import Prelude hiding (id, (.))
import Control.Category
For day-to-day use, just import (>>>) and leave the rest alone.
Building Pipelines
Real pipelines combine ., $, and named bindings. Here's a parser-like example:
import Data.Char (isSpace, isDigit)
import Data.List (dropWhileEnd)
-- Strip comments, whitespace, and split on commas
parseCSVLine :: String -> [String]
parseCSVLine = filter (not . null)
. map trim
. splitOn ','
. takeWhile (/= '#')
where
trim = dropWhile isSpace . dropWhileEnd isSpace
splitOn :: Char -> String -> [String]
splitOn c s = case break (== c) s of
(chunk, []) -> [chunk]
(chunk, _:rest) -> chunk : splitOn c rest
Reading bottom to top: take everything before a #, split on commas, trim each piece, drop empty pieces. The pipeline mirrors the explanation, which is exactly what you want.
When the pipeline gets longer, breaking at composition operators helps:
processLog :: [String] -> Report
processLog = summarize
. groupByUser
. filter isError
. map parse
. filter (not . isComment)
Each stage on its own line, aligned at ., scans like a list of steps. This is the "point-free pays off" sweet spot.
Readability Tradeoffs
There's no universal answer. A few heuristics that hold up:
Prefer point-free for "what" definitions, pointful for "how" definitions. wordCount = length . words is what word-counting is. A function with branching logic, accumulated state, or side-effect orchestration is a how, and naming the arguments makes the steps visible.
Prefer point-free when the function is a leaf in a larger pipeline. If validateEmail is itself going to be composed into validateUser, the cleaner its definition, the cleaner the bigger pipeline.
Don't optimize for "elegant." Optimize for the next person reading at 2am. That person is sometimes you.
A pointful version with a where clause is often the best of both worlds:
processLog :: [String] -> Report
processLog logs = summarize (groupByUser errors)
where
errors = filter isError parsed
parsed = map parse (filter (not . isComment) logs)
Names like errors and parsed document the pipeline at the cost of a few extra lines.
Common Pitfalls
(.) vs ($) confusion. f . g $ x is f (g x). f . g x is a type error (you're trying to compose f with the result of g x, which is a value, not a function).
Eta-reducing into the monomorphism restriction. Removing the argument can change a polymorphic function into a monomorphic one, depending on context. If you get a confusing type error after eta reducing, put the argument back or add an explicit type signature.
Composing with multi-argument functions. (.) . (.) exists and "works," but you'll regret writing it. If you need \x y -> f (g x y), just write that.
Mixing >>> and . in the same module. Pick one. Switching back and forth makes readers parse direction every time.
Key Takeaways
(.) is right-to-left composition: the function on the right runs first. Point-free style drops named arguments via eta reduction and makes pipelines read like recipes. Use it for short, single-purpose functions where the composition is the definition. Avoid it when arguments need shuffling or the pipeline gets longer than four steps. >>> from Control.Category is left-to-right composition for teams that prefer pipe-shaped code. The goal isn't shorter code; it's code where the pipeline structure matches how you'd describe the function in plain English.