Если вы когда-нибудь читали агитации, призывающие к изучению Haskell, наверняка вас убеждали, что в нём ну очень удобно обрабатывать ошибки, ведь там есть Монада Either.

Однако, чем дальше вы изучаете Haskell (если, конечно, изучаете), тем больше понимаете, что Either неудобен примерно всегда, и использовать его вы не станете. А именно потому, что он не допускает побочных эффектов (например, IO).

Хотя, например, в Rust, такой проблемы нет, однако и там сообщество пришло к выводу, что тамошний Result из коробки тоже непригоден к использованию.

Далее, скорее всего, вы узнаете про ExceptT/MonadError, который и побочные эффекты позволяет, и ошибки умеет бросать. Но и у него есть проблемы, целых две.

  1. Если вы разрабатываете достаточно сложную систему, то ваш код скорее всего будет бросать больше, чем одну ошибку. Тут и начинаются танцы. Вы либо используете сумму (под конкретный случай, или Either или самописная сумма):

    data Foo = Foo -- 1st exception
    data Bar = Bar -- 2nd exception
    data FooOrBar = ... -- sum of 1st and 2nd exceptions
    
    foo :: MonadError FooOrBar m => m ()

    Либо делаете ещё более страшные вещи:

    Если используете ExceptT:

    foo :: ExceptT Foo (ExceptT Bar IO) ()
    foo = do
      throwError Foo
      -- неявный лифтинг работать не будет из-за FunctionalDependency
      lift $ throwError Bar 

    Если используете MonadError, то из-за FunctionalDependency в принципе нельзя написать(MonadError Foo m, MonadError Bar m) => m (). Так как эту поблему нашёл не я, и она давно известна, то решение уже имеется, это библиотека capability.

    foo :: (HasThrow "foo" Foo m, HasThrow "bar" Bar m) => m ()
    foo = do
      throw @"foo" Foo
      throw @"bar" Bar

    Но, во-первых, это какой-то позор со стороны интерфейса. Во-вторых, это не решает проблемы проблемы номер 2.

  2. Ну, а в дополнение к тому, что, используя MonadError/ExceptT, нельзя хоть сколько нибудь удобным способом кидать несколько разных ошибок, так их ещё и нельзя по-настоящему ловить. Вы можете обработать, но не поймать. Потому что catchError имеет сигнатуру m a -> (e -> m a) -> m a. Она означает, что если даже вы вызовете эту функцию и действительно отловите ошибку (не бросте её заново), то типы ничего об этом не скажут. Где-то выше по стеку вы всё ещё не сможете быть уверены, что ошибка поймана.

Первая проблема, конечно, из коробки решена исключениями: бросай что хочешь, сколько хочешь. Но их очевидный недостаток в отсутствии явности. Однако, для явности у наc есть checked exceptions. Они описаны здесь. С первого взгляда здесь всё хорошо.

  1. Можно бросать ошибки.

  2. Можно бросать разные ошибки, несвязанные друг с другом

  3. Можно ловить их, не оставляя от них следа.

foo :: (CanThrow Foo, CanThrow Bar) => IO ()
foo = do
  throwChecked Foo
  throwChecked Bar

bar :: CanThrow Foo => IO ()
bar = foo 
-- ^^^
-- Could not deduce (CanThrow Bar) arising from a use of ‘foo’

baz :: CanThrow Foo => IO ()
baz = foo `catchChecked` \Bar -> pure () 
-- ^^^
-- Ok

Однако, из-за того, что CanThrow по факту, ни к чему не привязан, то опять же есть 2 проблемы. Одна из них фатальна. И что самое обидное: ни об одной из них в посте WellTyped не сказано!

  1. Вывод типов работает очень плохо.

    foo :: IO () 
    foo = do   
      void qux `catchChecked` \Foo -> pure ()   
      where     
        qux = for [1..10] \_ -> throwChecked Foo
    -- ^^^
    -- No instance for (CanThrow Foo)
    --    arising from a use of ‘throwChecked’
    -- • In the expression: throwChecked Foo
    --  In the second argument of ‘for’, namely ‘\ _ -> throwChecked Foo’
    --  In a stmt of a 'do' block: for [1 .. 10] \ _ -> throwChecked Foo

    Но это решается сигнатурами и в принципе несмертельно, потому что только делает код несколько вербознее и ничего больше.

    foo :: IO ()
    foo = do
      void qux `catchChecked` \Foo -> pure ()
    
      where 
        qux :: CanThrow Foo => IO [()]
        qux = do
          for [1..10] \_ -> throwChecked Foo
  2. Однако второе (и я всё ещё дико негодую, что WellTyped об этом не предупреждают!) может взорвать вам рантайм.

    foo :: CanThrow Foo => IO ()
    foo = do
      forkIO do
        throwChecked Foo
      throwChecked Foo

    Так как у вас в скоупе присутствует CanThrow, вы, естественно, можете вызывать throwChecked. Но на самом деле вы бросили исключение в соседнем треде, а хендлер для него не обозначили. И компилятор вас не заставил!

Однако, если вы будете достаточно внимательны, то checked exceptions может быть вашим вариантом из-за того, что их можно довольно быстро и безболезненно встроить.

И не стоит отчаиваться, ведь есть вариант ещё лучше, пусть и не получится его встроить так же легко, как checked exceptions. Это эффекты. Что такое эффекты, для чего они нужны и как их использовать, вы можете почитать в документации к любой из реализаций. Вот несколько:

(Это далеко не весь список)

Для примера здесь я буду использовать effectful. Так как сам автор effectful не то что бы подозревает, что его библиотека решает нашу проблему, то мы будем использовать эффект Error немного иначе, чем это задумывалось. Эффект Error в общем-то имеет интерфейс, состоящий из двух функций:

  1. throwError :: forall e es a. (HasCallStack, Error e :> es)
    => e -> Eff es a

  2. catchError :: forall e es a. Error e :> es
    => Eff es a -> (CallStack -> e -> Eff es a) -> Eff es a

Но так как catchError не стирает ошибку, а только реагирует на неё, то мы ей пользоваться не будем. Напишем свою:

-- Для простоты я убрал обработку CallStack
catchError :: Eff (Error e ': es) a -> (e -> Eff es a) -> Eff es a 
catchError eff handler = runErrorNoCallStack eff >>= \case 
  Left e -> handler e
  Right r -> pure r

Теперь можем пользоваться.

Это всё ещё так же хорошо работает с несколькими ошибками, как и checked exception:

foo :: (Error Foo :> es, Error Bar :> es) => Eff es () 
foo = do
  Eff.throwError Foo
  Eff.throwError Bar

Типы выводятся:

foo :: Eff es () 
foo = qux `catchError` \Foo -> pure ()
  where
    qux = for_ [1..10] \_ -> Eff.throwError Foo

С ошибками в соседнем треде всё ещё не так просто. Но есть шаманское решение, которое позволит сделать форки более безопасными, хоть и более вербозными. Для начала определим свой собсвенный хитрый fork:

type family HasNoError es :: Constraint where
  HasNoError (Error e ': es) = TypeError ('Text "You can't fork action that throws error")
  HasNoError (e ': es) = HasNoError es
  HasNoError '[] = ()

data Fork :: Effect where
  Fork :: m () -> Fork m ThreadId

type instance DispatchOf Fork = 'Dynamic

fork :: forall es' es. (Fork :> es', HasNoError es', Subset es' es) => Eff es' () -> Eff es ThreadId
fork = inject . send @_ @es' . Fork

И единственный его минус, по моему мнению, что надо, чтобы эффекты, запущенные в новом треде, были статически известны, чтобы компилятор мог проверить, что HasNoError не приводит к TypeError.

foo :: Error Foo :> es => Eff es () 
foo = for_ [1..10] \_ -> Eff.throwError Foo

baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
  threadId <- fork foo
  Eff.throwError Foo
-- ^^^ 
-- 1) • Could not deduce (Eff.Subset es'0 es)
--      arising from a use of ‘fork’
-- 2) Could not deduce (Error Foo :> es'0) arising from a use of ‘foo’
--      from the context: (Fork :> es, Error Foo :> es)

-- Что я и говорил, нужно указать список эффектов явно. 
-- Конечно, ошибки компиляции оставляют желать лучшего, но как
-- сделать их читаемее, я пока не придумал

-- Попытка номер 2
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
  threadId <- fork @'[Fork] foo
  Eff.throwError Foo
-- ^^^
--    • There is no handler for 'Error Foo' in the context
--    • In the second argument of ‘fork’, namely ‘foo’
--      In a stmt of a 'do' block: threadId <- fork @'[Fork] foo
--      In the expression:
--        do threadId <- fork @'[Fork] foo
--           throwError Foo
--    |
--xxx |   threadId <- fork @'[Fork] foo
--    |                             ^^^

-- Обмануть компилятор не вышло, я указал, что foo должен уметь только Fork,
-- но в сигнатуре foo явно указан Error Foo. 

-- Попытка номер 3
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
  threadId <- fork @'[Fork, Error Foo] foo
  Eff.throwError Foo
-- ^^^
--    • You can't fork action that throws error
--    • In a stmt of a 'do' block:
--        threadId <- fork @'[Fork, Error Foo] foo
--      In the expression:
--        do threadId <- fork @'[Fork, Error Foo] foo
--           throwError Foo
--      In an equation for ‘baz’:
--          baz
--            = do threadId <- fork @'[Fork, Error Foo] foo
--                 throwError Foo
--    |
--xxx |   threadId <- fork @'[Fork, Error Foo] foo
--    |               ^^^^

-- А теперь мы уже сами себя спасли. Констрейнт HasNoError точно
-- не выполняется, о чём и сообщает компилятор.

-- Попытка номер 4
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
  threadId <- fork @'[Fork] (foo `catchError` \Foo -> pure ())
  Eff.throwError Foo
-- ^^^
-- Ok!

Пробуем запустить более интересный пример:

foo :: Error Foo :> es => Eff es () 
foo = for_ [1..10] \_ -> Eff.throwError Foo

baz :: (IOE :> es, Fork :> es, Error Bar :> es) => Eff es ()
baz = do
  threadId <- fork @'[Fork, IOE] 
    ( foo `catchError` \Foo -> liftIO do 
        t <- myThreadId
        putStrLn $ "Catch Foo. From another thread (" <> show t <> ")" 
    )
  liftIO do 
    threadDelay 100000
    print threadId
  throwError Bar

qux :: (IOE :> es, Fork :> es) => Eff es ()
qux = baz `catchError` \Bar -> liftIO do
    t <- myThreadId
    putStrLn $ "Catch Bar. From main thread (" <> show t <> ")"

runFork :: Concurrent :> es => Eff (Fork ': es) a -> Eff es a
runFork = interpret \env -> \case
  Fork m -> localUnlift env (ConcUnlift Ephemeral Unlimited) \unlift -> forkIO (unlift m)

-- >>> runEff $ runConcurrent $ runFork qux
-- Catch Foo. From another thread (ThreadId 247)
-- ThreadId 247
-- Catch Bar. From main thread (ThreadId 246)

Стоит признаться, что я не пробовал всё это дело на хоть сколько нибудь большом проекте.

Я просто, своего рода, экспериментатор)))).

Не призываю никого запихивать это к себе на прод, если у вас таковой на Haskell имеется, но призываю поиграться и поизучать, возможно, некоторые подвоные камни, которые я не нашёл.

Конец.