Arrow Functional Programming in Kotlin — Complete Guide
In this tutorial, you will learn about Arrow Functional Programming in Kotlin. We cover key concepts, practical examples, and best practices to help you master this topic.
Arrow is a functional programming library for Kotlin that provides immutable data types, type-safe error handling with Either and Validated, effect handling with IO, and optics for immutable data manipulation.
What You Will Learn
- The core functional programming concepts implemented by Arrow
- Using
Optionfor nullable values andEitherfor error handling - Validating multiple inputs with
Validatedand accumulating errors - Handling side effects with
IOand coroutine-basedResource - Composing data transformations with
map,flatMap, andtraverse - Using lenses and optics for immutable data updates
- Building type-safe domain models with functional patterns
Why It Matters
Kotlin supports functional programming constructs like lambda expressions, higher-order functions, and immutable collections, but the standard library does not provide the data types needed for pure functional programming. Arrow fills this gap by porting concepts from Haskell and Scala to Kotlin. When you use Arrow, you model failures as types instead of exceptions, compose effects without shared mutable state, and write code that is easier to reason about and test. This is especially valuable in domains where correctness is critical, such as financial systems, Data Pipelines, and security applications.
Real-World Use
The DodaTech billing system uses Arrow's Validated type to validate invoice data. When a user submits an invoice, multiple fields (amount, tax ID, currency code) are validated independently, and all errors are accumulated into a single result. Either is used throughout the domain layer to represent operations that can succeed with a value or fail with a typed error, eliminating the possibility of unhandled exceptions.
Learning Path
flowchart LR A[Spring Boot + Testing] --> B[Arrow FP\nYou are here] B --> C[Kotlin Ecosystem] style B fill:#f90,color:#fff
Setting Up Arrow
Add the Arrow dependencies to your Gradle build:
// build.gradle.kts
dependencies {
implementation("io.arrow-kt:arrow-core:1.2.4")
implementation("io.arrow-kt:arrow-fx-coroutines:1.2.4")
implementation("io.arrow-kt:arrow-optics:1.2.4")
ksp("io.arrow-kt:arrow-optics-ksp-plugin:1.2.4")
}
Arrow Core provides the core data types (Option, Either, Validated). Arrow Fx provides effect types (IO, Resource). Arrow Optics provides lenses and the KSP annotation processor for generating optics boilerplate.
Option: Handling Nullable Values
Option is a type-safe alternative to nullable references. It can be Some(value) or None:
import arrow.core.Option
import arrow.core.Some
import arrow.core.None
import arrow.core.none
fun findUser(id: Long): Option<String> {
return if (id == 1L) Some("Alice")
else none()
}
fun main() {
val user1 = findUser(1L)
val user2 = findUser(2L)
println(user1.getOrElse { "Guest" })
println(user2.getOrElse { "Guest" })
user1.map { it.uppercase() }
.onSome { println("Found: $it") }
.onNone { println("User not found") }
}
Output:
Alice
Guest
Found: ALICE
Option forces you to handle both cases. Unlike nullable types, you cannot access the value inside an Option without specifying what to do when it is empty. map, flatMap, and fold provide safe ways to transform and extract values.
Either: Type-Safe Error Handling
Either represents a value that can be one of two types: Left (typically an error) or Right (a success). By convention, Right holds the success value:
import arrow.core.Either
import arrow.core.right
import arrow.core.left
sealed class ValidationError {
data class InvalidEmail(val value: String) : ValidationError()
data class PasswordTooShort(val minLength: Int) : ValidationError()
object UserNotFound : ValidationError()
}
fun validateEmail(email: String): Either<ValidationError, String> {
return if (email.contains("@")) email.right()
else ValidationError.InvalidEmail(email).left()
}
fun validatePassword(password: String): Either<ValidationError, String> {
return if (password.length >= 8) password.right()
else ValidationError.PasswordTooShort(8).left()
}
fun registerUser(email: String, password: String): Either<ValidationError, String> {
return validateEmail(email).flatMap { validEmail ->
validatePassword(password).map { validPassword ->
"User registered: $validEmail"
}
}
}
fun main() {
println(registerUser("alice@test.com", "secret123"))
println(registerUser("invalid", "short"))
}
Output:
Right(User registered: alice@test.com)
Left(InvalidEmail(email=invalid))
flatMap chains operations that depend on the previous result. The first error short-circuits the chain, so validatePassword is never called when the email is invalid. This mirrors exception propagation but is fully type-safe.
Validated: Accumulating Multiple Errors
Validated differs from Either in that it accumulates errors using a NonEmptyList instead of short-circuiting. This is ideal for form validation where you want to show all errors at once:
import arrow.core.Validated
import arrow.core.invalid
import arrow.core.valid
import arrow.core.NonEmptyList
import arrow.core.validatedNel
typealias ValidationResult<T> = Validated<NonEmptyList<ValidationError>, T>
fun validateEmailV(email: String): ValidationResult<String> {
return if (email.contains("@")) email.valid()
else ValidationError.InvalidEmail(email).invalidNel()
}
fun validatePasswordV(password: String): ValidationResult<String> {
return if (password.length >= 8) password.valid()
else ValidationError.PasswordTooShort(8).invalidNel()
}
fun validateForm(email: String, password: String): ValidationResult<String> {
return validatedNel(
validateEmailV(email),
validatePasswordV(password)
) { validEmail, validPassword ->
"Registered: $validEmail with password $validPassword"
}
}
fun main() {
println(validateForm("bad", "short"))
println(validateForm("alice@test.com", "secure123"))
}
Output:
Invalid(e=NonEmptyList(InvalidEmail(email=bad), PasswordTooShort(minLength=8)))
Valid(Registered: alice@test.com with password secure123)
validatedNel combines two Validated values, applying all errors. The NonEmptyList guarantees that when validation fails, there is at least one error.
IO: Effect Handling
IO (from Arrow Fx) represents a lazy computation that may produce side effects. It is a description of a computation, not the execution itself:
import arrow.fx.coroutines.IO
import arrow.fx.coroutines.parMapN
import java.net.URL
fun fetchUrl(url: String): IO<String> = IO {
URL(url).readText()
}
fun main() = IO.fx {
val (result1, result2) = parMapN(
fetchUrl("https://api.example.com/data1"),
fetchUrl("https://api.example.com/data2")
) { a, b -> a to b }
println("Data 1 length: ${result1.length}")
println("Data 2 length: ${result2.length}")
}.repeat().unsafeRunSync()
IO.fx is a coroutine-based DSL that lets you describe effectful computations. parMapN executes effects in parallel. The computation does not execute anything until unsafeRunSync() is called. This separation of description and execution makes testing easier because you can mock effects by providing alternative IO values.
Resource: Safe Resource Management
Resource ensures that resources are acquired and released safely, even when errors occur:
import arrow.fx.coroutines.Resource
import arrow.fx.coroutines.resource
class DatabaseConnection(private val url: String) {
fun query(sql: String): List<String> {
println("Executing: $sql")
return listOf("result1", "result2")
}
fun close() = println("Closing connection to $url")
}
fun databaseResource(url: String): Resource<DatabaseConnection> = resource(
acquire = { DatabaseConnection(url) },
release = { conn -> conn.close() }
)
suspend fun runQuery() {
databaseResource("jdbc:postgresql://localhost/mydb").use { conn ->
val results = conn.query("SELECT * FROM users")
println(results)
}
}
Resource.use automatically calls close when the block completes, even if an exception is thrown. This is equivalent to Java's try-with-resources but expressed as a composable functional value.
Optics: Immutable Data Updates
Optics provide a way to read and update deeply nested immutable data structures without manual copying. The @optics annotation generates lenses for data class fields:
import arrow.optics.Lens
import arrow.optics.optics
@optics
data class Address(val street: String, val city: String, val zipCode: String) {
companion object
}
@optics
data class Employee(val name: String, val address: Address) {
companion object
}
@optics
data class Company(val name: String, val employees: List<Employee>) {
companion object
}
fun main() {
val company = Company(
name = "DodaTech",
employees = listOf(
Employee("Alice", Address("123 Main St", "Springfield", "12345")),
Employee("Bob", Address("456 Oak Ave", "Riverside", "67890"))
)
)
val updatedCompany = Company.employees.firstOrNull()
.address.street.set(company, "789 Pine Rd")
println(updatedCompany.employees[0].address.street)
println(company.employees[0].address.street)
}
Output:
789 Pine Rd
123 Main St
The original company is unchanged. The lens Company.employees.firstOrNull().address.street focuses on the street field of the first employee and produces a new company with that field updated. Lenses compose, so you can create complex update paths from simple ones.
Composing with Traverse
traverse applies an effectful function to each element in a collection and collects the results:
import arrow.core.traverse
import arrow.core.validatedNel
import arrow.core.Validated
import arrow.core.NonEmptyList
fun parsePositiveInt(s: String): Validated<NonEmptyList<String>, Int> {
return try {
val n = s.toInt()
if (n > 0) n.validatedNel()
else "Not positive: $s".invalidNel()
} catch (e: NumberFormatException) {
"Not a number: $s".invalidNel()
}
}
fun main() {
val inputs = listOf("1", "2", "-3", "abc", "5")
val result = inputs.traverse { parsePositiveInt(it) }
println(result)
}
Output:
Invalid(e=NonEmptyList(Not positive: -3, Not a number: abc))
traverse applies parsePositiveInt to each input and combines the results. Valid inputs (1, 2, 5) are collected, and invalid inputs accumulate errors.
Pattern Matching with When
Functional error handling integrates naturally with Kotlin's when expression:
fun processRegistration(email: String, password: String): String {
return when (val result = registerUser(email, password)) {
is Either.Right -> "Welcome, user registered!"
is Either.Left -> when (val error = result.value) {
is ValidationError.InvalidEmail -> "Invalid email: ${error.value}"
is ValidationError.PasswordTooShort -> "Password too short, minimum ${error.minLength} characters"
is ValidationError.UserNotFound -> "User not found"
}
}
}
fun main() {
println(processRegistration("alice@test.com", "secret123"))
println(processRegistration("bad", "hi"))
}
Output:
Welcome, user registered!
Invalid email: bad
The compiler checks that all branches of the sealed class are covered. Adding a new ValidationError variant causes a compilation error until all when expressions handle it. This is the functional programming guarantee of exhaustiveness.
Testing Functional Code
Code that uses Arrow data types is easier to test because dependencies are explicit and effects are described as values:
import arrow.core.Either
import arrow.core.right
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class RegistrationTest : StringSpec({
"valid email and password return success" {
val result = registerUser("test@test.com", "password123")
result shouldBe Either.Right("User registered: test@test.com")
}
"invalid email returns error" {
val result = registerUser("invalid", "password123")
result shouldBe Either.Left(ValidationError.InvalidEmail("invalid"))
}
"short password returns error" {
val result = registerUser("test@test.com", "short")
result shouldBe Either.Left(ValidationError.PasswordTooShort(8))
}
})
No mocking is needed because Either values are just data. Tests simply assert on the returned Either, Option, or Validated values.
Common Mistakes
Using exceptions for control flow instead of Either: Throwing exceptions for expected failure cases breaks referential transparency and makes code harder to reason about. Use
Eitherto represent operations that can fail in expected ways.Ignoring the Left type in Either: Declaring
Either<Throwable, T>is too broad. Define a sealed class of domain-specific errors so that callers know exactly what failures to expect and handle.Short-circuiting when you should accumulate: Use
Validatedwhen you want to report all errors (form validation, data import). UseEitherwhen subsequent operations depend on previous results (pipelines, chained transformations).Calling
unsafeRunSyncinside business logic:unsafeRunSyncshould be called only at the entry point of the application (main function, controller). Inside business logic, keep effects asIOvalues and compose them usingIO.fx.Not using Arrow's KSP plugin for optics: Writing lenses manually for nested data classes is tedious and error-prone. The
arrow-optics-ksp-plugingenerates lenses at compile time, reducing boilerplate and ensuring correctness.
Practice Questions
- What is the difference between
Optionand Kotlin's nullable typeT?? - Why does
ValidateduseNonEmptyListfor errors instead of justList? - How does
IOhelp with testing code that has side effects? - What is the relationship between lenses and immutable data classes?
- Challenge: Implement a
PaymentProcessorthat validates a payment amount (positive), currency code (3-letter ISO code), and card number (Luhn algorithm). UseValidatedto accumulate all validation errors. UseEitherto represent the payment processing result.
Mini Project
Build a configuration file parser with Arrow:
- Define a sealed class for configuration errors (missing key, invalid format, type mismatch)
- Use
Validatedto parse each configuration value and accumulate errors - Use
Eitherto represent the overall Parsing result - Use
IOfor reading the configuration file - Use
Resourceto manage the file handle - Write tests for all parsing functions
FAQ
What is Next
Now that you understand functional programming with Arrow, explore the broader Kotlin Ecosystem to see how these patterns apply in real projects. Learn Testing with Kotest for property-based testing of functional code, and study Coroutines and Flow to understand the concurrency model that Arrow Fx builds upon.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro