DEV Community

Cover image for The Seed Decides Everything: fold() vs reduce() in Production Kotlin on Android
Android Expert
Android Expert

Posted on Originally published at Medium

The Seed Decides Everything: fold() vs reduce() in Production Kotlin on Android

fold() and reduce() look like twins. Both walk a collection, both hand you an acc and the next element, and both collapse everything into one value. Most developers pick whichever name comes to mind first, and for a long time nothing bad happens.

Then a Room query returns zero rows, a Flow emits its first empty list, and a screen crashes in production with UnsupportedOperationException. Or a "harmless" reduce quietly rewrites the first item in your UI state. Or a total of cached bytes wraps around to a negative number on a device with a big cache.

All of these trace back to one question: where does the accumulator start?

This post walks through the mechanics, the type rules that follow from them, and the failure modes I would flag in a code review. The examples use a download and sync domain, so none of it is tied to a particular app.

The one difference that matters

  • fold(initial) starts from a value you provide, then processes every element.
  • reduce starts from the first element and processes the remaining ones.

accumulator-start

Here is the same sum written both ways:

data class DownloadPart(
    val index: Int,
    val bytes: Long,
    val error: String? = null,
)

val parts = listOf(
    DownloadPart(index = 0, bytes = 4_096),
    DownloadPart(index = 1, bytes = 8_192),
    DownloadPart(index = 2, bytes = 2_048),
)

// reduce needs elements that are already the accumulator's type, so we map first.
val viaReduce: Long = parts
    .map { it.bytes }
    .reduce { acc, bytes -> acc + bytes }

// fold starts from 0L, so it can read the field directly and skips the intermediate list.
val viaFold: Long = parts.fold(0L) { acc, part -> acc + part.bytes }
Enter fullscreen mode Exit fullscreen mode

Both give 14_336. Look at what each version had to do to get there, though. The reduce version had to turn DownloadPart into Long first, because the accumulator and the elements share a type. The fold version was free to bring its own seed type.

(For a plain sum you would normally write parts.sumOf { it.bytes }. I'm using fold here because it is the simplest case that shows the mechanics.)

Type freedom: why fold builds state and reduce merges values

Because fold takes an explicit seed, its result type R is independent of the element type T:

inline fun <T, R> Iterable<T>.fold(
    initial: R,
    operation: (acc: R, T) -> R,
): R
Enter fullscreen mode Exit fullscreen mode

reduce has no seed to infer anything from. Its signature ties the accumulator to the elements:

inline fun <S, T : S> Iterable<T>.reduce(
    operation: (acc: S, T) -> S,
): S
Enter fullscreen mode Exit fullscreen mode

S has to be a supertype of T, so in practice the result is the element type, or a common parent of it.

type-flexibility

That is why fold is the natural tool when you are building a different shape out of a list. Here a list of parts becomes a transfer report:

data class TransferReport(
    val completedBytes: Long = 0,
    val failedParts: List<Int> = emptyList(),
)

fun List<DownloadPart>.toReport(): TransferReport =
    fold(TransferReport()) { report, part ->
        if (part.error == null) {
            report.copy(completedBytes = report.completedBytes + part.bytes)
        } else {
            report.copy(failedParts = report.failedParts + part.index)
        }
    }
Enter fullscreen mode Exit fullscreen mode

reduce could not express this, since DownloadPart and TransferReport have nothing in common.

reduce is the better fit when the first element really is a valid starting point and you are merging same-shaped values. A good example is combining time windows into one covering window:

data class SyncWindow(val startMs: Long, val endMs: Long)

fun List<SyncWindow>.coveringWindow(): SyncWindow? =
    reduceOrNull { a, b ->
        SyncWindow(
            startMs = minOf(a.startMs, b.startMs),
            endMs = maxOf(a.endMs, b.endMs),
        )
    }
Enter fullscreen mode Exit fullscreen mode

There is no meaningful “zero window” to seed with, which is exactly why starting from the first element feels natural here. The function returns SyncWindow? for a reason, and the next section explains it.

Failure mode 1: the empty collection

reduce cannot produce a value when there is no first element, so it throws. fold already holds its seed, so it simply returns it.

empty-collection

On Android, empty collections are normal runtime states. They show up on a fresh install, after “clear cache”, on the first emission of a Flow, and for an account with no data yet. Here is a bug I have seen in different forms in several codebases:

class StorageViewModel(dao: CachedFileDao) : ViewModel() {

    val usedBytes: StateFlow<Long> = dao.observeAll()
        .map { files ->
            files.map { it.sizeBytes }.reduce { a, b -> a + b } // throws when the cache is empty
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
}
Enter fullscreen mode Exit fullscreen mode

It works in every manual test, because the tester always has data. It crashes the first time observeAll() emits an empty list. A crash inside map on a flow collected by stateIn goes through the scope's exception handling, so depending on your setup it can take down the screen or the whole process.

You have three sound fixes, and the choice depends on what “empty” should mean:

// 1. An empty total is a legitimate zero.
files.fold(0L) { acc, f -> acc + f.sizeBytes }   // or simply files.sumOf { it.sizeBytes }

// 2. Absence is a legitimate answer and the caller must handle it.
files.map { it.lastAccessMs }.reduceOrNull { a, b -> maxOf(a, b) }   // Long?

// 3. The input is guaranteed non-empty, and you document why.
require(files.isNotEmpty()) { "Cache snapshot must contain at least one entry" }
Enter fullscreen mode Exit fullscreen mode

My rule is that reduce is acceptable only when I can point to the line that guarantees non-emptiness. If I can't, it is fold or reduceOrNull.

Failure mode 2: reduce mutates your first element

Because the first element becomes the accumulator, a lambda that mutates acc mutates your data:

class ShardTally(var hits: Int, var misses: Int)

val combined = shardTallies.reduce { acc, t ->
    acc.also {
        it.hits += t.hits
        it.misses += t.misses
    }
}
Enter fullscreen mode Exit fullscreen mode

After this call, shardTallies[0] holds the combined totals. If that list backs a StateFlow, observers now hold an object that changed without any emission. Call the function a second time and the first shard is counted twice. This kind of bug is slow to reproduce, because it depends on call order.

The fix is to produce new values and keep the elements immutable:

data class ShardTally(val hits: Int = 0, val misses: Int = 0)

val combined = shardTallies.fold(ShardTally()) { acc, t ->
    ShardTally(acc.hits + t.hits, acc.misses + t.misses)
}
Enter fullscreen mode Exit fullscreen mode

Failure mode 3: the shared mutable seed

fold has a mirror-image trap. The seed is just an argument, so if you hand it something that outlives the call, every call mutates the same object:

private val pending = mutableListOf<String>()   // lives as long as the class

fun collectNames(parts: List<DownloadPart>): List<String> =
    parts.fold(pending) { acc, p -> acc.apply { add("part-${p.index}") } }
Enter fullscreen mode Exit fullscreen mode

The first call returns three names. The second returns six. Always construct the seed inside the call, for example fold(mutableListOf<String>()).

Failure mode 4: the O(n²) copy-on-every-step

Immutable seeds and copy() read nicely, but + on a List or Map allocates a new collection each time. In the earlier toReport() I used report.failedParts + part.index, which is fine for a handful of parts and quietly quadratic for a large journal.

For large inputs, accumulate into a private builder and freeze it once at the end:

fun List<DownloadPart>.toReportFast(): TransferReport {
    var completed = 0L
    val failed = ArrayList<Int>()
    for (part in this) {
        if (part.error == null) completed += part.bytes else failed += part.index
    }
    return TransferReport(completedBytes = completed, failedParts = failed)
}
Enter fullscreen mode Exit fullscreen mode

This is still a fold in spirit, and the mutable state never escapes the function. If you prefer to keep the fold shape, seed it with a small private accumulator class and read it out at the end. Either way, measure first. Most lists on a phone screen are short enough that readability wins.

Failure mode 5: integer overflow in the seed

The seed’s type is chosen by you, and 0 is an Int:

// sizeKb is an Int field on each cache entry
val totalBytes = entries.fold(0) { acc, e -> acc + e.sizeKb * 1024 }
Enter fullscreen mode Exit fullscreen mode

Once the cache passes 2 GiB, this wraps to a negative number. A “storage used” label that shows -1.9 GB is a good way to learn that fold(0) and fold(0L) are different programs. Pick the widest sensible type for the seed, and convert before multiplying (e.sizeKb.toLong() * 1024).

Failure mode 6: swapping one for the other changes the answer

Replacing reduce with fold(0) looks like a safe refactor. It is safe for addition, because the seed is the identity element. It silently changes results for operations where the first element is special:

val steps = listOf(100, 30, 20)

steps.reduce { a, b -> a - b }   // 50    (100 - 30 - 20)
steps.fold(0) { a, b -> a - b }  // -150  (0 - 100 - 30 - 20)
Enter fullscreen mode Exit fullscreen mode

The same applies to min/max-style merges, string joins with separators, and anything non-commutative. Before swapping, ask whether your seed is a true identity for the operation.

Where fold really shines: replayable state

Android developers see the clearest payoff of fold in unidirectional state. A reducer has the shape (State, Event) -> State, which is exactly what fold expects, and reduce could never fit it because the two types differ.

sealed interface SyncEvent {
    data class Started(val totalParts: Int) : SyncEvent
    data class PartDone(val bytes: Long) : SyncEvent
    data class Failed(val reason: String) : SyncEvent
}

data class SyncState(
    val totalParts: Int = 0,
    val doneBytes: Long = 0,
    val failure: String? = null,
) {
    fun transition(event: SyncEvent): SyncState = when (event) {
        is SyncEvent.Started -> copy(totalParts = event.totalParts, doneBytes = 0, failure = null)
        is SyncEvent.PartDone -> copy(doneBytes = doneBytes + event.bytes)
        is SyncEvent.Failed -> copy(failure = event.reason)
    }
}

// Rebuild the state after process death from a persisted event journal.
val restored: SyncState = journal.fold(SyncState(), SyncState::transition)

// Same events, but keep every intermediate state, for debugging or time-travel tooling.
val timeline: List<SyncState> = journal.runningFold(SyncState(), SyncState::transition)
Enter fullscreen mode Exit fullscreen mode

Two production notes:

  • The reducer must be deterministic. No clock reads, no random IDs, no I/O inside transition. If replay produces a different state than the original run, the journal is useless.
  • Version your events. A journal written by last month’s app will contain shapes that today’s when has never seen. Decide on purpose whether unknown events are skipped, migrated, or fatal.

If the journal can grow large, run the fold on Dispatchers.Default rather than the main thread, and use asSequence() first when you also filter or map to avoid intermediate lists.

A quick decision guide

decision-flow

In words, the questions I ask are:

  1. Do I need to define the starting state myself? If yes, use fold.
  2. Is the first element naturally the starting state, and is the result the same type as the elements? If yes, reduce can be the cleaner choice.
  3. Can the collection be empty at runtime? If so, use fold with a sensible seed, or reduceOrNull and handle the null.

🔚 Takeaways

  • The only real difference is the starting accumulator, but it drives everything else: type flexibility, empty-input behavior, and what your lambda is allowed to mutate.
  • fold is the default for building state or a different type, and for any input that might be empty.
  • reduce is for merging same-typed values when the first element is a valid start and non-emptiness is guaranteed, or when you use reduceOrNull and handle the null.
  • Never mutate the accumulator when it is also one of your elements, and never reuse a seed across calls.
  • Choose the seed’s numeric type deliberately, and check that the seed is an identity before swapping reduce for fold.

Once the accumulator is the first thing you look at, the two functions stop being confusing and start reading as two different statements about where your data begins.

📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.


Originally published on Medium.

Top comments (0)