The Either Monad Design Pattern and Automatic Function Memoization in Kotlin

This tutorial explains two functional-programming techniques in Kotlin: representing success and failure with an Either type, and wrapping one-argument functions with a reusable memoization cache. The examples use Kotlin sealed classes, higher-order functions, extension functions, and ConcurrentHashMap.

A monad is a type that wraps a value together with rules for transforming and combining computations. In everyday Kotlin code, the practical value is more important than the terminology: wrapper types can make missing values, failures, and chained operations explicit. Kotlin’s nullable types often cover the same use case as a Maybe type, while Either can preserve information about why an operation failed.

What the Either Type Represents in Kotlin

Either<L, R> represents one of two mutually exclusive values. By convention, Left contains an error or alternative result, and Right contains the successful value. Unlike returning null, this design lets a function return structured failure information such as an error code, validation message, or exception.

For example, a server request can return Either<ErrorResponse, Response>. Callers must then handle both outcomes, which makes the error path visible in the function’s return type.

Define Either as a Kotlin Sealed Class

  1. Declare Either as a sealed class
    sealed class Either<out E, out V>
  2. Add two subclasses of Either, representing Error and Value:
    sealed class Either<out L, out R> {
    data class Left<out L>(val left: L) : Either<L, Nothing>()
    data class Right<out R>(val right: R) : Either<Nothing, R>()
    }
  3. Add factory functions to conveniently instantiate Either:
    sealed class Either<out L, out R> {
    data class Left<out L>(val left: L) : Either<L, Nothing>()
    data class Right<out R>(val right: R) : Either<Nothing, R>()
    
    companion object {
    fun <R> right(value: R): Either<Nothing, R> = 
             Either.Right(value)
    fun <L> left(value: L): Either<L, Nothing> = 
             Either.Left(value)
        }
    }

The type parameters are declared with out variance. This allows Left<L> to use Nothing for the absent right value and Right<R> to use Nothing for the absent left value. Because Nothing is a subtype of every Kotlin type, both subclasses can be returned wherever an Either<L, R> is expected.

Convert an Exception-Producing Operation to Either

The following getEither() function runs an operation supplied as a lambda. A successful result becomes Either.Right; an Exception becomes Either.Left.

fun <V> getEither(action: () -> V): Either<Exception, V> =
try { Either.right(action()) } catch (e: Exception) { Either.left(e) }

This helper is suitable when exceptions are intentionally being translated into values. Avoid catching Throwable, because that would also catch serious JVM errors that applications normally should not convert into routine results. For domain validation, prefer a specific error type instead of storing a generic Exception.

Handle Left and Right Values with fold()

A useful operation on Either is fold(). It accepts one function for Left and another for Right, then returns a single common result type.

sealed class Either<out L, out R> {
data class Left<out L>(val left: L) : Either<L, Nothing>()
data class Right<out R>(val right: R) : Either<Nothing, R>()

fun <T> fold(leftOp: (L) -> T, rightOp: (R) -> T): T = when (this) {
is Left -> leftOp(this.left)
is Right -> rightOp(this.right)
    }

  //...
}

The when expression is exhaustive because Either is sealed and both permitted subclasses are handled. The caller does not need an else branch.

Suppose a backend layer uses these response types:

data class Response(val json: JsonObject)
data class ErrorResponse(val code: Int, val message: String)

A request function can expose both possible outcomes in its return type:

fun someGetRequest(): Either<ErrorResponse, Response> = //..

The caller can then use fold() to display an error or process the successful response:

someGetRequest().fold({
showErrorInfo(it.message)
}, {
parseAndDisplayResults(it.json)
})

Both lambdas passed to fold() must return compatible types. In the example, both functions can return Unit. In other cases, they might both produce a display model, status string, or another common result.

Add map and flatMap Operations to Either

fold() consumes an Either. To transform successful values while preserving failures, add map(). To chain operations that themselves return Either, add flatMap().

</>
Copy
fun <L, R, T> Either<L, R>.map(transform: (R) -> T): Either<L, T> =
    when (this) {
        is Either.Left -> this
        is Either.Right -> Either.Right(transform(right))
    }

fun <L, R, T> Either<L, R>.flatMap(
    transform: (R) -> Either<L, T>
): Either<L, T> =
    when (this) {
        is Either.Left -> this
        is Either.Right -> transform(right)
    }

map() changes only the successful value. flatMap() avoids producing a nested type such as Either<L, Either<L, T>>. These operations are what make an Either-based computation chain practical.

Choose Between Custom Either, Kotlin Result, and Arrow Either

A small custom Either is useful for learning or for tightly controlled code. For production projects, first consider whether Kotlin’s standard Result<T> is sufficient. Result models success or exception-based failure, while Either<L, R> can use any domain-specific type on the left.

Projects that need a broader functional API can use Arrow’s Either, which provides established operators for mapping, binding, validation, and error handling. Avoid maintaining a large custom implementation unless the project has a clear reason to do so.

Automatic Function Memoization in Kotlin

Memoization caches the result of a function for each input and returns the cached result when the same input is requested again. It is most useful for deterministic functions that are expensive to calculate and are repeatedly called with identical arguments.

Memoization trades memory for reduced computation. The cache can grow for as long as new arguments are supplied, so an unbounded memoizer is not appropriate for every workload. Results can also become incorrect when a function depends on mutable state, time, random values, files, databases, or network responses.

Create a Reusable Memoizer with ConcurrentHashMap

  1. Declare a Memoizer class responsible for caching the results:
    class Memoizer<P, R> private constructor() {
    
    private val map = ConcurrentHashMap<P, R>()
    
    private fun doMemoize(function: (P) -> R):
            (P) -> R = { param: P ->
    map.computeIfAbsent(param) { param: P ->
    function(param)
    }
                }
    
    companion object {
    fun <T, U> memoize(function: (T) -> U): (T) -> U =
                    Memoizer<T, U>().doMemoize(function)
        }
    }
  2. Provide a memoized() extension function for the (P) -> R function type:
    fun <P, R> ((P) -> R).memoized(): (P) -> R = Memoizer.memoize<P, R>(this)

The cache maps each function argument of type P to the corresponding result of type R. It does not store the function itself as a key. Each call to memoized() creates a new Memoizer instance and therefore a separate cache for that wrapped function.

ConcurrentHashMap.computeIfAbsent() checks whether a value already exists for the argument. When no value is present, it invokes the supplied mapping function and attempts to store the returned result. This method comes from Java’s concurrent map API and can be called directly from Kotlin.

The use of ConcurrentHashMap makes access safer when the memoized function is called from multiple threads. It does not make the wrapped function itself free from side effects, and callers should not assume that every complex concurrent computation is automatically executed exactly once under all circumstances. The function should remain deterministic and safe to invoke.

Apply memoized() to a Kotlin Function Reference

Functions in Kotlin have function types such as (P) -> R. The extension therefore lets a function reference be wrapped directly:

Consider this recursive factorial function:

fun factorial(n: Int): Long = if (n == 1) n.toLong() else n * factorial(n - 1)

You can apply the memoized() extension function to enable caching of the results:

val cachedFactorial = ::factorial.memoized()
println(" Execution time: " + measureNanoTime { cachedFactorial(12) } + " ns")
println(" Execution time: " + measureNanoTime { cachedFactorial(13) } + " ns")

The original example showed output similar to the following:

Execution time: 1547274 ns
Execution time: 24690 ns

Exact nanosecond measurements vary between runs because of JVM warm-up, JIT compilation, scheduling, and hardware. More importantly, these two calls use different cache keys: 12 and 13. The wrapper caches only the top-level call, and the recursive calls inside factorial() still call the original function. Therefore, this example does not prove that computing factorial(13) reused the cached value for factorial(12).

To demonstrate a cache hit with the existing wrapper, call the memoized function twice with the same argument:

</>
Copy
val cachedFactorial = ::factorial.memoized()

val firstCall = measureNanoTime { cachedFactorial(12) }
val repeatedCall = measureNanoTime { cachedFactorial(12) }

println("First call: $firstCall ns")
println("Repeated call: $repeatedCall ns")

The repeated call can return the cached value. Treat this as a simple demonstration rather than a reliable benchmark. For performance testing on the JVM, use a benchmarking framework such as JMH and include warm-up iterations.

Memoize a Recursive Function Correctly

For recursive memoization, recursive calls must go through the memoized function rather than the original function. One approach uses a local cache inside the recursive implementation:

</>
Copy
fun memoizedFactorial(): (Int) -> Long {
    val cache = mutableMapOf(0 to 1L, 1 to 1L)

    fun calculate(n: Int): Long {
        require(n >= 0) { "n must be non-negative" }
        return cache.getOrPut(n) {
            n * calculate(n - 1)
        }
    }

    return ::calculate
}

This version also defines factorial for 0 and rejects negative inputs. Its MutableMap is not thread-safe; use appropriate synchronization or a concurrent design when the same instance is shared across threads.

Memoize Functions with Multiple Parameters

A function with two parameters can be memoized by using a composite key, such as a Pair. This avoids creating a separate memoizer implementation for every FunctionN type.

</>
Copy
fun <A, B, R> ((A, B) -> R).memoized2(): (A, B) -> R {
    val cache = ConcurrentHashMap<Pair<A, B>, R>()

    return { first, second ->
        cache.computeIfAbsent(first to second) {
            this(first, second)
        }
    }
}

The key types must have stable equals() and hashCode() behavior. Immutable data classes, strings, numbers, and immutable pairs are generally safer keys than mutable objects whose fields may change after insertion.

When Either and Memoization Should Be Used Together

An expensive deterministic function can return an Either, and the entire result can be memoized. This means repeated inputs may reuse either the successful value or the failure value. Caching failures can be useful for deterministic validation, but it may be wrong for transient failures such as network timeouts or temporarily unavailable services.

</>
Copy
sealed interface ParseError {
    data class InvalidNumber(val input: String) : ParseError
}

fun parsePositiveInt(value: String): Either<ParseError, Int> {
    val number = value.toIntOrNull()
        ?: return Either.Left(ParseError.InvalidNumber(value))

    return if (number > 0) {
        Either.Right(number)
    } else {
        Either.Left(ParseError.InvalidNumber(value))
    }
}

val cachedParser = ::parsePositiveInt.memoized()

This is appropriate because parsing the same string is deterministic. Memoizing a function that reads current server data would require an expiration policy, invalidation rules, and usually a dedicated caching library rather than an unbounded map.

Common Kotlin Either and Memoization Mistakes

  • Using exceptions for expected validation: model routine domain failures with a specific left type instead of throwing and catching generic exceptions.
  • Ignoring both branches: use exhaustive when, fold(), or established operators so that failure handling is not silently skipped.
  • Memoizing impure functions: do not cache functions whose correct result changes with time, mutable state, network data, or external files unless invalidation is designed explicitly.
  • Using mutable cache keys: changing a key after insertion can make the cached entry difficult or impossible to retrieve correctly.
  • Assuming recursive calls are cached: wrapping the outer function reference does not automatically redirect internal recursive calls through the cache.
  • Allowing unlimited cache growth: add size limits, expiration, or explicit clearing when inputs are numerous or long-lived.

Either Monad and Kotlin Memoization FAQs

Is a monad a design pattern?

A monad is primarily a functional-programming abstraction rather than a Gang of Four object-oriented design pattern. In practical Kotlin discussions, it is sometimes described as a design pattern because it provides a repeatable structure for wrapping values and sequencing computations.

Does Kotlin include Either in the standard library?

No. Kotlin provides nullable types and Result<T>, but it does not include a general Either<L, R> type in the standard library. You can define a small sealed type or use a library such as Arrow when broader functional operators are needed.

What is the difference between Either and Result in Kotlin?

Result<T> represents success or failure backed by a Throwable. Either<L, R> can use any left type, including validation errors, HTTP error models, or domain-specific failure classes that are not exceptions.

Is ConcurrentHashMap enough for production memoization?

It is enough for a small, unbounded cache when keys and values are suitable and the lifecycle is controlled. Production caches often need maximum size, expiration, statistics, invalidation, and protection from memory growth, which are better handled by a dedicated caching library.

Can memoization cache null results?

ConcurrentHashMap does not permit null keys or null values. If a wrapped function can return null, represent the result with a non-null wrapper, such as a sealed type, or choose another cache design that explicitly distinguishes a cached null from a missing entry.

Editorial QA Checklist for This Kotlin Tutorial

  • Confirm that every custom Either example handles both Left and Right branches.
  • Verify that new Kotlin code blocks use the language-kotlin PrismJS class.
  • Do not present nanosecond output as portable or reproducible across JVM runs.
  • State clearly whether a memoization example caches top-level calls or recursive subproblems.
  • Check cache examples for thread-safety, null handling, mutable keys, and unbounded growth.
  • Use domain-specific error types where failures are expected and recoverable.