From f31b7a9d26b38500e506254fa40ca8946d101f83 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 17 Aug 2025 12:48:16 -0500 Subject: [PATCH 1/8] Dashed off a quick post celebrating the new submarine error handling --- .../_posts/2025-08-17-custom-error-types.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 collections/_posts/2025-08-17-custom-error-types.md diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md new file mode 100644 index 0000000..3c7a144 --- /dev/null +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -0,0 +1,150 @@ +--- +layout: post +title: Custom Error Types Using Cats Effect and MTL +category: technical + +meta: + nav: blog + author: djspiewak +--- + +One of the most famous and longstanding limitations of the Cats Effect `IO` type (and the Cats generic typeclasses) is the fact that the only available error channel is `Throwable`. This stands in contrast to bifunctor or polyfunctor techniques, which add a typed error channel within the monad itself. You can see this easily in type signatures: `IO[String]` indicates an `IO` which returns a `String` or may produce a `Throwable` error. Something like `BIO[ParseError, String]` would represent a `BIO` that produces a `String` *or* raises a `ParseError`. The latter type signature is more general than `Throwable`, since it allows for user-specified error types, and it's somewhat more explicit about where errors can and cannot occur. + +In a meaningful sense, type type of bifunctor error encoding is analogous to *checked* exceptions in Java, whereas monofunctor error encoding (like Cats Effect's `IO`) is analogous to *unchecked* exceptions. Both are valid design decisions for an effect type, but they come with different benefits and tradeoffs. + +Cats has long been quite prescriptive about monofunctor effects, in part because this considerably simplifies the compositional integration space. Libraries like Fs2, Http4s, Calico, and so many more are able to build on top of parametric effects (the famous `F[_]`) with a consistent understanding of what error channels are available and how they're going to behave. This has *very* subtle interactions with concurrent logic and resource handling, and by insisting on a monofunctor calculus, the Cats ecosystem is able to maintain very strong properties with relatively simple implementations in these areas. + +However, the core problem of custom error types doesn't *really* go away. Parsing is a great example of this. For example, Circe has a `ParsingFailure` type which carries a specific JSON parse error message as well as some associated traceback context. While this type does happen to extend `Exception`, and thus can be raised within an `IO`, it's not necessarily *right* for it to do so. This is common, but arguably it's only common because of the prevalence of monofunctors. + +A standard solution to this problem, if you *don't* want to extend `Exception` with your error types, is to simply return `Either` everywhere. Unfortunately, that results in a lot of type signatures which look like this: + +```scala +def parse(input: String): IO[Either[Failure, Result]] = ??? +``` + +And then of course, everything you do with that result must be explicitly `flatMap`ped into the `Either`, and higher-order control flow libraries like Fs2 will often need some extra coaxing in order to make everything work the way you want it to. This gets old in a hurry, which often results in reaching for alternatives like `EitherT`. That way lies madness. + +## Capabilities + +The good news is that we now have a better answer here, and one which composes perfectly with the existing (and future) ecosystem, maintains all relevant concurrency properties, and which type infers extremely well, particularly in Scala 3. The answer has been to double down on the relatively little-used implicit capabilities library for Cats, known under the very misleading name of Cats MTL. + +The name "Cats MTL" comes from Haskell's MTL package, which in turn was pretty aptly named: "Monad Transformer Library". Haskell's MTL is entirely oriented around making it easier and more ergonomic to manipulate monad transformer *stacks*, which is to say, multiple layers of datatypes like `EitherT`, `Kleisli`, and so on. Monad transformer stacks are extremely difficult to work with, both in Scala and in Haskell, and so over time people progressively evolved techniques involving typeclasses in Haskell and implicits in Scala to more ergonomically manipulate composable effect types. Cats MTL was rooted in an adaptation of some of these ideas. + +Over time though, we've learned that monad transformer datatypes *themselves* are often too clunky and even unnecessary. They work well in a few contexts, most notably local scopes (i.e. within the body of a single method), but they're generally the wrong solution for the problem. Quite notably, while the Cats Effect concurrent typeclasses do *work* on monad transformer stacks and derive lawful results, the practical outcomes can be very unintuitive. For that reason, it's generally not advisable to use types like `EitherT` or `IorT` composed together with libraries like Fs2 or similar. + +However, the basic idea of MTL itself, divorced from the *datatypes* (like `EitherT`), is actually a very good one. At its core, MTL is just about expressing capabilities available within a given scope using implicit evidence. Capabilities can be things like parallelism, resource safety, error handling, dependency injection, sequential composition, or similar. When done correctly, this can be a very powerful and lightweight way of expressing compositional effects with a high degree of granularity and type safety. It's not a coincidence that this is exactly the route being explored by many of the researchers working on Scala academically! + +## Scoped Error Capabilities + +The problem has been to find a way to blend all of these constructs together in a way that practically *works* with the ecosystem, is syntactically lightweight, has pleasant type inference and errors, and doesn't confuse the heck out of anyone who touches it. That is a problem we feel we have now solved, at least with errors. + +```scala +import cats.effect.IO +import cats.mtl.{Handle, Raise} + +// define a domain error type +enum ParseError: + case UnclosedBracket + case MissingSemicolon + case Other(msg: String) + +// use that error type in some function +def parse[F[_]](input: String)(using Raise[F, ParseError], Monad[F]): F[Result] = + // do some hardcore parsing + if missingBracket then + UnclosedBracket.raise[F] + else if missingSemicolon then + MissingSemicolon.raise[F] + else + result.pure[F] + +// use allow/rescue like try/catch to create scoped error handling +val program: IO[Result] = Handle.allow[ParseError]: + for + x <- parse(inputX) + y <- parse(inputY) + _ <- IO.println(s"successfully parsed $x and $y") + yield () +.rescue: + case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") + case Other(msg) => IO.println(s"error: $msg") +``` + +There's a lot to unpack here! At the very beginning we define a custom error type, `ParseError`. This is just a domain error like any other, and you'll note that it *doesn't* extend `Exception` or `Throwable` or similar. Without Cats MTL, we would generally have to wrap this error up in `Either` in all our function's result types, if we wanted to use it (similar to what Circe does). In this case though, instead of adding the error to the result type, we added a `using` parameter to our `parse` function! + +Specifically, what we're doing here when we say `using Raise[F, ParseError]` is that the `parse` method requires the ability to raise (but not handle!) errors of type `ParseError`. This is a bit like saying `throws ParseError` in Java, except it isn't an exception! + +Later on, in the body of `parse`, we use this `Raise` capability to call the `raise` method, producing errors in failure cases. This is a bit like the `throw` keyword, but again with our own custom domain error type. Btw, if we had expanded our `Monad[F]` using into something like `MonadError[F, Throwable]` or, more aggressively, `Async[F]`, we would have *also* had the ability to raise any error of type `Throwable` using the same syntax! In this case though, `parse` is only able to raise domain errors. + +As an aside, the `F[_]` here could be instantiated with many different monadic types. While we're using `IO` in production, perhaps we would want to test this function using `Either[ParseError, A]` as our type. This is very much supported! And in fact, if you did this, the `Raise` would have been implicitly materialized by Cats MTL, since `Either` has an obvious implementation of that function. + +Finally, at the end of the snippet above, we define `program` using the brand new syntax: `allow`/`rescue`. This is where things get *very* fancy. What we're doing here is we're introducing a new lexical scope (indented after the `allow[ParseError]:`) in which it is valid to `raise` an error of type `ParseError`. You should think of this as being very similar to `try`/`catch`, except it works with effect types like `IO` and any error type you define (not just `Throwable`). Within this scope, we write code as usual, and we're allowed to call the `parse` function. Note that if we had tried to call `parse` *outside* of this scope, it would have been a compile error informing us that we're missing the `Raise` capability. + +At the end of the `allow` scope, we call `.rescue`, and this requires us to pass a function which *handles* any errors which could have been raised by the body of the `allow`. This works exactly like `catch`, except with your own domain error types. In this case, we are apparently just logging the existence of the errors and moving on with our life, because we do some printing and away we go, but you could imagine perhaps returning a custom HTTP error code, or triggering some fallback behavior, or really any other error handling logic. + +### Scala 2 + +Oh, and just in case you were wondering, this syntax *does* work on Scala 2 as well, it's just a bit less fancy! Here's the same snippet from above, but with 100% more braces and a lot more explicit types: + +```scala +import cats.effect.IO +import cats.mtl.{Handle, Raise} + +// define a domain error type +sealed trait ParseError extends Product with Serializable + +object ParseError { + case class UnclosedBracket extends ParseError + case class MissingSemicolon extends ParseError + case class Other(msg: String) extends ParseError +} + +// use that error type in some function +def parse[F[_]](input: String)(implicit r: Raise[F, ParseError], m: Monad[F]): F[Result] = { + // do some hardcore parsing + if (missingBracket) + UnclosedBracket.raise[F] + else if (missingSemicolon) + MissingSemicolon.raise[F] + else + result.pure[F] +} + +// use allow/rescue like try/catch to create scoped error handling +val program: IO[Result] = Handle.allowF[IO, ParseError] { implicit h => + for { + x <- parse[IO](inputX) + y <- parse[IO](inputY) + _ <- IO.println(s"successfully parsed $x and $y") + } yield () +} rescue { + case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") + case Other(msg) => IO.println(s"error: $msg") +} +``` + +We need to do a lot more hand-holding for the compiler by using the `allowF` function instead of `allow`, but in general this is very much the same idea! + +## Under the Hood + +Behind the scenes, this functionality is doing two very creative things. First, as the Scala 2 snippet hints, we're introducing a new implicit within the local scope of the function passed to `allow`/`allowF`. This is one of Scala's more unique features and we're leveraging it quite heavily. In Scala 3, we're able to hide this syntax *entirely* by using context functions (the `A ?=> B` syntax), but in Scala 2 we need to use the `implicit x =>` lambda syntax in order to make this work. (as an aside, note that this syntax does not parse if you attempt to explicitly specify the type of `x`, but that's okay because you don't ever need to do that anyway) + +That implicit is introduced targeting the effect type we passed to `allowF`, or in Scala 3's case, the type which was inferred from the return. In this case, that type is `IO`! In other words, you don't need to be using parametric effects (`F[_]`) in order to make all this work! `Raise[IO, ParseError]` is a totally valid `Raise` instance, and it's exactly what we have in scope here. Or rather, we actually have `Handle[IO, ParseError]` (which extends `Raise`), which gives us the ability to both raise *and* handle errors. + +Once the scope is closed, syntactically, we force the user to supply an error handler to ensure that any errors which were raised and unhandled within the body are correctly managed. This is a pretty logical way of setting up your error handling, and precisely mirrors the way that you would do this same thing with a more imperative direct syntax like `try`/`catch`/`throw`/`throws`. + +In the way way deep underdark of the implementation, this whole thing works at runtime by creating what we call a "submarine error". Specifically, we have a local traceless exception type called `Submarine` inside of the `allow` implementation which extends `RuntimeException`. When you `raise` a custom domain error (`ParseError` in this case), we use `Submarine` to "submerge" your error within the `Throwable` error channel of the enclosing effect – in this case, `IO`. Since we catch this error at the boundary, this whole process is entirely invisible to you *unless* you write something like `handleErrorWith` and catch all `Throwable`-typed errors within the scope, in which case you might see something of type `Submarine`. The correct thing to do with this error type, should you see it, depends considerably on exactly *why* you're writing `handleErrorWith`, and as it turns out this is exactly the whole point! + +By implementing this functionality without extending the number of actual error channels within the effect type (either with a bifunctor or something like `EitherT`), we ensure that everything continues to compose correctly around all resource handling, structured and unstructured concurrency, and otherwise-oblivious generic library code which has no idea what your domain errors are or how they might behave. Even in the case of an explicit `handleErrorWith`, you might be adding that type of error handler because you're writing some logic which must make *certain* that there is no possible way to short-circuit without passing through your handler (e.g. perhaps you're trying to make sure that some critical resource is cleaned up), or alternatively you may just be trying to observe `Throwable` errors to log and re-raise them, or any number of other things you *might* be doing with the error channel that we don't have any insight into. + +Rather than trying to impose a particular multi-channel composition semantic on your code, we simply stick with a single error channel with known and well-understood supremacy semantics, and everything else follows from there. + +## Conclusion + +Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! + +Even more excitingly, this is a bit of taste of the next phase of the effect type ecosystem. Scala is continuing to move heavily in the direction of implicit capabilities for these types of behaviors, and while efforts such as Caprese are still a long way from bearing real-world fruit, much of the work that is being done in that direction also creates the primitives needed to encode a compositional capabilities ecosystem for our existing production effect types, such as Cats Effect `IO`! + +Cats MTL will continue to evolve in this area, with an eye towards advancing the capabilities and improving syntax and ergonomics of this type of functionality both now and in the future. From d86e44eeeb02207a4397617be9ba5bd945a60227 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 17 Aug 2025 19:13:37 -0500 Subject: [PATCH 2/8] Update 2025-08-17-custom-error-types.md Co-authored-by: Seth Tisue --- collections/_posts/2025-08-17-custom-error-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 3c7a144..13d059d 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -26,7 +26,7 @@ And then of course, everything you do with that result must be explicitly `flatM ## Capabilities -The good news is that we now have a better answer here, and one which composes perfectly with the existing (and future) ecosystem, maintains all relevant concurrency properties, and which type infers extremely well, particularly in Scala 3. The answer has been to double down on the relatively little-used implicit capabilities library for Cats, known under the very misleading name of Cats MTL. +The good news is that we now have a better answer here, and one which composes perfectly with the existing (and future) ecosystem, maintains all relevant concurrency properties, and which type-infers extremely well, particularly in Scala 3. The answer has been to double down on the relatively little-used implicit capabilities library for Cats, known under the very misleading name of Cats MTL. The name "Cats MTL" comes from Haskell's MTL package, which in turn was pretty aptly named: "Monad Transformer Library". Haskell's MTL is entirely oriented around making it easier and more ergonomic to manipulate monad transformer *stacks*, which is to say, multiple layers of datatypes like `EitherT`, `Kleisli`, and so on. Monad transformer stacks are extremely difficult to work with, both in Scala and in Haskell, and so over time people progressively evolved techniques involving typeclasses in Haskell and implicits in Scala to more ergonomically manipulate composable effect types. Cats MTL was rooted in an adaptation of some of these ideas. From 3c9843b562264e717888cf3b0bcc584d92797c14 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 17 Aug 2025 19:16:21 -0500 Subject: [PATCH 3/8] Update 2025-08-17-custom-error-types.md Co-authored-by: Seth Tisue --- collections/_posts/2025-08-17-custom-error-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 13d059d..1325a12 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -10,7 +10,7 @@ meta: One of the most famous and longstanding limitations of the Cats Effect `IO` type (and the Cats generic typeclasses) is the fact that the only available error channel is `Throwable`. This stands in contrast to bifunctor or polyfunctor techniques, which add a typed error channel within the monad itself. You can see this easily in type signatures: `IO[String]` indicates an `IO` which returns a `String` or may produce a `Throwable` error. Something like `BIO[ParseError, String]` would represent a `BIO` that produces a `String` *or* raises a `ParseError`. The latter type signature is more general than `Throwable`, since it allows for user-specified error types, and it's somewhat more explicit about where errors can and cannot occur. -In a meaningful sense, type type of bifunctor error encoding is analogous to *checked* exceptions in Java, whereas monofunctor error encoding (like Cats Effect's `IO`) is analogous to *unchecked* exceptions. Both are valid design decisions for an effect type, but they come with different benefits and tradeoffs. +In a meaningful sense, this type of bifunctor error encoding is analogous to *checked* exceptions in Java, whereas monofunctor error encoding (like Cats Effect's `IO`) is analogous to *unchecked* exceptions. Both are valid design decisions for an effect type, but they come with different benefits and tradeoffs. Cats has long been quite prescriptive about monofunctor effects, in part because this considerably simplifies the compositional integration space. Libraries like Fs2, Http4s, Calico, and so many more are able to build on top of parametric effects (the famous `F[_]`) with a consistent understanding of what error channels are available and how they're going to behave. This has *very* subtle interactions with concurrent logic and resource handling, and by insisting on a monofunctor calculus, the Cats ecosystem is able to maintain very strong properties with relatively simple implementations in these areas. From cb065da11335ac82784e261611300e84be56bed8 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 17 Aug 2025 19:17:12 -0500 Subject: [PATCH 4/8] Update 2025-08-17-custom-error-types.md Co-authored-by: Seth Tisue --- collections/_posts/2025-08-17-custom-error-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 1325a12..05c3032 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -145,6 +145,6 @@ Rather than trying to impose a particular multi-channel composition semantic on Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! -Even more excitingly, this is a bit of taste of the next phase of the effect type ecosystem. Scala is continuing to move heavily in the direction of implicit capabilities for these types of behaviors, and while efforts such as Caprese are still a long way from bearing real-world fruit, much of the work that is being done in that direction also creates the primitives needed to encode a compositional capabilities ecosystem for our existing production effect types, such as Cats Effect `IO`! +Even more excitingly, this is a bit of a taste of the next phase of the effect type ecosystem. Scala is continuing to move heavily in the direction of implicit capabilities for these types of behaviors, and while efforts such as Caprese are still a long way from bearing real-world fruit, much of the work that is being done in that direction also creates the primitives needed to encode a compositional capabilities ecosystem for our existing production effect types, such as Cats Effect `IO`! Cats MTL will continue to evolve in this area, with an eye towards advancing the capabilities and improving syntax and ergonomics of this type of functionality both now and in the future. From 4935d015fa2dfbf4867fb80dd90bcf733fb0dbae Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 18 Aug 2025 11:04:29 -0500 Subject: [PATCH 5/8] Apply suggestions from code review Co-authored-by: Thanh Le --- .../_posts/2025-08-17-custom-error-types.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 05c3032..6e7c356 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -52,23 +52,23 @@ enum ParseError: def parse[F[_]](input: String)(using Raise[F, ParseError], Monad[F]): F[Result] = // do some hardcore parsing if missingBracket then - UnclosedBracket.raise[F] + UnclosedBracket.raise[F, Result] else if missingSemicolon then - MissingSemicolon.raise[F] + MissingSemicolon.raise // we can rely on type inference and omit extra typings else result.pure[F] // use allow/rescue like try/catch to create scoped error handling -val program: IO[Result] = Handle.allow[ParseError]: +val program: IO[Unit] = Handle.allow[ParseError]: for - x <- parse(inputX) + x <- parse[IO](inputX) y <- parse(inputY) _ <- IO.println(s"successfully parsed $x and $y") yield () .rescue: case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") - case Other(msg) => IO.println(s"error: $msg") + case ParseError.Other(msg) => IO.println(s"error: $msg") ``` There's a lot to unpack here! At the very beginning we define a custom error type, `ParseError`. This is just a domain error like any other, and you'll note that it *doesn't* extend `Exception` or `Throwable` or similar. Without Cats MTL, we would generally have to wrap this error up in `Either` in all our function's result types, if we wanted to use it (similar to what Circe does). In this case though, instead of adding the error to the result type, we added a `using` parameter to our `parse` function! @@ -95,8 +95,8 @@ import cats.mtl.{Handle, Raise} sealed trait ParseError extends Product with Serializable object ParseError { - case class UnclosedBracket extends ParseError - case class MissingSemicolon extends ParseError + case object UnclosedBracket extends ParseError + case object MissingSemicolon extends ParseError case class Other(msg: String) extends ParseError } @@ -112,7 +112,7 @@ def parse[F[_]](input: String)(implicit r: Raise[F, ParseError], m: Monad[F]): F } // use allow/rescue like try/catch to create scoped error handling -val program: IO[Result] = Handle.allowF[IO, ParseError] { implicit h => +val program: IO[Unit] = Handle.allowF[IO, ParseError] { implicit h => for { x <- parse[IO](inputX) y <- parse[IO](inputY) @@ -121,7 +121,7 @@ val program: IO[Result] = Handle.allowF[IO, ParseError] { implicit h => } rescue { case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") - case Other(msg) => IO.println(s"error: $msg") + case ParseError.Other(msg) => IO.println(s"error: $msg") } ``` From 055f1b3c1a107041bc6435c14c76a245ed786d2f Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 1 Sep 2025 09:55:44 -0500 Subject: [PATCH 6/8] Added some more verbiage and a tldr --- collections/_posts/2025-08-17-custom-error-types.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 6e7c356..17a1ce5 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -8,7 +8,9 @@ meta: author: djspiewak --- -One of the most famous and longstanding limitations of the Cats Effect `IO` type (and the Cats generic typeclasses) is the fact that the only available error channel is `Throwable`. This stands in contrast to bifunctor or polyfunctor techniques, which add a typed error channel within the monad itself. You can see this easily in type signatures: `IO[String]` indicates an `IO` which returns a `String` or may produce a `Throwable` error. Something like `BIO[ParseError, String]` would represent a `BIO` that produces a `String` *or* raises a `ParseError`. The latter type signature is more general than `Throwable`, since it allows for user-specified error types, and it's somewhat more explicit about where errors can and cannot occur. +**tl;dr** Cats MTL 1.6.0 introduces a brand new lightweight syntax for managing user-defined error types in the Cats ecosystem without requiring complex monad transformers. + +One of the most famous and longstanding limitations of the Cats Effect `IO` type (and the Cats generic typeclasses) is the fact that the only available error channel is `Throwable`. This stands in contrast to bifunctor or polyfunctor techniques, which add a typed error channel within the monad itself. You can see this easily in type signatures: `IO[String]` indicates an `IO` which returns a `String` or may produce a `Throwable` error (`Future[String]` is directly analogous). Something like `BIO[ParseError, String]` would represent a `BIO` that produces a `String` *or* raises a `ParseError`. The latter type signature is more general than `Throwable`, since it allows for user-specified error types, and it's somewhat more explicit about where errors can and cannot occur. In a meaningful sense, this type of bifunctor error encoding is analogous to *checked* exceptions in Java, whereas monofunctor error encoding (like Cats Effect's `IO`) is analogous to *unchecked* exceptions. Both are valid design decisions for an effect type, but they come with different benefits and tradeoffs. @@ -129,7 +131,7 @@ We need to do a lot more hand-holding for the compiler by using the `allowF` fun ## Under the Hood -Behind the scenes, this functionality is doing two very creative things. First, as the Scala 2 snippet hints, we're introducing a new implicit within the local scope of the function passed to `allow`/`allowF`. This is one of Scala's more unique features and we're leveraging it quite heavily. In Scala 3, we're able to hide this syntax *entirely* by using context functions (the `A ?=> B` syntax), but in Scala 2 we need to use the `implicit x =>` lambda syntax in order to make this work. (as an aside, note that this syntax does not parse if you attempt to explicitly specify the type of `x`, but that's okay because you don't ever need to do that anyway) +Behind the scenes, this functionality is doing two very creative things. First, as the Scala 2 snippet hints, we're introducing a new implicit within the local scope of the function passed to `allow`/`allowF`. This is one of Scala's more unique features and we're leveraging it quite heavily. In Scala 3, we're able to hide this syntax *entirely* by using context functions (the `A ?=> B` syntax), but in Scala 2 we need to use the `implicit x =>` lambda syntax in order to make this work. That implicit is introduced targeting the effect type we passed to `allowF`, or in Scala 3's case, the type which was inferred from the return. In this case, that type is `IO`! In other words, you don't need to be using parametric effects (`F[_]`) in order to make all this work! `Raise[IO, ParseError]` is a totally valid `Raise` instance, and it's exactly what we have in scope here. Or rather, we actually have `Handle[IO, ParseError]` (which extends `Raise`), which gives us the ability to both raise *and* handle errors. @@ -143,7 +145,7 @@ Rather than trying to impose a particular multi-channel composition semantic on ## Conclusion -Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! +Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! He has already begun leveraging this functionality in Lichess, one of the larger production Scala projections: [lichess-org/lila#17944](https://github.com/lichess-org/lila/pull/17944) Even more excitingly, this is a bit of a taste of the next phase of the effect type ecosystem. Scala is continuing to move heavily in the direction of implicit capabilities for these types of behaviors, and while efforts such as Caprese are still a long way from bearing real-world fruit, much of the work that is being done in that direction also creates the primitives needed to encode a compositional capabilities ecosystem for our existing production effect types, such as Cats Effect `IO`! From 5e541c2640f835ece85423422fe947cb66d19c9e Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 1 Sep 2025 14:24:21 -0500 Subject: [PATCH 7/8] Update 2025-08-17-custom-error-types.md Co-authored-by: Arman Bilge --- collections/_posts/2025-08-17-custom-error-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index 17a1ce5..eb8de01 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -145,7 +145,7 @@ Rather than trying to impose a particular multi-channel composition semantic on ## Conclusion -Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! He has already begun leveraging this functionality in Lichess, one of the larger production Scala projections: [lichess-org/lila#17944](https://github.com/lichess-org/lila/pull/17944) +Hopefully you find this technique helpful! This has been in the works for a *surprisingly* long time (I think it was first suggested in the Typelevel Discord about two or three years ago), and it was Thanh Le ([@lenguyenthanh](https://github.com/lenguyenthanh)) who ultimately pushed it over the line. Huge shoutout! He has already begun leveraging this functionality in Lichess, one of the larger production Scala projects: [lichess-org/lila#17944](https://github.com/lichess-org/lila/pull/17944) Even more excitingly, this is a bit of a taste of the next phase of the effect type ecosystem. Scala is continuing to move heavily in the direction of implicit capabilities for these types of behaviors, and while efforts such as Caprese are still a long way from bearing real-world fruit, much of the work that is being done in that direction also creates the primitives needed to encode a compositional capabilities ecosystem for our existing production effect types, such as Cats Effect `IO`! From e5711d6835c63827f31a811e42a98c07fa486f60 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 1 Sep 2025 13:03:14 -0700 Subject: [PATCH 8/8] Review feedback --- .../_posts/2025-08-17-custom-error-types.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/collections/_posts/2025-08-17-custom-error-types.md b/collections/_posts/2025-08-17-custom-error-types.md index eb8de01..aa62ce4 100644 --- a/collections/_posts/2025-08-17-custom-error-types.md +++ b/collections/_posts/2025-08-17-custom-error-types.md @@ -24,11 +24,11 @@ A standard solution to this problem, if you *don't* want to extend `Exception` w def parse(input: String): IO[Either[Failure, Result]] = ??? ``` -And then of course, everything you do with that result must be explicitly `flatMap`ped into the `Either`, and higher-order control flow libraries like Fs2 will often need some extra coaxing in order to make everything work the way you want it to. This gets old in a hurry, which often results in reaching for alternatives like `EitherT`. That way lies madness. +And then of course, everything you do with that result must be explicitly `flatMap`ped into the `Either`, and higher-order control flow libraries like Fs2 will often need some extra coaxing in order to make everything work the way you want it to. This gets old in a hurry, which often results in reaching for alternatives like `EitherT`. That way lies frustration and woe. ## Capabilities -The good news is that we now have a better answer here, and one which composes perfectly with the existing (and future) ecosystem, maintains all relevant concurrency properties, and which type-infers extremely well, particularly in Scala 3. The answer has been to double down on the relatively little-used implicit capabilities library for Cats, known under the very misleading name of Cats MTL. +The good news is that we now have a better answer here, and one which composes very nicely with the existing (and future) ecosystem, maintains all relevant concurrency properties, and which type-infers extremely well, particularly in Scala 3. The answer has been to double down on the relatively little-used implicit capabilities library for Cats, known under the very misleading name of Cats MTL. The name "Cats MTL" comes from Haskell's MTL package, which in turn was pretty aptly named: "Monad Transformer Library". Haskell's MTL is entirely oriented around making it easier and more ergonomic to manipulate monad transformer *stacks*, which is to say, multiple layers of datatypes like `EitherT`, `Kleisli`, and so on. Monad transformer stacks are extremely difficult to work with, both in Scala and in Haskell, and so over time people progressively evolved techniques involving typeclasses in Haskell and implicits in Scala to more ergonomically manipulate composable effect types. Cats MTL was rooted in an adaptation of some of these ideas. @@ -68,9 +68,12 @@ val program: IO[Unit] = Handle.allow[ParseError]: _ <- IO.println(s"successfully parsed $x and $y") yield () .rescue: - case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") - case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") - case ParseError.Other(msg) => IO.println(s"error: $msg") + case ParseError.UnclosedBracket => + IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => + IO.println("you missed your semicolons very much") + case ParseError.Other(msg) => + IO.println(s"error: $msg") ``` There's a lot to unpack here! At the very beginning we define a custom error type, `ParseError`. This is just a domain error like any other, and you'll note that it *doesn't* extend `Exception` or `Throwable` or similar. Without Cats MTL, we would generally have to wrap this error up in `Either` in all our function's result types, if we wanted to use it (similar to what Circe does). In this case though, instead of adding the error to the result type, we added a `using` parameter to our `parse` function! @@ -121,9 +124,12 @@ val program: IO[Unit] = Handle.allowF[IO, ParseError] { implicit h => _ <- IO.println(s"successfully parsed $x and $y") } yield () } rescue { - case ParseError.UnclosedBracket => IO.println("you didn't close your brackets") - case ParseError.MissingSemicolon => IO.println("you missed your semicolons very much") - case ParseError.Other(msg) => IO.println(s"error: $msg") + case ParseError.UnclosedBracket => + IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => + IO.println("you missed your semicolons very much") + case ParseError.Other(msg) => + IO.println(s"error: $msg") } ```