Post

Functional Programming in Kotlin: What Interviewers Mean by It, and Where It Stops Being Pragmatic on the JVM

The functional programming question in a Kotlin interview is not about lambdas. It is about immutability that is real, domain models where illegal states cannot exist, errors as values with the right channel for each kind, laziness with a cost model, and knowing where the JVM makes the pure approach expensive. Every snippet compiled and run on Kotlin 2.0.

Functional Programming in Kotlin: What Interviewers Mean by It, and Where It Stops Being Pragmatic on the JVM

“Do you write functional Kotlin?” is a question almost every Kotlin candidate gets and almost nobody answers well, because the honest answer is “sort of” and the interviewer is not asking about style. They are asking whether you know what the functional constraints buy you, whether you apply them where they pay and drop them where they cost, and whether you can model a domain so that the compiler catches the bugs instead of the test suite.

This post is the functional programming round for a Kotlin candidate: the three-sentence model, the questions in the order they come, the gotchas that reveal someone who uses map and filter but has not thought about immutability or errors, and ten compiled snippets. It leans on the JVM runtime post for why immutability is a concurrency tool and on the coding posts for the language traps that still apply. The snippets were compiled and run on Kotlin 2.0.21 before publishing, which matters for this post because Result as a return type and data object both need a compiler newer than the one the earlier posts used.

The Question

  • “What does functional programming mean to you in Kotlin?”
  • “How do you keep a domain model immutable? Is val enough?”
  • “How do you handle errors without exceptions?”
  • “When do you use a sealed class instead of an enum or an interface?”
  • “What is the difference between a List and a Sequence?”
  • “Explain fold versus reduce.”
  • “Why does the standard library mark so many functions inline?”
  • “Is Kotlin a functional language?”
  • “Write a pipeline that parses, validates, and aggregates records without mutation.”
  • “Where does the functional approach stop being worth it on the JVM?”

The last one is the senior question. A candidate who cannot say where to stop has not used the approach at scale.

What They Are Really Checking

  1. Do you know what the constraints buy? Immutability buys local reasoning and free thread safety. Purity buys testability and caching. Totality buys the compiler as a reviewer. Candidates who say “cleaner code” have not thought about it.
  2. Is your immutability real? val on a MutableList is not immutable. A data class with a var is not a value. The interviewer will find the hole.
  3. Can you make illegal states unrepresentable? Sealed hierarchies with exhaustive when are the single most useful functional idea in Kotlin, and the interviewer wants to see you reach for them without being prompted.
  4. Do you pick the right error channel? Nullable for absence, Result for technical failure at a boundary, a sealed type for domain failure that carries data. Using one for all three is the tell.
  5. Do you have a cost model? Every chained collection call allocates a list. Every non-inline lambda allocates an object. Function types box primitives. Laziness has overhead. Say the costs and where they matter.
  6. Do you know where to stop? A local StringBuilder inside a pure function is fine. A monad transformer stack in a CRUD service is not.

The Gotchas

Gotcha 1: val is not immutability. val list = mutableListOf(1) cannot be reassigned and can be mutated freely. List<T> is a read-only view; whoever holds the underlying MutableList can still change it under you. Immutability is a property of the object and its reachable graph, and the tools are toList() copies at boundaries, data class with vals all the way down, and persistent collections when structural sharing matters.

Gotcha 2: copy() is shallow. order.copy(status = PAID) shares the original’s lines list. If that list is mutable, both orders see the mutation. Shallow copy plus deep immutability is fine; shallow copy plus a mutable field is a bug waiting for the second writer.

Gotcha 3: Every chained collection operation allocates a full intermediate list. list.map { }.filter { }.take(3) builds two complete lists to produce three elements. asSequence() makes the pipeline lazy and element-at-a-time. For small collections the eager version is faster; for large ones or early termination, the sequence wins by orders of magnitude. Know which you have.

Gotcha 4: A Sequence is cold and re-runs. Calling count() and then toList() on the same sequence runs the whole pipeline twice, including any side effects in the lambdas. Materialize once if you need the result twice.

Gotcha 5: reduce throws on empty; fold does not. reduce has no seed, so an empty input is an error, and its result type is the element type. fold takes a seed and can change type. Use fold when the input can be empty or the accumulator is a different type; use a Long accumulator when summing Ints.

Gotcha 6: else on a sealed when defeats the point. The compiler checks exhaustiveness only when there is no else. Add a subclass next year and the else branch silently handles it wrong. Never write else for a sealed subject; let the compiler tell you what you missed.

Gotcha 7: runCatching catches everything. Including CancellationException, per the concurrency post, and including OutOfMemoryError. Result is for technical failure at a boundary you control. It is not a domain type, and getOrThrow() puts the exception right back.

Gotcha 8: Nullable as an error channel. T? says “absent.” It does not say why. A parser that returns null for empty input, bad syntax, and out-of-range values has thrown away the one thing the caller needed. Use a sealed error type when the reason matters.

Gotcha 9: Recursion without tailrec overflows. The JVM has no tail-call optimization. tailrec makes the compiler rewrite a direct self-call in tail position into a loop, and it warns when the call is not actually in tail position. Anything else that is recursive over a large input needs an explicit loop, an explicit stack, or DeepRecursiveFunction.

Gotcha 10: Function types box primitives. (Int) -> Int compiles to Function1<Integer, Integer>. Every call boxes the argument and the result unless the function taking it is inline. This is why the standard library’s collection operations are inline, and why your own hot-path higher-order functions should be too.

How to Answer

Step 1: The model in three sentences

Functional programming, for me, is three constraints: data does not change after it is created, functions compute results from their inputs without touching anything else, and the type says everything that can come out. The payoff is that I can reason about any piece of code locally, test it without setup, share it between threads without locks, and have the compiler reject states that should not exist. I apply the constraints where they buy that, which is the domain model and the business logic, and I relax them at the edges and inside hot loops where the JVM makes them expensive.

That answers “what does it mean to you” and “where does it stop” in one breath, and it makes the interviewer’s next question one you have already framed.

Step 2: Immutability that is real

Level What it means Kotlin
Reassignment The name cannot point elsewhere val
Read-only view This reference cannot mutate it; another one might List<T>, Map<K, V>
Immutable object Nothing can mutate it after construction data class with val fields whose types are themselves immutable
Immutable graph The above, recursively, and copies at every boundary toList() on input, copy() for change, no var anywhere reachable
Persistent Immutable with structural sharing so “modification” is cheap kotlinx.collections.immutable: persistentListOf, PersistentMap
1
2
3
4
5
6
7
8
9
enum class Status { NEW, PAID }
data class Line(val sku: String, val qty: Int, val unitCents: Long)
data class Order(val id: String, val lines: List<Line>, val status: Status) {
    init { require(lines.none { it.qty <= 0 }) { "quantities must be positive" } }
}

fun Order.withLine(line: Line): Order = copy(lines = lines + line)     // new list, new order; the old one is untouched
fun Order.paid(): Order = copy(status = Status.PAID)
val Order.totalCents: Long get() = lines.sumOf { it.qty * it.unitCents }

What to say: the init block makes the invariant part of construction, so an Order with a bad line cannot exist. lines + line allocates a new list rather than appending. copy() is shallow, and that is safe here only because Line is itself immutable. And an immutable object is safely published to any thread the moment its constructor returns, which is the JVM post’s final-field rule doing real work.

Step 3: Make illegal states unrepresentable

Tool Use when Carries data Exhaustive when
enum class A fixed set of values with no per-value data No, beyond constants Yes
sealed interface or sealed class A fixed set of shapes, each with its own data Yes Yes
Open interface An unbounded set; other modules add implementations Yes No; you need else
1
2
3
4
5
6
7
8
9
10
11
12
sealed interface PaymentState
data object Pending : PaymentState
data class Authorized(val authCode: String) : PaymentState
data class Captured(val authCode: String, val capturedCents: Long) : PaymentState
data class Failed(val reason: String) : PaymentState

fun describe(state: PaymentState): String = when (state) {          // no else: adding a state breaks the build here, which is the point
    Pending -> "pending"
    is Authorized -> "authorized ${state.authCode}"
    is Captured -> "captured ${state.capturedCents} cents under ${state.authCode}"
    is Failed -> "failed: ${state.reason}"
}

What to say: a Captured state cannot exist without an authCode, and a Failed one cannot exist without a reason, because the constructors demand them. Compare that to a single class with nullable authCode, capturedCents, and reason fields, where the code has to check combinations and the compiler helps with none of it. The phrase is “make illegal states unrepresentable,” and saying it is worth a point on its own.

Step 4: Errors as values, with the right channel

Channel Says Use for Not for
T? Absent Lookups, optional fields, “no such key” Failures with a reason
Result<T> Succeeded, or failed with a Throwable Wrapping a technical boundary: I/O, parsing a third-party format, a call you do not own Domain logic; it carries no domain information
Sealed outcome type Succeeded with T, or failed with one of these specific errors Domain failures the caller must handle differently Technical noise the caller cannot act on
Exception Something is broken Bugs, unrecoverable states, and the edges where a framework expects them Control flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
sealed interface ParseError {
    data object Empty : ParseError
    data class BadNumber(val raw: String) : ParseError
    data class OutOfRange(val value: Int) : ParseError
}

sealed interface Parsed<out T> {
    data class Ok<T>(val value: T) : Parsed<T>
    data class Err(val error: ParseError) : Parsed<Nothing>
}

fun parseQuantity(raw: String): Parsed<Int> {
    if (raw.isBlank()) return Parsed.Err(ParseError.Empty)
    val n = raw.trim().toIntOrNull() ?: return Parsed.Err(ParseError.BadNumber(raw))
    return if (n in 1..1000) Parsed.Ok(n) else Parsed.Err(ParseError.OutOfRange(n))
}

inline fun <T, R> Parsed<T>.map(f: (T) -> R): Parsed<R> = when (this) {
    is Parsed.Ok -> Parsed.Ok(f(value))
    is Parsed.Err -> this
}

inline fun <T, R> Parsed<T>.flatMap(f: (T) -> Parsed<R>): Parsed<R> = when (this) {
    is Parsed.Ok -> f(value)
    is Parsed.Err -> this
}

// Fail fast on the first error, or collect every value: the two shapes a pipeline needs.
fun <T> List<Parsed<T>>.sequence(): Parsed<List<T>> {
    val out = ArrayList<T>(size)                  // local mutation, invisible outside: fine
    for (p in this) when (p) {
        is Parsed.Ok -> out.add(p.value)
        is Parsed.Err -> return p
    }
    return Parsed.Ok(out)
}

fun <T> List<Parsed<T>>.partitionErrors(): Pair<List<T>, List<ParseError>> {
    val oks = ArrayList<T>()
    val errs = ArrayList<ParseError>()
    for (p in this) when (p) {
        is Parsed.Ok -> oks.add(p.value)
        is Parsed.Err -> errs.add(p.error)
    }
    return oks to errs
}

And the technical boundary, where Result belongs:

1
2
3
4
5
6
7
fun readConfig(read: () -> String): Result<String> = runCatching(read)

fun loadPort(read: () -> String): Parsed<Int> =
    readConfig(read).fold(
        onSuccess = { parseQuantity(it) },
        onFailure = { Parsed.Err(ParseError.Empty) }   // translate the technical failure into a domain error at the boundary
    )

What to say: Parsed is a tiny Either. map and flatMap let the happy path read as a pipeline while errors short-circuit, which is what people mean by railway-oriented programming. sequence fails fast and partitionErrors accumulates; a form validator wants the second and a payment pipeline wants the first, and knowing which is the question. Result wraps the one place an exception can come from and is converted to a domain error immediately, so nothing downstream ever sees a Throwable. And the ArrayList inside sequence is mutation that nobody outside can observe, which is the pragmatic line: purity is about the interface, not the implementation.

Step 5: Laziness, with a cost model

  List (eager) Sequence (lazy)
Each operation Produces a full new list Produces a lazy wrapper; nothing runs until a terminal call
Evaluation order Whole collection through each step Each element through the whole pipeline
Early termination (first, take) Still processes everything before it Stops as soon as it can
Overhead per element Low; tight loops An iterator and a virtual call per step
Best for Small collections, short chains Large or infinite inputs, long chains, early exit
Re-evaluation No; the list exists Yes; every terminal call reruns the pipeline
1
2
3
4
5
6
7
8
9
10
11
12
// How many times does the expensive map run before we find the first big square?
fun firstBigSquareEager(nums: List<Int>, calls: IntArray): Int =
    nums.map { calls[0]++; it * it }.first { it > 100 }          // maps every element, then searches

fun firstBigSquareLazy(nums: List<Int>, calls: IntArray): Int =
    nums.asSequence().map { calls[0]++; it * it }.first { it > 100 }   // maps until the first hit, then stops

fun firstTenPrimes(): List<Int> =
    generateSequence(2) { it + 1 }                                  // infinite; only legal because take() bounds it
        .filter { n -> (2 until n).none { n % it == 0 } }
        .take(10)
        .toList()

On a list of a thousand numbers, the eager version runs the map a thousand times and the lazy one runs it eleven times. Say those two numbers; they are the whole argument. Then say the other half: for a list of ten, the eager version is faster, because the sequence machinery costs more than the ten wasted multiplications.

Step 6: Fold, reduce, and composition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun totalByStatus(orders: List<Order>): Map<Status, Long> =
    orders.fold(emptyMap()) { acc, o ->                           // fold: seed, and the accumulator changes type
        acc + (o.status to (acc[o.status] ?: 0L) + o.totalCents)
    }

fun runningTotals(orders: List<Order>): List<Long> =
    orders.runningFold(0L) { acc, o -> acc + o.totalCents }.drop(1)   // scan: every intermediate accumulator

fun longestSku(lines: List<Line>): String? =
    lines.map { it.sku }.reduceOrNull { a, b -> if (b.length > a.length) b else a }   // reduce: no seed, same type, empty-safe variant

infix fun <A, B, C> ((A) -> B).andThen(g: (B) -> C): (A) -> C = { a -> g(this(a)) }

val trimmed: (String) -> String = String::trim                 // the expected type picks the overload; a bare String::trim is ambiguous
val normalizeSku: (String) -> String = trimmed andThen String::uppercase

What to say: fold when the accumulator is a different type or the input may be empty; reduce only when neither is true, and reduceOrNull when you are not sure; runningFold when you need every step. andThen is function composition as an infix extension, which is the idiomatic Kotlin shape for it. normalizeSku allocates one function object once, at initialization, not per call. And the trimmed binding is there for a reason: trim has three overloads, so a bare String::trim passed straight into andThen is an overload-resolution error until an expected function type pins it down. Function references to overloaded members need that hint, and it is a small thing interviewers notice when it happens live.

Step 7: Recursion on a machine without tail calls

1
2
3
4
5
6
tailrec fun gcd(a: Long, b: Long): Long = if (b == 0L) a else gcd(b, a % b)   // rewritten to a loop by the compiler

tailrec fun sumTo(n: Long, acc: Long = 0L): Long = if (n == 0L) acc else sumTo(n - 1, acc + n)   // safe at a million; without tailrec it overflows

fun depthOf(node: Node?): Int = if (node == null) 0 else 1 + maxOf(depthOf(node.left), depthOf(node.right))  // NOT tail-recursive; fine for balanced trees, overflows on a degenerate one
class Node(val left: Node? = null, val right: Node? = null)

What to say: tailrec applies only to a direct self-call in tail position, and the compiler warns if you mark a function that does not qualify. depthOf cannot be made tail-recursive because it combines two recursive results, which is why the JVM post’s “skewed trees are linked lists” matters here. The options are an explicit stack, or DeepRecursiveFunction, which runs the recursion on the heap.

Step 8: Where it stops being pragmatic on the JVM

Situation Functional instinct Pragmatic answer
Building a string in a loop Fold over strings StringBuilder inside the function; the interface is still pure
An algorithm over an array Immutable copies per step IntArray mutated locally; return a fresh result
A hot path with a higher-order function (Int) -> Int parameter inline, or the JIT will box every call
A long collection chain on a large input Chained map/filter asSequence(), or one loop
Error handling in a CRUD service Either everywhere Sealed outcomes at the domain layer; exceptions where the framework expects them
Effects: time, randomness, I/O Pure core, effects at the edge Inject clock: () -> Instant and random: Random; let coroutines be the effect boundary
A monad transformer stack Full FP library Only if the whole team has opted in; otherwise the next reader pays

The sentence: purity is a property of the interface, not the implementation. A function that takes an immutable input, returns an immutable output, and touches nothing else is pure even if it mutates a local array on the way. That is the line, and it is where most of the performance comes back.

Follow-Up Questions to Expect

  • “Is Kotlin a functional language?” Multi-paradigm with first-class support for the functional subset that pays: immutable data, sealed types, higher-order and inline functions, lazy sequences. No higher-kinded types, no typeclasses; the Arrow library fills some of that for teams that want it.
  • Sequence versus Flow?” Both lazy and cold. Sequence is synchronous and pulls; Flow is suspending and can wait for asynchronous sources. Same pipeline vocabulary, different execution model.
  • “How do you test a pure function versus one with effects?” A pure function is a table of inputs and outputs, and property-based testing works on it directly. A function with effects gets its effects injected as functions so the test can substitute them.
  • “Fail fast or accumulate errors?” flatMap and sequence short-circuit on the first error. Validation wants every error, which is the partitionErrors shape, or a validated type with a combining operation. Ask which the caller needs.
  • “Why does immutability help with concurrency?” Because a constructed immutable object is safely published to every thread by the JVM’s final-field rule, with no locks. Point at the JVM post.
  • “When would you bring in Arrow?” When the team wants typed error handling with Raise or Either across the codebase and has agreed to the vocabulary. Not for one module, and not without the agreement.

Key Takeaways

  • Three constraints: immutable data, pure functions, total types. They buy local reasoning, free thread safety, and the compiler as reviewer.
  • val is not immutability. List is a view. copy() is shallow. Immutability is a property of the whole reachable graph.
  • Sealed types with exhaustive when and no else. Make illegal states unrepresentable.
  • Three error channels: nullable for absence, Result at technical boundaries, sealed outcomes for domain failure. Convert at the boundary.
  • fold over reduce when the type changes or the input can be empty. asSequence() for large inputs and early exit; eager for small ones.
  • tailrec for direct self-calls; an explicit stack or DeepRecursiveFunction otherwise.
  • Function types box; inline fixes it on hot paths.
  • Purity is about the interface. Local mutation inside a pure function is the pragmatic line.

Further Reading

This post is licensed under CC BY 4.0 by the author.