The reason badFunction should be an ExceptT Error IO () is because runExceptT has type runExceptT :: ExceptT e m a -> m (Either e a). Since your main has type main :: IO (), this means that runExceptT badFunction needs to be IO …, hence the m in the ExceptT e m a should be IO.
But you do not per se need this, your badFunction does not do any IO, so you can define it as an Except:
badFunction :: Except Error ()
badFunction = throwE Empty
Then you can use it in an IO () by using runIdentity to obtain the value out of the identity, and then use pure to "wrap" the result in an IO:
main :: IO ()
main = do
caught_result <- pure (runIdentity (runExceptT badFunction))
case caught_result of
Left _ -> putStrLn "caught some error"
Right _ -> putStrLn "no errors were caught"
But then we can just use a let … clause in the main and remove the pure:
main :: IO ()
main = do
let caught_result = runIdentity (runExceptT badFunction)
case caught_result of
Left _ -> putStrLn "caught some error"
Right _ -> putStrLn "no errors were caught"
As @JonPurdy says, the combination of runIdentity and runExceptT is runExcept :: Except e a -> Either e a, so we can rewrite this to:
main :: IO ()
main = do
let caught_result = runExcept badFunction
case caught_result of
Left _ -> putStrLn "caught some error"
Right _ -> putStrLn "no errors were caught"