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 returns undefined (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 BangPatterns or seq in 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.