Haskell ApplicativeDo and Irrefutable Patterns: Is There a Hidden Performance Cost?
When writing idiomatic Haskell, developers often turn to -XApplicativeDo to regain readable do-notation syntax for types that only implement Applicative (or types like Validation and Concurrently where applicative execution is strictly preferred over monadic chaining). However, many encounter a familiar roadblock: pattern matching inside the do block causes the compiler to revert to monadic bind (>>=) unless an irrefutable pattern (~) is introduced.
This leads to an important question: Does using irrefutable patterns with ApplicativeDo introduce performance penalties, unnecessary thunks, or space leaks?
The Problem: Why ApplicativeDo Needs Irrefutable Matches
Consider the following types and actions:
data FileHierarchy = FileHierarchy
{ directoryName :: FilePath
, directoryContents :: [FilePath]
}
getUserAttributes :: App (String, [Permission])
getCurrentDirectoryInfo :: App FileHierarchy
If you write a straightforward do block:
countUserWritableFiles :: App Int
countUserWritableFiles = do
(name, permissions) <- getUserAttributes
FileHierarchy{directoryContents = dirConts} <- getCurrentDirectoryInfo
pure $ length (filterWritable name permissions dirConts)
Even with ApplicativeDo turned on, GHC typically rejects this if App does not have a Monad instance. In standard Haskell do-notation desugaring, pattern matches are considered potentially refutable. Even for single-constructor product types (like tuples or single-constructor records), standard pattern binding uses a case expression under the hood that defaults to MonadFail behavior.
To force GHC to construct an applicative expression via (<$>) and (<*>), we mark the patterns as irrefutable with tildes (~):
countUserWritableFiles :: App Int
countUserWritableFiles = do
~(name, permissions) <- getUserAttributes
~FileHierarchy{directoryContents = dirConts} <- getCurrentDirectoryInfo
pure $ length (filterWritable name permissions dirConts)
What Does the Compiler Actually Generate?
To evaluate the performance impact, look at how both approaches are desugared by GHC.
1. Explicit Applicative Combinators
Using raw combinators:
countUserWritableFiles =
(\ (name, permissions) FileHierarchy{directoryContents = dirConts} ->
length (filterWritable name permissions dirConts)
) <$> getUserAttributes <*> getCurrentDirectoryInfo
A lambda pattern like \(a, b) -> ... desugars to an immediate case expression when the lambda arguments are applied:
\arg1 arg2 ->
case arg1 of
(name, permissions) ->
case arg2 of
FileHierarchy _ dirConts ->
length (filterWritable name permissions dirConts)
2. ApplicativeDo with Irrefutable Patterns
When you use ApplicativeDo with ~, the irrefutable pattern turns the matching into lazy field projections via let bindings:
\arg1 arg2 ->
let (name, permissions) = arg1
FileHierarchy _ dirConts = arg2
in length (filterWritable name permissions dirConts)
Does This Extra Laziness Hurt Performance?
In almost all real-world scenarios, no, there is no meaningful performance penalty or memory leak. Here is why:
1. GHC's Simplifier Inlines and Eliminates Projections
If you inspect the GHC Core output (using -ddump-simpl), you will notice that GHC's optimizer (at -O or -O2) recognizes when variables from a lazy let binding are immediately consumed in the lambda's body.
Because the body immediately evaluates length (filterWritable ...), which requires name, permissions, and dirConts, GHC's strictness analyzer transforms the lazy let projections directly into the exact same strict case analyses produced by explicit lambda pattern matching.
2. Short-Lived Scope
The danger of irrefutable patterns usually comes from retaining references to large composite structures inside long-lived thunks. For example, if you do:
let ~(largeData, _) = producePair in someLongRunningLoop largeData
The entire pair could remain in memory until largeData is forced.
In an applicative action, however, the results of the actions are merged and evaluated in the immediate continuation lambda. Once the applicative step runs, the intermediate container is deconstructed. If your return expression is strict or evaluated promptly, no runaway heap residency occurs.
Edge Cases to Keep in Mind
While the generated Core is typically identical after optimization, there are two caveats to keep in mind:
- Uncaught
bottom(Partial Data): If an action returnsundefined(e.g.,error "fail"), an irrefutable pattern defers the crash until the specific field is evaluated. With explicit patterns, evaluation crashes immediately when entering the lambda. In practice, this difference rarely matters unless your code relies on exact exception-ordering semantics. - Unused Record Fields: If your record contains large fields and you only project one tiny field without forcing the rest, a lazy binding might retain the root record longer if the result of the pure expression is kept as an unevaluated thunk. If this is a concern, force the fields explicitly using
BangPatternsorseqin the return statement.
Conclusion
Using ApplicativeDo with irrefutable pattern matches is not a performance pitfall. GHC's optimizer normalizes the desugared lazy bindings into identical Core compared to explicit (<*>) chains, giving you the best of both worlds: clean, linear do-notation syntax without runtime performance degradation.