← All writing

Kotlin · Functional programming · 25 min read

Monads for Kotlin Developers

Published 13 September 2026

The word monad can make ordinary programming sound more mysterious than it is. Kotlin developers already use many of the underlying ideas: nullable call chains, List.flatMap, Result, sequential suspending functions, and Arrow's typed-error DSLs all connect computations according to some rule.

They are not all interchangeable, and not every API in that list is itself a monad. They are useful starting points because they let us derive the idea from Kotlin code instead of beginning with Haskell notation.

This exploration develops one practical definition:

A monad is a parameterized type shape with lawful operations for putting a value into that shape and sequencing shape-producing computations.

Those operations are conventionally called pure and flatMap. The rest of the definition, especially type shape and lawful, will become more concrete through this exploration.

1. Start With a Kotlin Problem

Suppose an application loads an account in three steps:

fun parseUserId(input: String): UserId? = TODO()
fun findUser(id: UserId): User? = TODO()
fun loadAccount(user: User): Account? = TODO()

Each function either produces the value needed by the next function or returns null. One direct implementation is:

fun accountFor(input: String): Account? {
    val id = parseUserId(input) ?: return null
    val user = findUser(id) ?: return null
    return loadAccount(user)
}

This is good Kotlin. The control rule is visible: continue when a value exists; otherwise return null.

The same rule can be factored into a reusable operation:

inline fun <A : Any, B : Any> A?.flatMap(
    next: (A) -> B?
): B? = if (this == null) null else next(this)

Now the workflow can be written as a chain:

fun accountFor(input: String): Account? =
    parseUserId(input)
        .flatMap(::findUser)
        .flatMap(::loadAccount)

flatMap models the repeated decision:

  • If the current value is null, keep null and do not call next.
  • If the current value is present, pass it to next.

The business functions only perform their own step. The sequencing operation handles the computational rule shared by every step.

Kotlin already offers essentially this nullable behavior through a safe call and let:

fun accountFor(input: String): Account? =
    parseUserId(input)
        ?.let(::findUser)
        ?.let(::loadAccount)

let alone is just a scope function. The safe-call operator is what skips the call when the receiver is null. Together they behave like nullable flatMap in this example.

This gives us the first useful intuition:

Monadic sequencing factors out a repeated rule for deciding how one result reaches the next computation.

For nullable values that rule is short-circuiting on absence. Because the sequencing operation is implemented with ordinary code, other types can use very different rules: they might branch into several results, carry updated state, preserve a failure, or defer work. This freedom is what makes monads appear so diverse. Before looking for what they have in common, it helps to explore another familiar rule.

2. Why flatMap Has That Name

Under value-based semantics and pure transformations, Kotlin's List is one concrete example of a monad. We do not need its formal definition yet. Its familiar map and flatMap operations show where the name flatMap comes from and introduce a different sequencing rule: branch into zero or more results, then concatenate them.

map expects a function that returns an ordinary value:

val lengths: List<Int> =
    listOf("Ada", "Grace").map(String::length)

// [3, 5]

The list structure remains, while each inner value changes from String to Int.

If the transformation itself returns a list, map preserves both list layers:

val nested: List<List<Char>> =
    listOf("Ada", "Grace").map(String::toList)

// [[A, d, a], [G, r, a, c, e]]

flatMap maps the function and flattens the resulting nested lists:

val characters: List<Char> =
    listOf("Ada", "Grace").flatMap(String::toList)

// [A, d, a, G, r, a, c, e]

The general shapes are:

map:     M<A> + (A -> B)    -> M<B>
flatMap: M<A> + (A -> M<B>) -> M<B>

If we used map with the second function, its result would be M<M<B>>. flatMap removes one repeated M layer and returns M<B>.

For Kotlin's List, removing that layer means concatenating the inner lists in order while preserving duplicates. Concatenation is a natural rule for lists, but the general flatMap shape does not prescribe it. An API could choose a different rule, such as keeping only the first occurrence of each result:

inline fun <A, B> List<A>.flatMapUnique(
    transform: (A) -> Iterable<B>
): List<B> = flatMap(transform).distinct()

val uniqueCharacters: List<Char> =
    listOf("Ada", "Grace").flatMapUnique(String::toList)

// [A, d, a, G, r, c, e]

This has the same broad input and output shape as flatMap, but it represents different behavior: duplicate results disappear. A matching shape alone does not make every conceivable rule a monad. The laws introduced later rule out some implementations or require a different outer abstraction, such as a set or a type that guarantees unique elements.

Nullable types hide this distinction because Kotlin collapses repeated nullability: Account?? is not a distinct type from Account?. Lists make the flattening visible.

3. The Monad Shape

A monad has two fundamental operations:

pure:    A -> M<A>
flatMap: M<A> + (A -> M<B>) -> M<B>

Read M<A> as "an A in some computational structure M." Depending on M, that structure might represent:

  • A collection data structure such as List<A>, interpreted as zero or more possible results
  • A value that may be absent
  • A success or a typed failure
  • An explicit state transition
  • A deferred calculation
  • An action that may read a file, send a network request, or print output before producing an A, represented as IO<A> in Haskell

pure puts an already available value into the structure without adding any extra behavior. Examples include:

Nullable: value
List:     listOf(value)
Either:   Right(value)
Logged:   Logged(value, emptyList())

The name pure is historical terminology. Calling pure(impureCall()) does not undo a side effect that already occurred, and it does not prove that arbitrary Kotlin code has no observable effects.

flatMap receives both a structured value and a function that chooses the next structured computation. The outer structure stays the same while the successful or produced value may change from A to B.

For example, a typed-error chain might move through these types:

Outcome<LookupError, UserId>
Outcome<LookupError, User>
Outcome<LookupError, Account>

The value type changes at each step. The Outcome<LookupError, ...> shape and its error-handling rule remain.

Why This Is Awkward to Express Generically in Kotlin

M in M<A> is not an ordinary complete type. It is a type constructor: something waiting for a type argument. Examples are List<...>, nullable ... ?, and Outcome<LookupError, ...>.

Kotlin can declare List<A>, but it cannot directly declare a type parameter meaning "any M for which M<A> is valid." This feature is usually called higher-kinded types.

That is why Kotlin does not have one natural standard interface like this pseudocode:

// Pseudocode: M cannot be declared this way in Kotlin.
interface Monad<M> {
    fun <A> pure(value: A): M<A>
    fun <A, B> flatMap(value: M<A>, next: (A) -> M<B>): M<B>
}

Kotlin libraries usually put map and flatMap on concrete types instead. The concept is generic even when the language API is not.

4. Build One in Ordinary Kotlin

Here is a small Outcome type. It is the same broad shape as a right-biased Either: failure is stored on one side, success on the other, and operations such as map and flatMap act on the successful side.

sealed interface Outcome<out E, out A> {
    data class Failure<E>(val error: E) : Outcome<E, Nothing>
    data class Success<A>(val value: A) : Outcome<Nothing, A>
}

Its pure operation creates a success:

fun <E, A> pureOutcome(value: A): Outcome<E, A> =
    Outcome.Success(value)

Its flatMap operation defines the sequencing rule:

inline fun <E, A, B> Outcome<E, A>.flatMap(
    next: (A) -> Outcome<E, B>
): Outcome<E, B> = when (this) {
    is Outcome.Failure -> this
    is Outcome.Success -> next(value)
}

On failure, flatMap preserves the error and skips next. On success, it passes the successful value to next.

That is enough to compose an entire workflow:

fun parseUserId(input: String): Outcome<LookupError, UserId> = TODO()
fun findUser(id: UserId): Outcome<LookupError, User> = TODO()
fun loadAccount(user: User): Outcome<LookupError, Account> = TODO()

fun accountFor(input: String): Outcome<LookupError, Account> =
    parseUserId(input)
        .flatMap(::findUser)
        .flatMap(::loadAccount)

map can now be derived from flatMap and pure:

inline fun <E, A, B> Outcome<E, A>.map(
    transform: (A) -> B
): Outcome<E, B> =
    flatMap { value -> pureOutcome(transform(value)) }

The same derivation works for List. Here, listOf plays the role of pure by putting each transformed value into a one-element list:

inline fun <A, B> List<A>.mapViaFlatMap(
    transform: (A) -> B
): List<B> =
    flatMap { value -> listOf(transform(value)) }

val lengths: List<Int> =
    listOf("Ada", "Grace").mapViaFlatMap(String::length)

// [3, 5]

flatMap invokes transform for every element and concatenates the resulting one-element lists. The result is the same as calling the standard List.map.

Formally, flatMap alone cannot derive map; it also needs a way to put the transformed B back into the structure. Haskell exposes that operation as pure through Applicative, which every Monad must provide. Generic Haskell code can call it without knowing whether the concrete type is a list, Maybe, or Either.

Kotlin has no shared Monad interface, so application code rarely uses the name pure. Concrete constructors such as listOf, Outcome.Success, and Arrow's Either.Right fill that role, and each type normally provides map directly.

A Different Rule: Accumulating Messages

We have already seen that monads can take different forms: nullable values short-circuit on absence, while lists branch and concatenate results. A pattern traditionally called Writer provides another rule: continue normally while accumulating messages as data. We will call our small Kotlin version Logged.

data class Logged<A>(
    val value: A,
    val messages: List<String>,
) {
    fun <B> flatMap(next: (A) -> Logged<B>): Logged<B> {
        val result = next(value)
        return Logged(
            value = result.value,
            messages = messages + result.messages,
        )
    }

    companion object {
        fun <A> pure(value: A): Logged<A> =
            Logged(value, emptyList())
    }
}

Logged<A> contains a value and the messages collected while producing it. flatMap passes the value to next, takes the new value, and appends the new messages to those already collected.

For example, a price calculation can retain an explanation of each adjustment:

fun applyDiscount(total: Int): Logged<Int> =
    Logged(total - 10, listOf("Applied discount: -10"))

fun addShipping(total: Int): Logged<Int> =
    Logged(total + 5, listOf("Added shipping: +5"))

val checkout: Logged<Int> =
    Logged.pure(100)
        .flatMap(::applyDiscount)
        .flatMap(::addShipping)

// checkout.value == 95
// checkout.messages ==
//     ["Applied discount: -10", "Added shipping: +5"]

The chain carries one value forward and preserves the messages in order. There is no short-circuiting or branching.

The messages are ordinary data, not calls to an external logging system. This pattern can be useful for calculation explanations, audit details, or generated warnings that a caller must inspect. For a small fixed calculation, returning a named result object directly is often simpler than introducing Logged.

Outcome and Logged have the same operation shapes but different sequencing rules.

Type shape Meaning Sequencing rule
A? A value may be absent Continue with a present value; stop on null
Outcome<E, A> A value or typed failure Continue on success; preserve failure
List<A> Zero or more values Run the next step for every value; concatenate the results
Logged<A> A value plus accumulated messages Pass the value forward; append messages in order
suspend () -> A with suitable operations A suspending calculation Run calculations in sequence and pass the result onward

The word context is often used for the structure represented by M. In this document it does not mean Kotlin's CoroutineContext.

5. The Laws: What Makes the Operations Predictable

Any methods can be named pure and flatMap. A monad requires them to obey three laws.

Suppose these declarations exist:

val value: A
val computation: M<A>
fun firstOperation(value: A): M<B>
fun secondOperation(value: B): M<C>

The laws are:

// Left identity
pure(value).flatMap(::firstOperation) == firstOperation(value)

// Right identity
computation.flatMap(::pure) == computation

// Associativity
computation.flatMap(::firstOperation).flatMap(::secondOperation) ==
    computation.flatMap { value ->
        firstOperation(value).flatMap(::secondOperation)
    }

The == signs mean equivalent behavior, not necessarily Kotlin's equals implementation or object identity. For collection monads, the equations assume pure transformation functions and compare the resulting values. Kotlin permits side effects inside those functions; such effects can expose evaluation order that lies outside this value-based model.

Left Identity

Putting a value into the minimal context and immediately sequencing firstOperation must behave like calling firstOperation directly. pure cannot secretly add logging, failure, extra list elements, or another observable action.

Right Identity

Sequencing a computation with pure must not change it. This law catches implementations that accidentally run work early, duplicate it, discard information, or alter state.

Associativity

Regrouping a chain must not change its meaning. This lets libraries build long chains without parentheses deciding their behavior.

Associativity does not permit reordering:

(m flatMap firstOperation) flatMap secondOperation

may be regrouped, but secondOperation may not be moved before firstOperation. Later computations often depend on earlier results, and observable actions may depend on their order.

For asynchronous or effectful APIs, equivalence must include relevant behavior such as evaluation time, cancellation, failure, resource lifetime, and produced side effects. Matching generic signatures alone is not enough.

How Relevant Are These Laws in Kotlin?

Most Kotlin application developers do not need to prove these laws or think about them while writing ordinary ?.let, collection, Result, or Arrow code. The laws matter most when implementing a type such as Logged, designing a library operation named flatMap, or testing whether a custom sequencing rule is predictable.

Associativity is not limited to code that explicitly stores or combines lambda values. Every flatMap call already accepts a function describing what happens next, even when that function is written as a method reference. The law becomes practical when a chain is extracted into a helper:

val inlineWorkflow: Logged<Int> =
    Logged.pure(100)
        .flatMap(::applyDiscount)
        .flatMap(::addShipping)

fun discountedThenShipped(total: Int): Logged<Int> =
    applyDiscount(total).flatMap(::addShipping)

val extractedWorkflow: Logged<Int> =
    Logged.pure(100).flatMap(::discountedThenShipped)

Associativity says that inlineWorkflow and extractedWorkflow have the same value and messages. In this pure workflow, extracting a named function changes the grouping but not the result.

This kind of regrouping is uncommon in small Kotlin chains, and standard-library or Arrow types have already made the relevant design choices. The laws support value-preserving refactors of pure code; they do not make side-effectful callbacks freely interchangeable. They become a direct engineering concern when defining a new flatMap, especially around evaluation timing, concurrency, cancellation, or resources.

6. Monad-Shaped Ideas in the Kotlin Standard Library

Kotlin does not label APIs as monads, but several familiar APIs expose parts of the pattern.

Nullable Values

The opening example gave A? both required operations: an ordinary non-null value can be used as A?, and ?.let provides the sequencing behavior. This is the nullable monad in practical terms. A successful null cannot be distinguished from absence, so this model only has one meaning for null.

The idiomatic Kotlin spelling is usually ?.let, ?:, or direct early returns, not a custom method called flatMap:

val city: City? = user
    ?.let(::loadAddress)
    ?.let(::loadCity)

The monad vocabulary explains the composition rule; it does not require replacing readable Kotlin control flow with abstract terminology.

Lists and Other Collections

For List<A>, listOf(value) plays the role of pure and List.flatMap is already in the standard library:

data class Order(
    val number: String,
    val items: List<String>,
)

val orders = listOf(
    Order("A-100", listOf("keyboard", "mouse")),
    Order("A-101", listOf("monitor")),
)

val packingList: List<String> = orders.flatMap { order ->
    order.items.map { item -> "${order.number}: $item" }
}

// [
//     "A-100: keyboard",
//     "A-100: mouse",
//     "A-101: monitor",
// ]

Each order produces its own list of packing entries. flatMap runs that transformation for every order and concatenates the results into one packing list. This is List's sequencing rule: one value may branch into zero, one, or many next values.

The List monad can model nondeterministic choice: one position may have many possible next positions. For a chess board, a function can return every board reachable by one legal move:

val initialBoard: Board = TODO()

fun legalNextBoards(board: Board): List<Board> = TODO()

val positionsAfterThreePlies: List<Board> =
    listOf(initialBoard)
        .flatMap(::legalNextBoards)
        .flatMap(::legalNextBoards)
        .flatMap(::legalNextBoards)

The first flatMap produces every position after one move, the second expands every one of those positions, and the third does so again. The resulting list is one level of the game tree after three plies, where one ply is one player's move.

This is the usual List rule, not a different List monad. It preserves order and may contain the same position more than once when different move sequences reach it. If an algorithm only cares about unique positions, a set-like abstraction can use a different flattening rule: union the branches and discard duplicates.

inline fun <A, B> Set<A>.flatMapToSet(
    next: (A) -> Set<B>
): Set<B> = buildSet {
    this@flatMapToSet.forEach { value ->
        addAll(next(value))
    }
}

fun uniqueLegalNextBoards(board: Board): Set<Board> =
    legalNextBoards(board).toSet()

val uniquePositionsAfterTwoPlies: Set<Board> =
    setOf(initialBoard)
        .flatMapToSet(::uniqueLegalNextBoards)
        .flatMapToSet(::uniqueLegalNextBoards)

Here setOf(value) plays the role of pure, and flatMapToSet combines branches with set union. This is a Set-style monad rather than a List monad. Board.equals must include every rule-relevant part of the position before deduplication is safe.

Real chess engines cannot expand the full tree for long because the number of positions grows rapidly. They combine this branching model with evaluation, pruning, caching, and algorithms such as minimax with alpha-beta pruning. The monadic view explains the branching step; it is not the complete search algorithm.

Kotlin Result

Result<A> represents either a successful A or failure with a Throwable. The standard library provides operations such as map, mapCatching, recover, and fold, but it does not provide flatMap under that name.

An application can define one:

inline fun <A, B> Result<A>.flatMap(
    transform: (A) -> Result<B>
): Result<B> = fold(
    onSuccess = transform,
    onFailure = { error -> Result.failure(error) },
)

Rewriting the account example so every step returns Result gives the familiar shape:

fun parseUserId(input: String): Result<UserId> = TODO()
fun findUser(id: UserId): Result<User> = TODO()
fun loadAccount(user: User): Result<Account> = TODO()

fun accountFor(input: String): Result<Account> =
    parseUserId(input)
        .flatMap(::findUser)
        .flatMap(::loadAccount)

This extension preserves an existing Result.failure, but an exception thrown by transform escapes. Another implementation could catch it and return a failure instead. That choice needs a clear policy for fatal exceptions and coroutine cancellation; the monad abstraction does not choose one.

Scope Functions Are Building Blocks, Not Monads

Functions such as let, run, and also are higher-order functions: they accept another function. That alone does not make them monads.

val length: Int = "Kotlin".let(String::length)

Plain let has no reusable outer type shape and no short-circuiting rule. In value?.let(...), the nullable safe-call supplies the interesting structure. This distinction is useful whenever familiar syntax looks "monadic": identify which type or language feature actually controls sequencing.

7. Suspending Functions and Coroutines

Sequential suspending Kotlin often looks like direct-style monadic code:

suspend fun fetchAccount(): Account {
    val user = fetchUser()
    return fetchAccountFor(user)
}

The result of fetchUser is assigned a local name, and the next suspending call can depend on it. This is similar in spirit to do notation in languages that expose monadic sequencing directly.

The resemblance has limits. suspend allows a function to call other suspending functions and to pause at suspension points. It does not launch a coroutine, imply concurrency, turn blocking calls into non-blocking ones, or record side effects in the return type.

A family of suspending function values can be given monad-shaped operations explicitly:

typealias Suspended<A> = suspend () -> A

fun <A> suspended(value: A): Suspended<A> = { value }

fun <A, B> flatMapSuspended(
    first: Suspended<A>,
    next: (A) -> Suspended<B>,
): Suspended<B> = {
    val value = first()
    next(value)()
}

The type shape here is suspend () -> A, not the keyword suspend by itself. Exception, cancellation, and coroutine-context behavior are part of this type's observable behavior, so they also matter when judging the laws.

Ordinary sequential calls are clearer than wrapping every suspending function in Suspended. The model explains why direct-style code composes; Kotlin lets us write that composition without spelling out flatMap.

Deferred<A> Is a Work Handle

Deferred<A> is a result-bearing Job, usually created by async. The work may not have started yet, may be running, or may already be complete, failed, or cancelled. await() is a suspending call that returns the successful result or rethrows the deferred work's failure. It throws CancellationException when the deferred work or the awaiting coroutine is cancelled; prompt cancellation means caller cancellation can win even when a result has just become available.

await: suspend (Deferred<A>) -> A

That differs from flatMap:

flatMap: Deferred<A> + (A -> Deferred<B>) -> Deferred<B>

await() has no next function and returns a plain value. The standard Deferred API has no monadic pure and flatMap pair.

A library could add such operations, but it would have to define who owns the combined coroutine, when each stage starts, and how failure, cancellation, and CoroutineContext propagate. Kotlin normally avoids that extra abstraction: await the result inside structured suspending code, use Deferred<A> when separately started work needs a result handle, and use Job when only completion matters.

8. Arrow: Concrete Types and Direct-Style Contexts

Arrow brings typed functional patterns to Kotlin. Current Arrow favors concrete data types and receiver-based DSLs rather than one generic Monad<F> interface.

The examples in this chapter use Arrow 2.2.3. Older tutorials may show a very different API.

Either<E, A> and flatMap

Arrow's Either<E, A> represents Left(error) or Right(value). Its flatMap acts on the right, successful side and follows the same rule as the earlier Outcome example:

fun parseUserId(input: String): Either<DomainError, UserId> = TODO()
fun findUser(id: UserId): Either<DomainError, User> = TODO()
fun loadAccount(user: User): Either<DomainError, Account> = TODO()

fun accountFor(input: String): Either<DomainError, Account> =
    parseUserId(input)
        .flatMap(::findUser)
        .flatMap(::loadAccount)

Left skips the remaining functions; Right supplies its value to the next function. Either.Right(value) plays the role of pure.

This is the most direct correspondence between the formal monad operations and a common Kotlin library API.

Raise<E> and bind()

Arrow also supports direct-style typed errors through Raise and builders such as either:

fun accountFor(
    input: String
): Either<DomainError, Account> = either {
    val id = parseUserId(input).bind()
    val user = findUser(id).bind()
    loadAccount(user).bind()
}

Inside either, bind() returns the value from Right or ends the block with the error from Left. The statements that follow describe what happens after success; in functional terminology, they form the continuation.

This has the same success/failure sequencing behavior as the Either.flatMap chain, but Arrow's bind() is not itself the formal two-input operation:

formal flatMap: Either<E, A> + (A -> Either<E, B>) -> Either<E, B>
Raise bind:     Either<E, A> -> A inside a surrounding Raise<E> scope

The surrounding block supplies the rest of the computation, so the code reads like ordinary sequential Kotlin while retaining typed short-circuiting.

Raise<E> has one central operation:

interface Raise<in E> {
    fun raise(error: E): Nothing
}

Because raise returns Nothing, execution does not continue after the call. That makes Raise suitable for early exit, not for operations such as reading and updating State that must resume with a value.

Arrow and Suspending Functions Solve Different Problems

A suspending function can also use typed errors:

suspend fun Raise<DomainError>.accountFor(input: String): Account {
    val id = parseUserId(input).bind()
    val user = fetchUser(id).bind()
    return loadRemoteAccount(user).bind()
}

suspend permits suspension; Raise<DomainError> provides typed early exit. Cancellation is observed at cancellable suspension points or through explicit checks. Thrown exceptions and cancellation remain separate from DomainError, and Raise does not track network or database access.

When nested computations use different error types, the program must define how they enter the surrounding domain error. Arrow provides operations such as withError, while Either.mapLeft can perform the conversion before bind.

Current Arrow and Old Arrow Are Different

Arrow 2.2.3 has no generic Monad<F> interface or higher-kinded-type encoding. It also no longer ships the old State, StateT, Reader, Writer, or WriterT APIs.

Current Arrow favors concrete types and focused scopes. Option, Either, Eval, NonEmptyList, and Ior each have their own composition operations. Raise handles typed short-circuiting, while Resource manages acquisition and cleanup. These APIs do not implement one shared monad interface.

Code importing arrow.typeclasses.Monad, arrow.mtl.State, or StateT belongs to older Arrow releases.

9. Choosing the Right Kotlin Representation

Monad vocabulary is useful when it helps choose a concrete type. Start with what the caller needs to know.

Describe the Result

The caller needs A typical choice
A value, or no value, with no explanation A?
Success or an exception from a boundary call Result<A> with a clear catching policy
Success or an expected domain failure Arrow Either<E, A> or Raise<E>
Zero or more answers List<A> or another collection
A value together with explanations or warnings A named result type, or Logged<A> when the pattern repeats

For the account example, nullable results are enough if callers only ask, "Did we find an account?" Use Either or Raise when they must distinguish invalid input, a missing user, and an unavailable account.

Describe Execution Separately

Result types and execution types answer different questions. A function may suspend and still return an Either:

suspend fun loadAccount(
    input: String
): Either<DomainError, Account> = TODO()

Here suspend describes how the call runs, while Either describes its normal result. Use Deferred<A> only when separately started work needs a result handle, and Job when only completion matters.

Keep the Abstraction Proportionate

For a short, fixed workflow, direct Kotlin is usually best: early returns, safe calls, mapIndexed, or a small named result class. Reach for a reusable abstraction when the same sequencing rule appears throughout an API or when the type communicates behavior that callers must handle. The next chapter looks at State, a useful but deliberately advanced example of that tradeoff.

10. State and Context Order

State shows how far the same pure and flatMap pattern can stretch.

A State Transition Is a Function

The following State type is ordinary Kotlin, not an Arrow 2.2.3 API. It stores a state transition as a function:

class State<S, A>(
    val run: (S) -> Pair<S, A>
) {
    fun <B> flatMap(next: (A) -> State<S, B>): State<S, B> =
        State { initialState ->
            val (updatedState, value) = run(initialState)
            next(value).run(updatedState)
        }

    companion object {
        fun <S, A> pure(value: A): State<S, A> =
            State { state -> state to value }
    }
}

State<S, A> does not hold the current state. Its run function accepts an initial S and returns the updated state together with an A.

initial S -> run -> (updated S, produced A)

flatMap builds a larger transition: run the first transition, use its value to choose the next one, then run that transition with the updated state.

For example, an integer state can represent the next sequence number:

fun numbered(name: String): State<Int, String> =
    State { nextNumber ->
        (nextNumber + 1) to "$nextNumber. $name"
    }

val numberNames: State<Int, List<String>> =
    numbered("Ada").flatMap { ada ->
        numbered("Grace").flatMap { grace ->
            State.pure<Int, List<String>>(listOf(ada, grace))
        }
    }

val (nextNumber, labels) = numberNames.run(1)

// nextNumber == 3
// labels == ["1. Ada", "2. Grace"]

The first transition receives 1 and returns 2; the second receives 2 and returns 3. No shared variable is mutated. For this fixed numbering task, mapIndexed would be simpler.

State becomes more useful when the same state must pass through many reusable operations. A compiler, for example, may carry a temporary-name counter, a symbol table, and emitted instructions:

fun freshTemporary(): State<CompilerState, Temporary> = TODO()
fun declare(temporary: Temporary): State<CompilerState, Symbol> = TODO()
fun emitStore(symbol: Symbol): State<CompilerState, Unit> = TODO()

val compileAssignment: State<CompilerState, Unit> =
    freshTemporary().flatMap { temporary ->
        declare(temporary).flatMap { symbol ->
            emitStore(symbol)
        }
    }

Each operation receives the compiler state left by the previous one. Without State, callers would repeatedly unpack Pair<CompilerState, A> and forward the first component. The wrapper earns its keep only when that plumbing appears across many workflows. It is unrelated to synchronization or database transactions.

Context Order Changes Meaning

Combining State with typed failure requires a choice. Here, order means which context sits on the outside: failure around State, or State around failure. The two types report different information after failure:

typealias StateEither<S, E, A> =
    (S) -> Either<E, Pair<S, A>>

typealias StateWithError<S, E, A> =
    (S) -> Pair<S, Either<E, A>>

StateEither loses the final state when it returns Left. StateWithError always returns a state, even when its value is Left. Choose based on whether partial progress should remain visible. Neither type can roll back a database write or shared mutation; external rollback still needs a transaction.

Haskell libraries often package recurring combinations in monad transformers. Current Kotlin usually uses a concrete combined type, an explicit parameter, or a focused scope such as Raise. The extra specificity is often helpful here because behavior such as retaining state on failure stays visible.

11. Reading Monad Terminology

Kotlin usually presents these ideas through concrete methods and scopes. Haskell exposes the common abstraction more directly, so its vocabulary appears often in articles and API documentation.

Haskell and Arrow Spell the Ideas Differently

Term Meaning
pure(value) Put a value into the current monadic structure
bind The monadic sequencing operation called flatMap in Kotlin-style APIs
>>= Haskell's operator for bind
do notation Haskell syntax for naming intermediate results in a monadic chain
Raise.bind() Extract a successful value inside an Arrow Raise scope

The same Either chain can appear in two forms:

Kotlin: parse(input).flatMap(::find).flatMap(::load)
Haskell: parse input >>= find >>= load

Arrow's Raise.bind() gives similar short-circuiting behavior in direct-style code, but it is not literally the two-input flatMap operation. The surrounding either or Raise block supplies the rest of the computation.

What the Abstraction Leaves Open

Sequencing behavior varies. Lists branch, Outcome stops on failure, Logged accumulates messages, and State passes a state value explicitly. They share a lawful composition shape, not one runtime behavior. The State wrapper itself does not enforce immutability or purity.

Method names need supporting semantics. A method called flatMap still needs a compatible way to introduce values and behavior that satisfies the laws. Related operations may have different jobs: await() obtains a Deferred result, while suspend marks a function that may suspend.

Kotlin style remains Kotlin style. Lambdas may perform side effects regardless of the method that accepts them. Early returns, safe calls, ordinary suspending code, and Arrow's direct style are often clearer than an explicit flatMap chain.

Reading an Unfamiliar API

When an API looks monadic, work through three questions:

  1. What is the outer type shape, how does an ordinary value enter it, and how are operations chained?
  2. What does the shape mean, and what does chaining preserve, skip, combine, or defer?
  3. Do regrouping and identity preserve all observable behavior, including failure, timing, cancellation, state, resources, and side effects?

We can now restate the opening definition more plainly:

A monad provides a lawful way to put values into a computational structure and chain functions that produce more values in the same structure.

That definition is a tool for reading and designing APIs. It is not a reason to replace clear Kotlin with more abstract code.

References

← Back to all writing