Functional Programming in Kotlin

When I think about functional programming, three things come to mind: immutability, monads, and side effects. Immutability has earned its reputation - it genuinely simplifies reasoning about code and makes concurrency safer. Monads? They're powerful but intimidating (if you're not sure what they are, here's a great explainer). And side effects? Every useful program has them. It reads files, calls APIs, and writes to databases. The question isn't how to eliminate them, but how to keep them under control.
Kotlin promises multi-paradigm support, and FP is part of that promise. But how much actually holds up? Can you write idiomatic functional Kotlin without external libraries?
Who is this blog post for?
New to functional programming? Start with our introduction to FP concepts. This post builds on that foundation. If you already know what immutability and higher-order functions are, keep reading. I won't be explaining the map and filter. If you're wondering how functional Kotlin really is, or you're just tired of the "it depends" answer, you're in the right place. If you're already deep into effect systems, you probably know most of this, but the casual vs. pure FP framing might still be useful.
What makes a language "functional"?
Before we judge Kotlin, we need to define what we're measuring against. Functional programming isn't a binary checkbox. It's more like a spectrum, with "I use map sometimes" on one end and "everything is an IO monad" on the other.
A useful distinction comes from the blog mentioned earlier: there's casual FP, and there's pure FP. Casual FP means you treat functions as first-class citizens (pass them as arguments, return them from other functions), embrace immutability, use higher-order functions, model your domain with algebraic data types, and generally prefer expressions over statements. Pure FP goes further: side effects are tracked in the type system, referential transparency is enforced, and your entire program is essentially a composition of pure functions.
An interesting insight? Most of the practical benefits come from crossing into casual FP territory. Going from imperative to casual FP is a bigger leap than going from casual to pure. You get cleaner code, fewer bugs, and better testability. The jump to pure FP gives you mathematical guarantees, but the ROI is debatable for most business applications.
Casual FP: Where Kotlin delivers
Let's start with what Kotlin offers for casual functional programming. This covers the fundamentals that most teams need: immutability, pure functions, higher-order functions, and algebraic data types.
Immutability
Kotlin distinguishes between mutable and immutable collections and encourages immutability through language design.
val users = listOf(
User("Alice", 28),
User("Bob", 35)
)listOf() creates an immutable list. You can't add, remove, or modify elements after creation.
val updatedUsers = users + User("Charlie", 42)Adding an element returns a new list, leaving the original unchanged.
data class User(val name: String, val age: Int)data class with val properties is immutable by default. Combined with the copy() method, this makes immutable domain modeling natural.
While Kotlin doesn't enforce immutability (you can still use var and mutableListOf()), it provides the tools and nudges you in the right direction. Note that val only makes the reference immutable, not the data structure itself. The standard library defaults to immutable collections.
Pure functions
Every language lets you write pure functions, and Kotlin does too.
fun processOrder(order: Order): Order {
return order.copy(
total = calculateDiscount(order.total, order.discountPercent)
)
}The copy() method on data classes makes the "return modified copy instead of mutating" pattern natural. Unlike Haskell, Kotlin doesn't enforce purity at compile time. That means the compiler won't stop you from adding side effects to a function.
First-class and higher-order functions
Kotlin treats functions as first-class citizens. You can pass them as parameters, return them from other functions, and store them in variables.
val square: (Int) -> Int = { it * it }fun applyTwice(f: (Int) -> Int, x: Int): Int {
return f(f(x))
}
val result = applyTwice(square, 3) // 81A higher-order function accepting another function as a parameter. This pattern enables powerful abstractions like map, filter, and custom control flow. This is fundamental to functional programming, and Kotlin handles it elegantly with lambda syntax and function types.
Standard library FP operations
Kotlin's standard library includes rich support for functional operations on collections:
data class Product(val name: String, val price: Double, val inStock: Boolean)
val products = listOf(
Product("Laptop", 999.0, true),
Product("Mouse", 25.0, false),
Product("Keyboard", 75.0, true)
)
val affordableInStock = products
.filter { it.inStock } // (1)
.filter { it.price < 100 } // (2)
.map { it.name.uppercase() } // (3)
.sorted() // (4)
// Result: ["KEYBOARD"]- Keep only in-stock items
- Filter by price
- Transform to uppercase names
- Sort alphabetically
The collection API covers map, filter, flatMap, fold, reduce, groupBy, partition, and many more. For day-to-day functional transformations, you won't feel limited.
Algebraic Data Types (ADTs)
Kotlin provides excellent support for ADTs through sealed classes/interfaces and data classes.
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data object NetworkError : PaymentResult
}A sum type (sealed interface) where the result can be one of several variants. Use data object for variants without data.
fun handlePayment(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Payment successful: ${result.transactionId}"
is PaymentResult.Declined -> "Payment declined: ${result.reason}"
is PaymentResult.NetworkError -> "Network error, please retry"
}Exhaustive when expression - the compiler ensures all cases are handled. If you add a new variant, every when expression that doesn't handle it will fail to compile.
This is nearly identical to sum types in languages like Scala or Rust. The compiler's exhaustiveness checking is particularly valuable: if you add a new variant, the compiler will flag every when expression that needs updating.
Pattern Matching
Kotlin's when expression provides exhaustive pattern matching for sealed types:
sealed interface ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>
data class Error(val code: Int, val message: String) : ApiResponse<Nothing>
data object Loading : ApiResponse<Nothing>
}
fun <T> renderResponse(response: ApiResponse<T>): String = when (response) {
is ApiResponse.Success -> "Data: ${response.data}"
is ApiResponse.Error -> "Error ${response.code}: ${response.message}"
ApiResponse.Loading -> "Loading..."
} // Compiler checks exhaustivenessThis is powerful for most use cases: you get type safety and exhaustiveness checking, which catches missing cases at compile time.
Tail Call Optimization
Kotlin supports tail call optimization, but only with explicit tailrec annotation and strict constraints.
tailrec fun factorial(n: Long, acc: Long = 1): Long =
if (n <= 1) acc
else factorial(n - 1, n * acc)The tailrec annotation is required. The recursive call must be in the tail position, meaning it's the last operation in the function.
factorial(10000) // Works without stack overflowWith tailrec, the compiler transforms recursion into a loop, avoiding stack overflow for deep recursion.
Limitations:
- Only works for direct recursion (not mutual recursion)
- Function must be
private,final, or a local function - Recursive call must be the last operation
Compare this to Scala, where TCO is automatic for tail-position calls. The explicit annotation is actually a feature in disguise: if you accidentally write non-tail recursion, the compiler tells you immediately instead of failing at runtime.
Type-Safe Nullability
Kotlin uses nullable types (T?) instead of the Option/Maybe types common in FP languages.
fun findUser(id: String): User? = userRepository.find(id)Nullable return type - the ? suffix makes absence explicit in the type system.
val username = findUser("123")?.name?.uppercase()Safe call operator (?.) chains - if any step returns null, the entire chain short-circuits to null.
val username = findUser("123")?.name?.uppercase() ?: "Unknown"Elvis operator (?:) provides default values when the left side is null.
While this works and is type-safe, it's not the same as Option<T>. With Option, you get methods like map, flatMap, fold, which make it a proper monad:
// Kotlin nullable — imperative style
val user: User? = findUser("123")
val department = user?.department?.name ?: "Unknown"
// Option style (not in Kotlin stdlib)
val user: Option<User> = findUser("123")
val department = user
.flatMap { it.department }
.map { it.name }
.getOrElse { "Unknown" }For most practical purposes, Kotlin's nullable types are enough and arguably more readable for simple cases. But if you want to chain multiple fallible operations functionally, nullable types become awkward. You'll find yourself writing nested ?.let { } blocks.
Pure FP: What's missing?
Let's be explicit about what Kotlin doesn't provide for pure functional programming, you should know these gaps exist before committing to a purely functional approach.
Limited pattern matching
While when expressions handle basic pattern matching well, Kotlin lacks guards and destructuring in match clauses:
// Not possible in Kotlin
when (result) {
is Success(data) if data.isEmpty() -> ... // Guards not supported
is Error(404, _) -> ... // Can't destructure in when
}Workaround: Use nested conditions inside branches:
when (result) {
is ApiResponse.Success -> {
if (result.data.isEmpty()) "Empty data"
else "Data: ${result.data}"
}
is ApiResponse.Error -> {
if (result.code == 404) "Not found"
else "Error: ${result.message}"
}
ApiResponse.Loading -> "Loading..."
}It's more verbose, but it works. The lack of guards is annoying rather than blocking - you can always express the same logic, just with more nesting.
No built-in function composition
Kotlin doesn't provide built-in operators for function composition. You have to compose manually:
val addOne: (Int) -> Int = { it + 1 }
val double: (Int) -> Int = { it * 2 }
// Manual composition (works but reads right-to-left)
val addThenDouble = { x: Int -> double(addOne(x)) }
// Better: write your own extension
infix fun <A, B, C> ((A) -> B).andThen(g: (B) -> C): (A) -> C = { g(this(it)) }
val composed = addOne andThen double // (1)
composed(5) // 12Using custom extension function for composition
Languages like Scala provide compose and andThen out of the box. In Kotlin, you either write these extensions yourself (5 lines of code) or use a library. The workaround is trivial, but it's surprising this isn't in the standard library.
No Native Either/Result Type for Errors
Kotlin has a Result<T> type, but it's designed specifically for wrapping exceptions and can't model arbitrary error types:
fun divide(a: Int, b: Int): Result<Int> =
if (b == 0) Result.failure(ArithmeticException("Division by zero"))
else Result.success(a / b)
divide(10, 0).getOrElse { -1 }The problem: You can't model domain errors elegantly:
sealed interface ValidationError {
data object EmptyEmail : ValidationError
data object InvalidFormat : ValidationError
data class TooLong(val maxLength: Int) : ValidationError
}
// What we want:
fun validateEmail(email: String): Either<ValidationError, Email>
// What Kotlin gives us:
fun validateEmail(email: String): Result<Email> // Can only hold ExceptionWithout Either<E, A>, you're forced to either:
- Throw exceptions (not functional)
- Return nullable types (loses error information)
- Create your own Either type (boilerplate)
- Use a library like Arrow
No effectystem
Kotlin lacks a built-in effect system. In functional languages, effect types like IO<A>, Option<A>, or Either<E, A> wrap computations and provide properties through their type - Option represents presence/absence, IO represents an operation that can have side effects, Either represents failure or success.
// Kotlin: no effect system
fun loadUser(id: String): User = userRepository.find(id)The type (String) -> User tells you nothing about what this function does. Does it perform I/O? Can it fail? Does it have side effects?
In languages with effect systems, the type is explicit:
-- Haskell: IO effect
loadUser :: String -> IO User// Scala with Cats Effect
def loadUser(id: String): IO[User]IO represents an operation that can have side effects. The function returns a description of work to be done, not the result itself. Kotlin suspend modifier is the closest thing to an effect marker, but it only indicates suspension points for coroutines, not general effects:
No higher-kinded types
Kotlin lacks support for higher-kinded types (HKTs), which let you abstract over type constructors like List, Option, or Either. Without HKTs, you can't write generic code that works with any F[_] type constructor, limiting your ability to build generic functional abstractions.
In Scala, you can write functions that work with any container type:
// Generic function that works with any F[_]
def transform[F[_]: Functor, A, B](fa: F[A], f: A => B): F[B] =
fa.map(f)
// Works with List, Option, Either, etc.
transform(List(1, 2, 3), _ * 2) // List(2, 4, 6)
transform(Some(5), _ * 2) // Some(10)
transform(Right(5), _ * 2) // Right(10)
The F[ _ ] syntax means "F is a type constructor that takes one type parameter." This lets you write code once that works with any container-like type.
In Kotlin, you can't express this abstraction. You'd need separate implementations for List, nullable types, Result, and so on.
. There are currently no plans to implement HKTs in Kotlin.
No type classes
Kotlin interfaces aren't type classes (also known as ad-hoc polymorphism). You can't retroactively add implementations or define laws that instances must satisfy:
// Kotlin interface: must be declared when defining the class
interface Showable {
fun show(): String
}
// Can't make String showable without wrapping it
data class DisplayString(val value: String) : Showable {
override fun show() = value
}In Scala or Haskell, you can define a type class instance for any existing type without modifying it:
trait Showable[A]:
extension (a: A) def show: String
val stringShowable = new Showable[String]:
extension (value: String) def show: String = valueKotlin's extension functions provide some of the same benefits, they're resolved statically (at compile time, based on the declared type).
Conclusion
Kotlin is a solid casual FP language. It gives you the tools for immutability, higher-order functions, algebraic data types, and exhaustive pattern matching. You can write clean, functional code without external dependencies, data transformation pipelines, immutable domain models, and pure business logic. For most teams and most projects, this is enough.
Where Kotlin falls short is pure FP territory. No typed error handling beyond exceptions, no effect tracking, no type classes, no compiler-enforced referential transparency. If you want these guarantees, you need Arrow.
My advice: don't fight the language. Use nullable types when they're simpler, throw exceptions when the alternative is worse, and embrace sealed classes for domain modeling. For basics like function composition, write your own 5-line extensions. But when you find yourself building your own Either type, it's time to reach for Arrow.
In Part 2, we'll explore how Arrow fills these gaps and when it's worth adding that dependency to your project.
Review by: Rafał Wokacz, Michał Pierściński
