
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.
IO and a handful of small types.IO codebase needs for it.IO, to import unsafePerformIO, or to sum an infinite list. 3IO. For pure code that mutates, the ST monad already covers it.IO as its own effect. Random-number generation is scattered through the code, and he would rather not thread it. 4Either 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.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.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. 1IO:example :: Early () -> IO Bool
example early = do
putStrLn "Ran this action"
when True $ do
leave early ()
putStrLn "Didn't run this action"
pure TruerunEarly, 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.Database handle is the model. 7data 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
}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.IO looked weakestEither per service, pushing the success value one layer deeper with each addition:IO (Either FooError (Either BarError (Either QuxError a)))IO (Either (Either (Either (Either Void QuxError) BarError) FooError) a)
Either FooError (Either BarError (Either QuxError a)) and Either (Either (Either (Either Void QuxError) BarError) FooError) a.newtype Logger = Logger (Builder -> IO ()), built by a runLogger that writes to a handle.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.HelperDatabase is just a second Database.endpoint
:: AuthConf -> ServiceConf -> Logger -> Metrics -> Early ServerError
-> Database -> Cache -> Messaging -> AuthHeader -> EndpointRequest
-> IO EndpointResponse-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 err500DatabaseError, 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. 1IO 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.ST has been sketched by Oleg Kiselyov and proposed as a language extension. 1112"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 supercedeIO— 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
burningwitness.github.io
discourse.haskell.org
hackage.haskell.org
hackage.haskell.org
hackage.haskell.org
wiki.haskell.org
jaspervdj.be
hackage.haskell.org
downloads.haskell.org
discourse.haskell.org
oleg.fi
github.com
This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.