Railway-Oriented error handling for Haskell.
monad-rail implements Railway-Oriented Programming (ROP) — a functional pattern that makes error handling explicit and composable. Your computation runs on two tracks: success and failure. Once on the failure track, execution stops — unless you use <!> to run multiple validations in parallel and collect all their errors at once.
Add to your .cabal file:
build-depends:
monad-rail ^>=0.1.0.0Implement HasErrorInfo with errorPublicMessage — the only required method. Derive Data to get an automatic error code from the constructor name:
{-# LANGUAGE DeriveDataTypeable #-}
import Monad.Rail
data UserError
= NameEmpty
| EmailInvalid
| AgeTooLow
deriving (Show, Data)
instance HasErrorInfo UserError where
errorPublicMessage NameEmpty = "Name cannot be empty"
errorPublicMessage EmailInvalid = "Invalid email format"
errorPublicMessage AgeTooLow = "Must be at least 18 years old"
-- NameEmpty → { message: "Name cannot be empty", code: "NameEmpty" }
-- EmailInvalid → { message: "Invalid email format", code: "EmailInvalid" }
-- AgeTooLow → { message: "Must be at least 18 years old", code: "AgeTooLow" }When you need custom codes or extra per-constructor behaviour, override individual methods — see HasErrorInfo for the full pattern.
validateName :: String -> Rail ()
validateName name
| null name = throwError NameEmpty
| otherwise = pure ()
validateEmail :: String -> Rail ()
validateEmail email
| '@' `notElem` email = throwError EmailInvalid
| otherwise = pure ()
validateAge :: Int -> Rail ()
validateAge age
| age < 18 = throwError AgeTooLow
| otherwise = pure ()validateUser :: String -> String -> Int -> Rail ()
validateUser name email age = do
validateName name <!> validateEmail email <!> validateAge age
-- All three run regardless of failure.
-- If any fail, ALL errors are collected before stopping.
saveUser name email agemain :: IO ()
main = do
result <- runRail (validateUser "" "not-an-email" 16)
case result of
Right () ->
putStrLn "User saved!"
Left errors ->
-- Prints all 3 errors as a JSON array
print errorsOutput:
[
{"message":"Name cannot be empty","code":"NameEmpty"},
{"message":"Invalid email format","code":"EmailInvalid"},
{"message":"Must be at least 18 years old","code":"AgeTooLow"}
]The main type alias for railway computations:
type Rail a = RailT Failure IO aUse RailT directly if you need a different base monad.
Moves execution to the failure track with a single error:
throwError :: (HasErrorInfo e, Show e, Typeable e) => e -> RailT Failure m aAll subsequent steps in the do-block are skipped.
The key operator for Railway-Oriented Programming. Runs both sides regardless of failure and combines the errors:
| Left | Right | Result |
|---|---|---|
Right |
Right |
Right — continue |
Left e1 |
Right |
Left e1 — stop |
Right |
Left e2 |
Left e2 — stop |
Left e1 |
Left e2 |
Left (e1 <> e2) — stop, both errors |
Ideal for form validation, configuration checks, and any scenario where you want to report all problems at once.
Wraps any IO action that may throw exceptions and lifts it into the Railway:
tryRail :: HasCallStack => IO a -> Rail aIf the action throws, the exception is caught and converted to a SomeError wrapping an UnhandledException. This lets you bring ordinary IO operations into a Railway pipeline without manual exception handling.
-- File operations
readConfig :: FilePath -> Rail String
readConfig path = tryRail (readFile path)
-- Combined with validations
pipeline :: FilePath -> Rail ()
pipeline filePath = do
content <- tryRail (readFile filePath)
validateName content <!> validateEmail content
saveToDb contentLike tryRail, but lets you derive a domain-specific error code from the caught exception:
tryRailWithCode :: HasCallStack => (SomeException -> Text) -> IO a -> Rail aPass a constant function when the code is fixed, or inspect the exception to return different codes:
tryDb :: HasCallStack => IO a -> Rail a
tryDb = tryRailWithCode (const "DbError")
tryHttp :: HasCallStack => IO a -> Rail a
tryHttp = tryRailWithCode $ \ex ->
if "timeout" `T.isInfixOf` T.pack (displayException ex)
then "HttpTimeout"
else "HttpError"
pipeline :: Rail ()
pipeline = do
user <- tryDb (queryUser userId)
resp <- tryHttp (fetchProfile user)
pure ()Note: add
HasCallStackto your wrapper's own signature so the call stack is captured at each call site rather than frozen at the wrapper's definition.
The resulting error for a caught exception will have:
| Info | Field | Value |
|---|---|---|
PublicErrorInfo |
publicMessage |
"An unexpected error occurred" |
PublicErrorInfo |
code |
"UnhandledException" (customizable via tryRailWithCode or tryRailWithError) |
InternalErrorInfo |
internalMessage |
The exception message (logs only) |
InternalErrorInfo |
severity |
Critical |
InternalErrorInfo |
exception |
The original SomeException |
InternalErrorInfo |
callStack |
Haskell call chain at the tryRail call site |
Like tryRailWithCode, but derives the error code and public message from a HasErrorInfo value built from the caught exception:
tryRailWithError :: (HasCallStack, HasErrorInfo e) => (SomeException -> e) -> IO a -> Rail aThe error-building function receives the SomeException that was thrown, allowing the resulting error to carry information extracted from the exception itself. errorCode is used as the error code and errorPublicMessage as the public message.
{-# LANGUAGE DeriveDataTypeable #-}
data DbError = QueryFailed Text | ConnectionLost
deriving (Show, Data)
instance HasErrorInfo DbError where
errorPublicMessage (QueryFailed _) = "A database query failed"
errorPublicMessage ConnectionLost = "Lost connection to the database"
-- Always map to ConnectionLost, ignoring the exception:
safeQuery :: Rail [Row]
safeQuery = tryRailWithError (\_ -> ConnectionLost) runQuery
-- Inspect the exception to choose the right constructor:
safeQuery' :: Rail [Row]
safeQuery' = tryRailWithError (QueryFailed . T.pack . displayException) runQueryNote: add
HasCallStackto any wrapper's own signature so the call stack is captured at each call site rather than frozen at the wrapper's definition.
The error type produced by tryRail. It wraps SomeException and implements HasErrorInfo, so it works anywhere a Railway error is expected:
data UnhandledException = UnhandledException
{ unhandledCode :: Maybe Text
, unhandledException :: SomeException
, unhandledCallStack :: Maybe CallStack
, unhandledMessage :: Maybe Text
}When produced by tryRail, unhandledCode is Nothing (defaulting to "UnhandledException"), unhandledCallStack is captured automatically at the call site, and unhandledMessage defaults to Nothing (falling back to the generic public message "An unexpected error occurred").
Use throwUnhandledException when you catch exceptions yourself and the default code is sufficient:
import qualified Control.Exception as E
safeQuery :: Rail Row
safeQuery = do
result <- liftIO $ E.try runQuery
case result of
Right row -> pure row
Left ex -> throwUnhandledException exUse throwUnhandledExceptionWithCode when you need a domain-specific code — it also captures the call stack automatically:
safeQuery :: Rail Row
safeQuery = do
result <- liftIO $ E.try runQuery
case result of
Right row -> pure row
Left ex -> throwUnhandledExceptionWithCode "DbQueryFailed" exExecutes the computation and returns Either Failure a:
runRail :: Rail a -> IO (Either Failure a)The general form of runRail, for when your base monad is not IO:
runRailT :: Monad m => RailT e m a -> m (Either e a)Use it when RailT is stacked on top of another transformer, such as StateT or ReaderT:
import Control.Monad.State (StateT, runStateT)
data AppState = AppState { counter :: Int }
type AppRail a = RailT Failure (StateT AppState IO) a
runAppRail :: AppState -> AppRail a -> IO (Either Failure a, AppState)
runAppRail initialState = runStateT . runRailTTypeclass connecting your domain error types to the standard error format. Only errorPublicMessage is required — all other methods have sensible defaults:
class HasErrorInfo e where
errorPublicMessage :: e -> Text -- Required
errorCode :: e -> Text -- Default: constructor name via Data
errorDetails :: e -> Maybe SomeErrorDetails -- Default: Nothing
errorSeverity :: e -> ErrorSeverity -- Default: Error
errorInternalMessage :: e -> Maybe Text -- Default: Nothing
errorException :: e -> Maybe SomeException -- Default: Nothing
errorCallStack :: e -> Maybe CallStack -- Default: NothingUse publicErrorInfo and internalErrorInfo to assemble the corresponding records from any instance:
publicErrorInfo :: HasErrorInfo e => e -> PublicErrorInfo
internalErrorInfo :: HasErrorInfo e => e -> InternalErrorInfoDerive Data and implement errorPublicMessage. The errorCode default derives the error code from the constructor name via Data.toConstr:
{-# LANGUAGE DeriveDataTypeable #-}
data OrderError = ItemOutOfStock | PaymentDeclined
deriving (Show, Data)
instance HasErrorInfo OrderError where
errorPublicMessage ItemOutOfStock = "One or more items are out of stock"
errorPublicMessage PaymentDeclined = "Payment was declined"
-- errorCode = "ItemOutOfStock" or "PaymentDeclined"Note: the error code is the constructor name verbatim. Renaming a constructor silently changes its code, so treat constructor names as part of your public API contract.
Override any combination of methods when you need custom codes, errorDetails, severity, or internal context. Methods you do not override keep their defaults:
instance HasErrorInfo OrderError where
errorPublicMessage ItemOutOfStock = "One or more items are out of stock"
errorPublicMessage PaymentDeclined = "Payment was declined"
-- Custom codes
errorCode ItemOutOfStock = "OrderItemOutOfStock"
errorCode PaymentDeclined = "OrderPaymentDeclined"
-- Override severity for one constructor only
errorSeverity PaymentDeclined = Critical
-- Override internal message for one constructor
errorInternalMessage PaymentDeclined = Just "Stripe returned decline code: insufficient_funds"Error data is split into two records by visibility. Use the publicErrorInfo and internalErrorInfo functions to obtain them from any HasErrorInfo instance.
PublicErrorInfo — serialized to JSON, safe to return to callers:
| JSON key | Field | HasErrorInfo method |
|---|---|---|
message |
publicMessage |
errorPublicMessage |
code |
code |
errorCode |
details |
details |
errorDetails |
InternalErrorInfo — for logging and monitoring only. It implements ToJSON so you can log it server-side, but SomeError's ToJSON instance only serializes PublicErrorInfo, so internal fields are never included in API responses:
| JSON key | Field | HasErrorInfo method |
|---|---|---|
severity |
severity |
errorSeverity |
message |
internalMessage |
errorInternalMessage |
exception |
exception |
errorException |
callStack |
callStack |
errorCallStack |
data ErrorSeverity = Error | CriticalUse Critical for errors that need immediate attention (e.g., data corruption, infrastructure failures). Use Error for recoverable application-level failures.
SomeError is an existential wrapper, so you can mix error types freely:
data DbError = ConnectionFailed deriving (Show, Data)
instance HasErrorInfo DbError where
errorPublicMessage ConnectionFailed = "Service temporarily unavailable"
errorCode ConnectionFailed = "DbConnectionFailed"
errorSeverity ConnectionFailed = Critical
errorInternalMessage ConnectionFailed = Just "Postgres replica at 10.0.0.5:5432 unreachable"
pipeline :: Rail ()
pipeline = do
validateName name <!> validateEmail email -- UserError
fetchFromDb -- DbErrorFailure implements ToJSON via aeson. A failed computation serializes as a JSON array of error objects. Each error is a SomeError, whose ToJSON instance delegates only to PublicErrorInfo — internal diagnostic fields are never included in the output:
import Data.Aeson (encode)
import qualified Data.ByteString.Lazy.Char8 as BS
result <- runRail myRail
case result of
Left errors -> BS.putStrLn (encode errors)
Right _ -> pure ()BSD-3-Clause © 2026 Ismael Carlos Velten