– Tom Ellis, June 2026
This article is part of a collection
IOScopedRefHaskell is missing a mutable reference type similar to what Java calls a scoped value, Python a context variable and Common Lisp a “special” variable. Such a reference can be modified within a delimited scope and the modification is observable only within that scope, regardless of whether the reference is shared to child threads and regardless of whether exceptions are thrown that take execution back out of the scope of modification.
IOScopedRefThe reference type, which we’ll call IOScopedRef, would be similar
to
IORef
in that any modifications would be required to take place within IO.
It would differ from IORef in that modifications to it would only be
observable within a particular scope. As we will see, requiring
modifications to be of this form allows IOScopedRef to have
properties that IORef does not have.
Specifically, whereas to modify an IORef you have
modifyIORef :: IORef a -> (a -> a) -> IO ()for IOScopedRef you would have
modifyIOScopedRef :: IOScopedRef a -> (a -> a) -> (IO r -> IO r)
modifyIOScopedRef ref f body = ...and the effect of f on ref would be observable within body only,
and no longer once it has finished executing. For example, with
IORef we observe modifications due to any intervening executed code:
modifyIORef ref (const "hello")
i1 <- readIORef
-- i1 == "hello"
do
i2 <- modifyIORef ref (const "world")
-- i2 == "world"
... no more modifies ...
i3 <- readIORef ref
-- i3 == "world"but with IOScopedRef we do not:
modifyIOScopedRef ref (const "hello") $ do
i1 <- readIOScopedRef ref
-- i1 == "hello"
modifyIOScopedRef ref (const "world") $ do
i2 <- readIOScopedRef ref
-- i2 == "world"
... regardess of any more modfies here ...
i3 <- readIOScopedRef ref
-- i3 == "hello"
...That is to say, when exiting a modifyIOScopedRef block, the value of
ref would be reset to what its value was upon entering it. This
would happen regardless of whether the block was left by normal
termination or by abnormal termination (exception).
Furthermore, when an IORef is shared to child threads, modifications
in one thread can be observed by another thread. By contrast,
modifications to an IOScopedRef in one thread would not be observed
by another thread. For example, the first thread here might read
"Second thread", depending on the interleaving of the two threads:
modifyIORef ref (const "Before threads")
concurrently_
( do
i1 <- readIORef ref
-- i1 might be "Second thread", depending on how
-- the threads interleave
...
)
( do
modifyIORef ref (const "Second thread")
...
)but with IOScopedRef the threads would be guaranteed to not
interfere, regardless of the interleaving:
modifyIOScopedRef ref (const "Before threads") $ do
concurrently_
( do
i1 <- readIOScopedRef ref
-- i1 is guaranteed to be "Before threads",
-- regardless of how the threads interleave
...
)
( do
modifyIOScopedRef ref (const "Second thread") $ do
...
)IOScopedRefFirst let’s write out the basic API for IORef and then see the
analogous operations in a basic API for IOScopedRef. Note that, in
the same way that the operations on IORef correspond to operations
in the StateT monad transformer, operations on IOScopedRef
correspond to operations in the ReaderT monad transformer.
type IORef :: Type -> Type
-- Can be used to write equivalent of StateT's runStateT
newIORef :: a -> IO (IORef a)
-- Like StateT's get
readIORef :: IORef a -> IO a
-- Like StateT's modify
-- (can be used to implement writeIORef)
modifyIORef :: (a -> a) -> IORef a -> IO ()type IOScopedRef :: Type -> Type
-- Like ReaderT's runReaderT
-- (no direct equivalent of newIORef)
withIOScopedRef :: a -> (IOScopedRef a -> IO r) -> IO r
-- Like ReaderT's ask
readIOScopedRef :: IOScopedRef a -> IO a
-- Like ReaderT's local
-- (there is no direct equivalent of writeIORef)
modifyIOScopedRef :: (a -> a) -> IOScopedRef a -> IO r -> IO rIf you are thinking “the IOScopedRef API is a restricted form of
the IORef API” then you are right. But IOScopedRef cannot be
implemented using IORef, because the API restrictions allow
IOScopedRef to have additional properties that IORef does not
have. IOScopedRef is a natural counterpart to IORef but neither
can be implemented in terms of the other. IOScopedRef is genuinely
a feature missing from Haskell. Read on to find out more.
Next let’s see how to put the IOScopedRef API to use in a library,
in this case a logging library. The logging library allows us to use
logMsg to log Strings as log messages, specifying an integer
“severity” for each, and to use modifySeverity to locally modify the
severity of messages logged within a block. An IOScopedRef stores
the severity adjustment; logMsg uses readIOScopedRef to read the
current value of the reference; modifySeverity uses
modifyIOScopedRef to locally modify the severity adjustment.
type Severity = Int
data Logger = Logger
{ logMsg ::
Severity -> String -> IO (),
modifySeverity ::
forall a. (Severity -> Severity) -> IO a -> IO a
}
withStdoutLogger :: Severity -> (Logger -> IO r) -> IO r
withStdoutLogger initial k =
withIOScopedRef initial $ \ref -> do
k
Logger
{ logMsg = \lvl msg -> do
adj <- readIOScopedRef ref
putStrLn ("[" ++ show (lvl + adj) ++ "] " ++ msg),
modifySeverity = \f action ->
modifyIOScopedRef f ref action
}By way of example let’s write some code to get a user and the data of the user, locally modifying the severity of log messages when the user we are dealing with is a VIP.
loggerExample :: IO ()
loggerExample = withStdoutLogger 0 writeUserData
writeUserData :: Logger -> IO ()
writeUserData logger = do
logMsg logger 1 "Getting user"
user <- getUser
logMsg logger 1 ("Is VIP: " <> show (isVip user))
let modification = if isVip user then (+ 10) else id
d <- modifySeverity logger modification $ do
logMsg logger 0 "Getting data"
getData user
writeData d
logMsg logger 0 "Done"When we are dealing with a VIP the corresponding message is logged at a higher severity:
ghci> loggerExample
[1] Getting user
[1] Is VIP: True
[10] Getting data
[0] Done
We observed above that the IOScopeRef API was a restricted form of
the IORef API, but nonetheless that we could not implement it using
IORef. Now let’s see why not. The first thing we might try is to
store the value of the reference in an IORef, modifying the IORef
when we enter the body and restoring the original value when we leave.
newtype IOScopedRef a = MkIOScopedRef (Data.IORef.IORef a)
withIOScopedRef :: a -> (IOScopedRef a -> IO r) -> IO r
withIOScopedRef a body = do
ref <- Data.IORef.newIORef a
body (MkIOScopedRef ref)
readIOScopedRef :: IOScopedRef a -> IO a
readIOScopedRef (MkIOScopedRef ref) = Data.IORef.readIORef ref
-- WARNING: This does not have the desired behavior!
modifyIOScopedRefBad :: (a -> a) -> IOScopedRef a -> IO r -> IO r
modifyIOScopedRefBad f (MkIOScopedRef ref) body = do
-- Get the original value
orig <- Data.IORef.readIORef ref
-- Modify the reference
Data.IORef.modifyIORef ref f
-- Run the body
r <- body
-- Restore the original value
Data.IORef.writeIORef ref orig
-- Return the result of the body
pure rThis implementation will work for loggerExample above but it does
not have the desired behavior in all cases: it is not exception safe.
For example, the following client code which throws an exception in
the body
loggerExampleException :: IO ()
loggerExampleException = withStdoutLogger 0 $ \logger -> do
logMsg logger 1 "Getting user"
user <- getUser
logMsg logger 1 ("Is VIP: " <> show (isVip user))
let modification = if isVip user then (+ 10) else id
d <-
Control.Exception.handle
(\Exception -> logMsg logger 1 "Got exception")
( modifySeverity logger modification $ do
logMsg logger 0 "Getting data"
Control.Exception.throwIO Exception
getData user
)
writeData d
logMsg logger 0 "Done"gives the following output, which is wrong because the “Getting data” and “Done” log messages are logged at 10 more than the severity they should be.
-- > loggerExampleException
-- [1] Getting user
-- [1] Is VIP: True
-- [10] Getting data
-- [11] Got exception
-- [10] Done
The severity adjustment should have been reset to 0 when we left
modifySeverity block.
bracketWhy did this happen? Because when the exception was thrown, execution
skipped past the Data.IORef.writeIORef ref orig line that is
supposed to restore the original value, 0. Haskell already has a
way of tackling that problem:
bracket. It
allows us to define a cleanup action that runs regardless of whether
the body exited normally or abnormally (via exception). We might try
to use it as follows. We will see that it fixes the exception problem
yet suffers from another problem.
-- WARNING: This does not have the desired behavior!
modifyIOScopedRefStillBad ::
(a -> a) -> IOScopedRef a -> IO r -> IO r
modifyIOScopedRefStillBad f (MkIOScopedRef ref) body =
Control.Exception.bracket
(Data.IORef.readIORef ref)
-- Cleanup action
(Data.IORef.writeIORef ref)
-- Body
( \_ -> do
Data.IORef.modifyIORef ref f
body
)Under that implementation loggerExampleException gives the desired
output. The severity adjustment is reset to 0 on leaving the
modifySeverity block, even though that was via exception:
-- > loggerExampleException
-- [1] Getting user
-- [1] Is VIP: True
-- [10] Getting data
-- [1] Got exception
-- [0] Done
But there is another problem: it is not thread safe. Consider a
function which uses the same Logger concurrently in two child
threads:
loggerExampleConcurrently :: IO ()
loggerExampleConcurrently = withStdoutLogger 0 $ \logger -> do
logMsg logger 1 "Getting user"
user <- getUser
logMsg logger 1 ("Is VIP: " <> show (isVip user))
let modification = if isVip user then (+ 10) else id
(d, ()) <-
Control.Concurrent.Async.concurrently
( modifySeverity logger modification $ do
logMsg logger 0 "Getting data"
getData user
)
( -- Do some unimportant background processing
modifySeverity logger (subtract 100) $ do
Control.Concurrent.threadDelay 1000
)
writeData d
logMsg logger 0 "Done"The output is wrong in a different way. The modification to reduce the severity for the background processing thread affected the concurrent thread getting the VIP data!
-- > loggerExampleConcurrently
-- [1] Getting user
-- [1] Is VIP: True
-- [-90] Getting data
-- [0] Done
We want the output to be the same as our original loggerExample,
that is:
-- > loggerExampleConcurrently
-- [1] Getting user
-- [1] Is VIP: True
-- [10] Getting data
-- [0] Done
To achieve this, thread creation primitives must behave in a way that
ensures that local modifications to IOScopedRefs within threads are
not observable to concurrently executing threads. Because such
modifications are only observable locally, this means that the
interaction between the semantics of thread creation and of
IOScopedRef are good. In particular, in a function call like
myFunction
( \arg -> ...
modifyIOScopedRef ref f $ do
... body ...
)the values of ref observed in body would not depend on whether
myFunction chooses to execute its argument in a new thread.
IOScopedRefI believe that IOScopedRef cannot be implemented using GHC’s
existing mutable reference primitives (such as IORef, MVar,
TVar); it requires something genuinely new. There is a GHC proposal
to add a similar feature (scoped thread-locals
proposal)
and I proposed the IOScopedRef API in particular in a discussion
comment.
Here ends the introduction to IOScopedRef. The article series
continues with “A reference implementation of
IOScopedRef”.
The context
package provides
a partial implementation of a type equivalent to IOScopedRef. Its
API is as follows
-- Equivalent to `IOScopedRef ctx`
type Store ctx
-- Equivalent to `withIOScopedRef`
withNonEmptyStore :: MonadIO m => ctx -> (Store ctx -> m a) -> m a
-- Equivalent to `readIOScopedRef`
mine :: MonadIO m => Store ctx -> m ctx
-- Equivalent to `modifyIOScopedRef`
adjust :: MonadIO m => Store ctx -> (ctx -> ctx) -> m a -> m aIt cannot serve as a complete implementation of IOScopedRef because
a Store when read in a child thread created with standard thread
creation primitives does not pick up modifications that were made to
it in the parent thread. For example:
Context.withNonEmptyStore "Hello" $ \store -> do
x1 <- Context.mine store
-- "Hello", as desired
putStrLn x1
Context.adjust store (++ " world") $ do
x2 <- Context.mine store
-- "Hello world", as desired
putStrLn x2
Control.Concurrent.Async.concurrently_
(pure ())
( do
x3 <- Context.mine store
-- "Hello", but it should be "Hello world"
putStrLn x3
)As a convenience, context provides its own thread-creation
operations in
Context.Concurrent
which are Store-aware and propagate the desired value of all
Stores in scope to child threads. For more details on this sort of
behaviour see Fork-fragile reader-like operations in
Haskell.
At least Java, Python and Common Lisp have primitives that have
similar behavior to IOScopedRef, scoped values, context
variables and “special” variables respectively. The following is a
very basic survey of the basic operations available for each
language’s primitive.
Proposed for Haskell
IOScopedRefwithIOScopedRef "v" (\var -> ...)modifyIOScopedRef var f ...readIOScopedRef varJava
ScopedValueScopedValue.where(KEY, init)ScopedValue.where(KEY, x).run(() -> ...)KEY.get()Python
ContextVarvar = ContextVar("v")token = var.set(x) + try: ... finally: var.reset(token)var.get()Racket
make-parameter(define var (make-parameter "v"))(parametrize ([var x]) ...)varCommon Lisp
defvar (by
convention the variable name starts and ends with *)(defvar *var* "v")(let ((*var* x)) ...)*var*Numpy uses a
ContextVar
to allow local overriding of error handling
behaviour,
i.e. what should happen when a numeric operation returns inf
(infinity) or NaN (not a number).
mpmath
and
decimal
allow locally overriding the desired numeric
precision.
Racket’s feature “security guards” uses it “parameter” feature to allow local overriding of security level. Although not through a “scoped variable” as such, PostgreSQL has a similar feature.
Thanks to Simon Peyton Jones and Alice (prophet) for helpful discussion and suggestions.