Maybe Strikes Back

vchernetskyi993

Vsevolod

Posted on July 1, 2023

Maybe Strikes Back

In the end of the previous chapter, we've stated that Kotlin's Any? is a proper union type. Also, multiple handy operators and functions to work with Any? exist as part of the standard library. So, based on these two statements, Kotlin's null handling has been inferred to be "the best". Today I'm going to review its weaker points. Again, we'll be looking at Haskell and its Maybe type for inspiration. A lot of language designers do this anyway, why can't we?

Universal Interface

With a risk of sounding like a broken record, I would state that the main power of Maybe comes from the universal Monad type class. Maybe itself may not be the best answer to nullability question, but its interface answers to dozens of other questions at the same time. This leads to a unified usage and thus to more readable and re-usable code. And it's completely reasonable from the OOP developer point of view. We all have heard the popular mantra of "code to interface". And here Haskell does exactly that.

The key function of monad, bind, has signature m a -> (a -> m b) -> m b. Rewriting this using Java-like types may look clearer: Monad<A> -> Function<A, Monad<B>> -> Monad<B>. Or even more Javishly: Monad<B> bind(Monad<A> m, Function<A, Monad<B>> m). Does this look familiar? It should. It's a quite popular method among Java codebases, commonly known as flatMap. But where is the problem with such a popular method?

Let's see 3 core examples of it:

Optional<A>.flatMap(Function<A, Optional<B>>) -> Optional<B>

Stream<A>.flatMap(Function<A, Stream<B>>) -> Stream<B>

CompletableFuture<A>.thenCompose(Function<A, CompletableFuture<B>>) -> CompletableFuture<B>
Enter fullscreen mode Exit fullscreen mode
  • I'm cleaning up some type noise for brevity here
  • A lot more examples exist in the wild, but to showcase the idea these are enough
  • CompletableFuture is my favorite: to make matters worse, it doesn't even try to conform to usual naming

Kinds of Types

My OOP training screams to abstract a common parent out of the same type signatures. To achieve this, we need an interface looking like the following one:

interface Monad<A> {
    Monad<B> flatMap(Function<A, Monad<B>> f);
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that it's not generic enough. It will return us Monads where we would want Optionals and Streams. For example, expression optional.flatMap(...) will have parent's type of Monad<B> not child's Optional<B>. So, after such flatMap we wouldn't be able to chain Optional-specific methods anymore (without unholy casts, obviously).

If we go back to Haskell:

class Monad m where
  (>>=) :: m a -> (a -> m b) -> m b
Enter fullscreen mode Exit fullscreen mode

We'll spot that Haskell declares container itself as a generic type parameter (note the m in m a). Can Java do the same? I wasn't able to achieve it. If you succeed, you can destroy the reasoning of this article. Please go ahead.

No point in showing all the different type signature combinations I've tried. They either do not compile, or (as with initial "naive" Monad<A>) do not return child type. The prettiest example, just to illustrate my thought-flow:

interface Monad<A, C extends Monad<A, C>>
Enter fullscreen mode Exit fullscreen mode

Can be extended by:

sealed interface Maybe<A> extends Monad<A, Maybe<A>> permits Just, Nothing
Enter fullscreen mode Exit fullscreen mode

Which narrows our Monad parent type to proper Maybe child type and compiles using Java 17. The problem arises when we want to return Maybe<B> from Maybe<A> method. Our Monad.flatMap could look like the following (note that we're using the same trick of D extends Monad we used successfully on class level):

<B, D extends Monad<B, D>> D flatMap(Function<A, D> f)
Enter fullscreen mode Exit fullscreen mode

And, consequently, Maybe.flatMap:

<B> Maybe<B> flatMap(Function<A, Maybe<B>> f)
Enter fullscreen mode Exit fullscreen mode

Which doesn't compile with: flatMap(Function<A, Maybe<B>>)' in 'Maybe' clashes with 'flatMap(Function<A, D>)' in 'Monad';. Even though Maybe<B> extends Monad<B, Maybe<B>, the compiler doesn't substitute D with it, as it did with C on class level previously.

Generally speaking, it's a known limitation of the Java compiler (as well as Kotlin's). To get more information on the matter, search for higher-kinded types. There are multiple Java libraries trying to add HKT, but at the moment of writing all of them introduce themselves as "not production-ready" ones. Also, it sounds like HKT should be part of a language itself to be efficient enough. So, unfortunately for now we are stuck with "code to implementation" in Java-world.

Imperative Paradigm

In Haskell, the universal monadic interface allows us to write both declarative and (surprisingly for a purely functional language) imperative code. Let's look at a simple example. Two ways of fetching some entity from some database, populating some metadata from some external API, and then mapping the result to a DTO.

Declarative:

fetchDto id = fetchEntityFromDB id >>= populateMeta <&> toDto
Enter fullscreen mode Exit fullscreen mode

Imperative:

fetchDto id = do
  entity <- fetchEntityFromDB id
  entityWithMeta <- populateMeta entity
  let dto = toDto entityWithMeta
  return dto
Enter fullscreen mode Exit fullscreen mode

Which code looks more familiar and thus more readable for Java developers without no functional background (i.e. for an average coder prior to Java 8)? If you are not familiar with Haskell, take your time to comprehend and compare both examples, they are really simple.

So?.. Right you are. The first one! At least, that is what we've got in Java 8. Am I hearing some weak voices for the second approach? Fear no more, you're not alone in this. There are examples of imperative handling of Monads on the JVM. Not in Java itself, but Kotlin's suspend/await lets you write monadic code imperatively, exactly like do in Haskell (to be fair, similarly to lots of other async/await implementations). Yes, we are stepping out of our cozy Maybe-only shell to a wider land of different Monads here. But we'll return, promise.

Java 8:

CompletableFuture<Dto> fetchDto(String id) {
  return fetchEntityFromDb(id)
      .thenCompose(this::populateMeta)
      .thenApply(this::toDto);
}
Enter fullscreen mode Exit fullscreen mode

Kotlin:

suspend fun fetchDto(id: String): Dto {
    val entity = fetchEntityFromDb(id)
    val entityWithMeta = populateMeta(entity)
    val dto = toDto(entityWithMeta)
    return dto
}
Enter fullscreen mode Exit fullscreen mode

In Kotlin, we can shorten this function to a single line by using extension methods. That's not the point. The point is that we have a choice between imperative and declarative paradigms for asynchronous logic.

That's cool, but let's say that we want to add support for some other monadic type. For example, our beloved T?. How much code would we need to change in Kotlin?

fun fetchDto(id: String): Dto? {
    val entity = fetchEntityFromDb(id)
    val entityWithMeta = entity?.let { populateMeta(it) }
    val dto = entityWithMeta?.let { toDto(it) }
    return dto
}
Enter fullscreen mode Exit fullscreen mode

Huh, not DRY at all! How many lines we would need to change in Haskell?.. Zero, since we aren't using anything implementation-specific. Coding to an interface, remember that one?

The Union Way

As we already know, another mathematically correct nullable type resides in Scala. Does it adhere to the monadic interface? Can we implement one?

Scala lacks an explicit trait for Monad, however has an implicit interface that is used by for-comprehensions, which in turn are extremely similar to Haskell's do blocks we've already seen. Here is one possible implementation for unionized Maybe type:

extension[A] (m: A | Null)
  def map[B](f: A => B): B | Null = m match
    case null => null
    case _ => f(m.nn)

  def flatMap[B](f: (=> A) => B | Null): B | Null = m match
    case null => null
    case _ => f(m.nn)

  def withFilter(f: A => Boolean): A | Null = m match
    case null => null
    case _ => if f(m.nn) then m.nn else null
Enter fullscreen mode Exit fullscreen mode

Next is our good ol' sample function:

def fetchDto(id: String) = for {
  entity <- fetchEntityFromDb(id)
  entityWithMeta <- populateMeta(entity)
  dto <- toDto(entityWithMeta)
} yield dto
Enter fullscreen mode Exit fullscreen mode

And guess what? We can change our Maybe Monad to any other type implementing the same contract and this code will not change.

Let's re-check backward compatibility rules from the previous chapter. In this example, we can narrow down the return type in the signature of the populateMeta from Entity -> EntityWithMeta | Null to Entity -> EntityWithMeta. And as expected, client code is not broken. The same function compiles and works correctly!

This actually leads us to the interesting side effect. We now can work with non-nullable types using monadic pipelines:

val i: Int = 5
val j: Int | Null = i.map(_ - 3)
Enter fullscreen mode Exit fullscreen mode

The one major drawback of our A | Null type I see is that if we want to make it actually useful, we need to re-invent the wheel (i.e. re-implement all the functionality, like zip and orElse, from the Option type). Can you spot any other problems?


All in all, by this moment we've covered quite an interesting evolutionary path from Java 7 to Scala 3 union types. From a theoretical standpoint, the latter now seem superior to the available alternatives (which doesn't mean that it's ideal, does it?). In the next chapter (if it ever gets published), I'm planning to abstract out the notion of an "absent value" even more. Null is too specific. "Code to interface", still remember that one?

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
vchernetskyi993
Vsevolod

Posted on July 1, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About