Haskell List Pipelines and Effect Sequencing
How map, zip, filter, sequence, and Maybe turn PATH lookup into a pipeline of pure transformations around filesystem effects.
Executable lookup transforms an ordered list of directories into the first runnable file with a requested name. Most of that transformation is pure; only reading the environment and inspecting the filesystem require IO.
The shell implementation makes the stages visible:
findExecutableInPath :: String -> IO (Maybe FilePath)
findExecutableInPath target = do
path <- getEnv "PATH"
let pathList = splitSearchPath path
let pathFiles = map (</> target) pathList
let checks = map doesPathExist pathFiles
fileExists <- sequence checks
let pathFileExistCheck = zip pathFiles fileExists
let existingPaths = filter (\(_, exists) -> exists) pathFileExistCheck
let existingFilePaths = map fst existingPaths
permissions <- mapM getPermissions existingFilePaths
let executableCheck = map executable permissions
let executableFiles = zip existingFilePaths executableCheck
let executablePaths = filter (\(_, isExec) -> isExec) executableFiles
let validPaths = map fst executablePaths
case validPaths of
[] -> pure Nothing
path : _ -> pure (Just path)
This is a list pipeline interleaved with two effectful batches.
PATH is ordered data
On Unix-like systems, PATH is a search list separated by a platform-specific delimiter. splitSearchPath from System.FilePath respects that platform convention:
splitSearchPath "/usr/local/bin:/usr/bin:/bin"
-- ["/usr/local/bin", "/usr/bin", "/bin"]
Using words would be incorrect because PATH is not whitespace-separated. The list order matters: when two directories contain the same executable name, the first match shadows the later one.
(</>) joins a directory and a filename with the appropriate path separator:
map (</> "git") ["/usr/local/bin", "/usr/bin"]
-- ["/usr/local/bin/git", "/usr/bin/git"]
The partially applied expression (</> target) is a function waiting for its left operand. This is one of the everyday uses of currying: a two-argument function can be supplied one argument now and mapped over many values later.
Higher-order functions describe transformations
map applies one function to every element without changing the list shape:
map :: (a -> b) -> [a] -> [b]
filter retains elements that satisfy a predicate:
filter :: (a -> Bool) -> [a] -> [a]
The lambda in the PATH pipeline pattern-matches a tuple and ignores its first component:
\(_, exists) -> exists
The underscore means the path is not needed to decide whether the pair survives. fst later extracts the first component of each surviving tuple.
zip preserves the relationship between two aligned lists:
zip ["/bin/git", "/tmp/git"] [True, False]
-- [("/bin/git", True), ("/tmp/git", False)]
The alignment is positional. If the lists differ in length, zip silently truncates to the shorter one, so this pattern relies on an invariant: each Boolean result was produced from exactly one candidate path.
A list of actions is not an action returning a list
Mapping an effectful function does not execute it immediately:
map doesPathExist pathFiles :: [IO Bool]
The result is a pure list containing filesystem actions. sequence turns that structure inside out:
sequence :: [IO a] -> IO [a]
For the checks:
sequence checks :: IO [Bool]
The actions run in list order, and their results are collected in the same order. This order preservation is what makes the subsequent zip pathFiles fileExists valid.
mapM combines map and sequence:
mapM getPermissions existingFilePaths
is equivalent to:
sequence (map getPermissions existingFilePaths)
The more general modern spelling is traverse, but mapM makes the monadic effect explicit and is common in introductory Haskell code.
Maybe represents lookup failure
The lookup has two legitimate outcomes:
Nothing -- no executable candidate
Just path -- the first executable candidate
Returning IO (Maybe FilePath) composes both concerns. The outer IO records filesystem and environment access. The inner Maybe records possible absence. These layers mean different things and neither is redundant.
The final match preserves PATH precedence:
case validPaths of
[] -> pure Nothing
path : _ -> pure (Just path)
path : _ selects the first item and ignores the rest. The implementation checks every candidate before selecting the first, which is straightforward but does more filesystem work than necessary. A later version could inspect candidates one at a time and stop on the first executable. The observable rule would remain the same: ordered search, first valid match.
Pure core, effectful edges
The pipeline exposes a recurring functional-programming architecture:
get PATH IO String
-> split directories pure
-> construct candidates pure
-> inspect filesystem IO
-> pair and filter pure
-> inspect permissions IO
-> choose first result pure
Effects acquire facts about the world. Pure functions transform those facts. The distinction gives each stage a small type and makes intermediate values easy to inspect while learning how the program evaluates.