skip to content

What are the limits and constraints of `tailrec` in Kotlin? When can the compiler NOT apply the optimization?

level: seniorimportance: should knowfreq 38%

answer

  1. Direct self-recursion only — no mutual recursion
  2. No tail call inside try/catch/finally
  3. `open tailrec` is rejected by the compiler
  4. Non-tail = warning + fallback to plain recursion (not an error)
  5. Each call site judged independently

basics

~10 s

tailrec only works when a function calls itself directly as its very last step. It can't optimize mutual recursion, calls inside try/catch, open functions, or any call where work happens afterward.

solid answer

~50 s

Constraints: (1) **Direct self-recursion only** — the function must call itself; mutual recursion (A→B→A) is not supported. (2) The recursive call must be in **tail position** — the last operation, returned unchanged; any wrapping work (arithmetic, `?:`, logging) defeats it. (3) A call inside a **`try`/`catch`/`finally`** block is never a tail call, because the frame is needed for exception handling. (4) `tailrec` **cannot be `open`** — an overridable function can't be safely optimized since a subclass could change the target. (5) When the optimization can't apply, the compiler only **warns** and falls back to ordinary recursion — it does not fail the build, so the StackOverflowError risk silently remains. (6) It works on top-level, member, local, and extension functions, but each recursive call site must individually be in tail position; if some are and some aren't, only the tail ones are optimized and the rest warn.

go deeper

for a junior

Knows the call must be last but may miss the open/try-catch/mutual-recursion constraints.

for a middle

Lists tail position and the try/catch restriction; knows it warns rather than errors.

for a senior

Enumerates all constraints including no-open, no mutual recursion, per-call-site evaluation, and the silent-fallback risk.

for a principal

Reasons about why virtual dispatch forbids open, and proposes trampolining for cases tailrec cannot cover.

## Hard constraints ### 1. Direct self-recursion only `tailrec` optimizes a function that calls **itself**. **Mutual recursion** — `isEven` calls `isOdd` calls `isEven` — is **not** supported. There is no mutual-TCO in Kotlin; you must hand-convert mutual recursion to a loop or trampoline. ### 2. The call must be in tail position The recursive call has to be the **last operation**, its result returned as-is. Anything after it breaks the optimization: ```kotlin return n * rec(n - 1) // arithmetic after -> not tail return rec(x) ?: fallback() // Elvis runs after -> not tail rec(x); cleanup() // statement after -> not tail ``` ### 3. Not inside try/catch/finally A recursive call inside a **`try`/`catch`/`finally`** is never a tail call: the runtime must keep the current frame so it can catch a thrown exception or run `finally`. Even an empty `catch` blocks the optimization. ### 4. Cannot be `open` A `tailrec` function **cannot be marked `open`**. If it were overridable, a subclass override could be dispatched virtually, so the "self" call isn't statically known to target the same body. The compiler rejects `open tailrec`. ### 5. Warning, not error, on failure If you mark a function `tailrec` but no call (or not all calls) are in tail position, the compiler emits a **warning** — e.g. *"A function is marked as tail-recursive but no tail calls are found"* or *"Recursive call is not a tail call"* — and compiles it as **ordinary recursion**. The build succeeds; the stack-overflow risk stays. Treat the warning as a real defect. ### 6. Per-call-site If a function recurses in two places, each is evaluated independently. Tail ones are optimized into the loop; non-tail ones stay recursive and warn. ## What it DOES support - Top-level, member, **local** (nested), and **extension** functions. - A default-valued accumulator parameter (common pattern). - Conditional recursion via `if`/`when`, as long as each recursive branch is in tail position. ## Practical guidance ```kotlin // Mutual recursion: NOT optimizable with tailrec — rewrite as a loop fun isEven(n: Int): Boolean { var x = n while (x > 1) x -= 2 return x == 0 } ``` When `tailrec` can't apply and depth is unbounded, switch to an explicit loop, an accumulator, or an explicit stack/trampoline.

  • Why can't `tailrec` be `open`?
    An overridable function could be dispatched to a subclass override, so the self-call target isn't statically fixed and can't be safely turned into a loop.
  • How would you handle deep mutual recursion that tailrec can't optimize?
    Convert it to an explicit loop, or use a trampoline (return thunks from a loop) to bounce calls without growing the stack.

saying these in an interview costs you the question

  • Claiming `tailrec` supports mutual recursion
  • Saying calls inside try/catch can still be tail calls
  • Not knowing `open tailrec` is disallowed
  • Thinking a non-tail `tailrec` is a compile error rather than a warning

context