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, keepnulland do not callnext. - 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 asIO<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.
Side Note: Collections as Game-Tree Search
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:
- What is the outer type shape, how does an ordinary value enter it, and how are operations chained?
- What does the shape mean, and what does chaining preserve, skip, combine, or defer?
- 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.