Skip to content
where IO meets pure: extracting and bracket
step 1/7

Reading — step 1 of 7

Learn

~4 min readPractical Haskell

Real Haskell code mixes pure logic with IO. The discipline: keep pure functions pure, isolate side effects to thin shells. Real World Haskell calls this "functional core, imperative shell."

Extracting from IO with <-

main :: IO ()
main = do
    line <- getLine               -- IO String, extracts to String
    let processed = process line  -- pure transformation
    putStrLn processed             -- IO ()
  • <- extracts the value from an IO action
  • let for pure bindings (no IO involved)

The shape: read input → call pure function → write output. Pure logic (process) is isolated and testable without IO.

Calling pure functions from IO

main :: IO ()
main = do
    nums <- map read . words <$> getLine     -- IO [Int]
    print (sum nums)                          -- IO ()
  • <$> is fmap — applies a pure function inside IO without unwrapping
  • map read . words is a pure transformation: String -> [Int]

return — wrapping pure values

lookupUser :: Int -> IO User
lookupUser id = do
    raw <- queryDB id
    let user = parseUser raw
    return user                  -- wraps in IO

return :: a -> IO a wraps a pure value into an IO action. Misleading name — it's NOT control flow like in C/Java. It's just "inject this pure value into the IO context."

Modern Haskell often uses pure (more general) instead of return.

bracket — guaranteed cleanup

For resources that need cleanup (files, sockets, locks), use bracket:

import Control.Exception (bracket)
import System.IO

main :: IO ()
main = do
    bracket
        (openFile "data.txt" ReadMode)        -- acquire
        hClose                                   -- release (always)
        (\h -> do                                -- use
            content <- hGetContents h
            putStrLn ("got " ++ show (length content) ++ " bytes")
        )

bracket acquire release use — guarantees release runs even if use throws. Like try/finally in other languages, but baked into the type system.

withFile is a higher-level wrapper:

import System.IO (withFile, IOMode(..))

main :: IO ()
main = do
    withFile "data.txt" ReadMode $ \h -> do
        content <- hGetContents h
        putStr content

The handle is auto-closed when the lambda exits.

try and catch — handling exceptions

import Control.Exception

main :: IO ()
main = do
    result <- try (readFile "missing.txt") :: IO (Either IOException String)
    case result of
        Right contents -> putStrLn contents
        Left e         -> putStrLn ("failed: " ++ show e)

try :: IO a -> IO (Either SomeException a) — runs the action, catching exceptions into Either.

catch :: IO a -> (SomeException -> IO a) -> IO a — handler-style.

result <- readFile "data.txt" `catch` \(_ :: IOException) ->
    return ""                    -- fallback on missing file

evaluate — force a thunk inside IO

import Control.Exception (evaluate)

main = do
    let x = 1 `div` 0              -- pure thunk; not yet evaluated
    -- x is fine here — Haskell is lazy
    result <- try (evaluate x) :: IO (Either SomeException Int)
    print result                   -- Left ...

evaluate forces a pure value to weak head normal form INSIDE IO. Useful when you want to catch pure exceptions (division by zero, head of empty list) — they normally only manifest when the value is used.

Functional core, imperative shell

Typical Haskell program shape:

main :: IO ()
main = do
    -- shell: read input
    contents <- readFile "input.txt"
    
    -- core: pure transformation
    let result = process contents
    
    -- shell: write output
    writeFile "output.txt" result

process :: String -> String
process = unlines . map transform . lines
  where
    transform = ...     -- pure logic, easily tested

The pure core is unit-testable without IO. The IO shell is small and obvious. Same pattern as functional design in any language.

Common mistakes

  • return confused with C-style return — it's just pure for IO. Doesn't end the function.
  • Forgetting <- vs let<- extracts from IO; let binds a pure value. Mixing them confuses compilers and readers.
  • unsafePerformIO for convenience — almost always wrong. Breaks purity guarantees in subtle ways.
  • Reading huge files lazily then accidentally retaining the handle — combine readFile with side effects carefully; consider strict alternatives.
  • Catching all exceptions — usually a code smell. Pattern-match on specific exception types.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…