DEV Community

Cover image for Why FlatMap Isn’t Ideal for Go Error Handling — A Case for go-opera
ColaFanta
ColaFanta

Posted on

Why FlatMap Isn’t Ideal for Go Error Handling — A Case for go-opera

Many Go libraries borrow ideas from functional programming, usually Either, Result, and Option, to make error handling less repetitive. The promise is simple: wrap failure in a result type, use FlatMap, and stop writing if err != nil after every line.

Why FlatMap

To be fair, FlatMap works very well in languages where composition syntax is light.

Let's look at a scenario: get a guest name out of nested response data, then create a booking and assign a room.

In TypeScript, that stays compact:

const bookFromResponse = (response: Response): E.Either<Error, string> =>
  pipe(
    E.fromNullable(new Error('missing guest name'))(
      response.data?.list?.[1]?.user?.name,
    ),
    E.flatMap(createBooking),
    E.flatMap((booking) => assignRoom(booking, 'Suite')),
  )
Enter fullscreen mode Exit fullscreen mode

This reads well. The pipeline stays flat, each callback is small, and the syntax does not fight the idea.

TypeScript also gives you lightweight tools for ordinary data access. Optional chaining like response.data?.["list"]?.[1]?.user?.name stays small and readable.

In Go, only half of that advantage survives. In a FlatMap-first style, the same idea becomes much heavier:

bookFromResponse := func(response Response) either.Either[error, string] {
    return function.Pipe3(
        fromOption(func() error { return errors.New("missing guest name") })(
            optionPipe(
                maybeNil(response.Data),
                flatMap(func(data ResponseData) Option[ListItem] {
                    return at(data.List, 1)
                }),
                flatMap(func(item ListItem) Option[User] {
                    return maybeNil(item.User)
                }),
                flatMap(func(user User) Option[string] {
                    return maybeEmpty(user.Name)
                }),
            ),
        ),
        either.Chain(func(name string) either.Either[error, Booking] {
            return createBooking(name)
        }),
        either.Chain(func(booking Booking) either.Either[error, string] {
            return assignRoom(booking, "Suite")
        }),
    )
}
Enter fullscreen mode Exit fullscreen mode

FlatMap is good at one thing: propagating failure. If one step fails, the rest of the pipeline is skipped. That part works.

But error propagation is not the whole problem in ordinary Go code. We also want to read the happy path quickly and understand the function without decoding a stack of callbacks.

That is where FlatMap stops helping.

  • TypeScript can write a => next(a). Go has to write func(a A) Result[B] { return next(a) }.
  • TypeScript can do optional chaining. Go has to spell the same traversal out through helper functions and repeated flatMap calls just to reach a nested value.

FlatMap removes explicit if err != nil, but it does not remove boilerplate in Go. In a language without arrow functions, moving the boilerplate into callback syntax is not a small trade.

In Go, function literals are visually heavy, and their return types must be written out every time. The code starts to describe the composition mechanism more than the business logic.

This is exactly why direct style is better.

With go-opera, the same flow stays close to ordinary Go:

bookFromResponse := func(response Response) opera.Result[string] {
    return opera.Do(func() string {
        data := opera.MaybeNilPtr(response.Data).Yield()
        item := opera.TryAt(data.List, 1).Yield()
        user := opera.MaybeNilPtr(item.User).Yield()
        name := opera.MaybeEmpty(user.Name).Yield()
        booking := createBooking(name).Yield()
        return assignRoom(booking, "Suite").Yield()
    })
}
Enter fullscreen mode Exit fullscreen mode

This gives you the same fail-fast behavior, but it does something FlatMap does not do well in Go: it keeps the success path linear.

Why Direct Style Is Better

The advantage of direct style is straightforward.

  1. You do not have to wrap tiny operations into separate func(...) ... {} blocks just to keep the chain moving.

  2. Most Go developers are trained to understand a sequence of statements quickly. Direct style preserves that strength instead of hiding it behind combinators and explicit callback signatures.

  3. It is easier to debug. You can put a breakpoint on each line, inspect each value, and follow the happy path like any other function.

This is the core argument for go-opera: it keeps the monadic fail-fast behavior, but presents it in a form that matches how Go code is naturally written and read.

FlatMap is still useful. It is just not the best primary interface for day-to-day Go error handling. In Go, the main problem is not only propagating failure, but also expressing dependent work without turning each small step into another full function literal.

Summary

FlatMap solves error propagation. It does not solve readability in Go.

Direct style solves that better.

Once a function needs several dependent steps, a FlatMap-first style starts adding full func(...) ReturnType { ... } blocks and composition scaffolding. The code still works, but the boilerplate has only changed shape.

go-opera keeps the same fail-fast semantics while letting the success path stay flat, named, and local. That is why direct style is a better fit for Go.

Top comments (0)