Neodrop
Create
FeedDiscoverMy ChannelsAuto-publishJoin Our Discord
FeedDiscoverCreateMy Channels
Early return, and nothing else: an effect-system deep read
2026-10-12

Early return, and nothing else: an effect-system deep read

A deep read of Oleksii Divak's 3 October 2026 essay, which strips a production Haskell codebase of its effect system, finds a single use left — early return — and rebuilds it in plain IO with a scoped, typed exception.

Functional Programming Deep Dives
Functional Programming Deep Dives@Gidon Kaminer
Oleksii Divak published You don't need an effect system on 3 October 2026. 1 The post is an experiment in subtraction. Divak got permission to rewrite an old production Haskell codebase, and he asked it, one feature at a time, what its effect system was doing there.
An effect system is a library that records a program's side effects in its types, so a function's signature states what it reads, writes or throws. Divak threw out nearly every feature of the one in his code and found a single use left: error handling, which turned out to be early return under another name. The rest of the post rebuilds that one capability out of plain IO and a handful of small types.
For anyone deciding whether to build on an effect system, the post supplies a checklist: each job the abstraction claims, and what a plain IO codebase needs for it.

What the abstraction is supposed to do

The community's own account of the abstraction was narrower than he expected. In a long thread on the Haskell Discourse forum, its goals had been reduced to tracking effects, mocking, and internal consistency. 2 Divak counts at least ten effect systems in circulation, all at odds with one another and, to an outsider, interchangeable. 1
He then put five questions to his own code.
  • Passing arguments around. He can do that by hand.
  • A safer codebase. Effect tracking still permits a handler to perform arbitrary IO, to import unsafePerformIO, or to sum an infinite list. 3
  • Use outside IO. For pure code that mutates, the ST monad already covers it.
  • Tracking IO as its own effect. Random-number generation is scattered through the code, and he would rather not thread it. 4
  • Higher-order effects. His effects stay independent of one another.

The one thing left: early return

One use survived the subtraction. Early return is a recurring question in Haskell, and the standing answers are three. Make the error the last statement, threading an Either or a continuation, and the code becomes a staircase that drifts rightward. Throw an exception, which Divak likens to burying a landmine in the yard. Reach for an effect system, and you are paying for one of those two.
His finding is that the third option reduces to the first two. ExceptT, the standard error-handling monad transformer, threads an Either through every action. 5 Divak calls that approach very inefficient, with a reputation for composing badly with other effects, so he kept the exception and set out to make it type-safe.

A typed exception with a scoped handle

He wanted a given error to be throwable only by code holding a specific handle. The shape he reached for is bracket, which solved the same problem for file handles and raw pointers decades ago. 6 Here the scoped value lives at the type level.
{-# LANGUAGE RoleAnnotations #-}
module Early (Early, leave, runEarly) where

import Control.Exception
import Data.Typeable

type role Early nominal
data Early a = Early

data ReturningEarly a = ReturningEarly a

instance Typeable e => Exception (ReturningEarly e) where
  displayException (ReturningEarly _) = "Early return exception"

leave :: Typeable e => Early e -> e -> IO a
leave _Early e = throwIO $ ReturningEarly e

runEarly :: Typeable e => (Early e -> IO a) -> IO (Either e a)
runEarly f = catch (Right <$> f Early) (\(ReturningEarly e) -> pure $ Left e)
runEarly opens a scope and hands the body a token whose type names the error that body may raise. leave is the only way to use the token, and a catch at the boundary turns the private exception into an Either. The token's error type sits in a nominal role, which pins each handle to its own error. 1
Call sites stay ordinary IO:
example :: Early () -> IO Bool
example early = do
  putStrLn "Ran this action"
  when True $ do
    leave early ()
    putStrLn "Didn't run this action"
  pure True
Wrapped in runEarly, that prints Ran this action and returns Left (), leaving the second putStrLn unexecuted. 1 Divak says the code inherits the caveats that bracket already carries.

An effect is a value you pass

The remaining services came back the same way. His working definition: an effect is a data type that bridges an interface and its implementations, with the constructor kept private. The handle pattern's Database handle is the model. 7
Mocking shows the shape most clearly, because a mockable effect is a record of functions:
data Database = Database
  { createUser  :: Text -> IO (Maybe User)
  , getUserMail :: User -> IO [Mail]
  }

runRealDatabase :: RealDatabase -> Database
runRealDatabase db = Database
  { createUser  = Database.createUser db
  , getUserMail = Database.getUserMail db
  }
An effect system would carry the same service as a constraint, something like Database :> es => ... -> Eff es a. Passing the record as an argument does two things he values. It sidesteps the plugin an effect system needs to disambiguate, reorder or reinterpret overlapping effects. 8 And the service stays visible as a value, where a constraint would have hidden it behind a type class.

Where plain IO looked weakest

Stack three services and the result type threatens to nest one Either per service, pushing the success value one layer deeper with each addition:
IO (Either FooError (Either BarError (Either QuxError a)))
Divak has each runner lift its own error into an extensible sum instead. The success value then sits one layer from the outside however many services are stacked:
IO (Either (Either (Either (Either Void QuxError) BarError) FooError) a)
Two panels. Left: three nested rounded rectangles labelled FooError, BarError and QuxError around a coral success chip. Right: one rounded box labelled Either holding a stacked group of three error chips beside the coral success chip.
Two panels. Left: three nested rounded rectangles labelled FooError, BarError and QuxError around a coral success chip. Right: one rounded box labelled Either holding a stacked group of three error chips beside the coral success chip.
Our own diagram, not from the post. Left: three stacked runners nest the success value one layer deeper per service. Right: the same three errors collected into one sum keep the success value one layer from the outside. The post's two type signatures are Either FooError (Either BarError (Either QuxError a)) and Either (Either (Either (Either Void QuxError) BarError) FooError) a.

What the rewrite contained

The services themselves were four ordinary things. 1
  • Configuration is read once at startup and passed as an argument; wrapping it in a "reader" effect adds only redundant wrapping.
  • Logging is a mockable effect's special case — newtype Logger = Logger (Builder -> IO ()), built by a runLogger that writes to a handle.
  • Resources get the bracket treatment. A Database wraps one connection, and runDatabase uses mask and unmask so the same cleanup runs whichever way the body exits. Driver failures funnel through a DatabaseException carrying a CallStack.
  • Two services of the same kind are told apart at the call site by newtyping, so a HelperDatabase is just a second Database.
At the top of the stack, the endpoint takes one argument per effect:
endpoint
  :: AuthConf -> ServiceConf -> Logger -> Metrics -> Early ServerError
  -> Database -> Cache -> Messaging -> AuthHeader -> EndpointRequest
  -> IO EndpointResponse
Divak rejects the two popular remedies for that signature, nesting the handles or collecting them into one record, because both stop telling you which statement uses which effect. His alternative leans on a compiler warning: with -Wunused-matches, the Glasgow Haskell Compiler reports an argument the function leaves untouched, so an unused effect shows up in the build. 9 To make that warning meaningful, he arranges the code so each statement touches at most one effect.
createUser :: Logger -> Early ServerError -> Database -> Text -> IO User
createUser logger early database name = do
  mayUser <- newUser database name
  case mayUser of
    Just user -> pure user
    Nothing   -> do
      note logger "Could not create a user entry"
      leave early err500
The same property helps in reverse: to find what raises a DatabaseError, you look for the functions that take the Database argument. One caveat comes with servant, a type-level web framework for Haskell — passing effects as arguments makes servant's hoisting unusable, so the stack has to be invoked inside each endpoint by hand. 1

What it costs, and where the argument stops

Divak concedes one unique power to effect systems: they let a handler act between the lines of code, a place a value passed as an argument leaves out of reach. The rest is a trade he is glad to make. Effects in plain IO run on any Haskell 98 or later compiler, bring no new packages into the build, produce type errors of the ordinary kind, and compile and run fast. The cost is verbosity in functions that use many effects.
The argument is one engineer's report on one codebase, and the post frames it that way. His effects stayed independent, which is where higher-order effects earn their keep. Two footnotes mark the edge of his scope. Whether Haskell can separate safe code from unsafe remains an open problem. 10 And early exit inside ST has been sketched by Oleg Kiselyov and proposed as a language extension. 1112

Verbatim quotes

"Like most of the community, I couldn't properly formulate what an effect system does. Yes, I know there are at least ten of them, all at odds with one another, yet seemingly completely interchangeable."
"After throwing out pretty much every single feature, I finally found the one thing I was using the effect system for: error handling."
"There is only one unique feature effect systems — or, more generally, custom monads designed to supercede IO — provide: they can do anything in between the lines [of code]. And I don't think that's a good thing."
— Oleksii Divak, You don't need an effect system

References

  1. 1
    You don't need an effect system

    burningwitness.github.io

  2. 2
    Why use an effect system?

    discourse.haskell.org

  3. 3
    System.IO.Unsafe

    hackage.haskell.org

  4. 4
    System.Random.Stateful

    hackage.haskell.org

  5. 5
    Control.Monad.Trans.Except

    hackage.haskell.org

  6. 6
    Bracket pattern

    wiki.haskell.org

  7. 7
    Handle pattern

    jaspervdj.be

  8. 8
    polysemy

    hackage.haskell.org

  9. 9
    GHC User's Guide: warnings

    downloads.haskell.org

  10. 10
    Deprecating Safe Haskell…?

    discourse.haskell.org

  11. 11
    ST with early exit

    oleg.fi

  12. 12
    ghc-proposals issue 503

    github.com

Functional Programming Deep Dives
Functional Programming Deep Dives@Gidon Kaminer

This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.

Related content

  • What the abstraction is supposed to do
  • The one thing left: early return
  • A typed exception with a scoped handle
  • An effect is a value you pass
  • Where plain IO looked weakest
  • What the rewrite contained
  • What it costs, and where the argument stops
  • Verbatim quotes