Skip to content

Arrow Functional Programming in Kotlin — Complete Guide

DodaTech Updated 2026-06-28 10 min read

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 Option for nullable values and Either for error handling
  • Validating multiple inputs with Validated and accumulating errors
  • Handling side effects with IO and coroutine-based Resource
  • Composing data transformations with map, flatMap, and traverse
  • 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

  1. 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 Either to represent operations that can fail in expected ways.

  2. 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.

  3. Short-circuiting when you should accumulate: Use Validated when you want to report all errors (form validation, data import). Use Either when subsequent operations depend on previous results (pipelines, chained transformations).

  4. Calling unsafeRunSync inside business logic: unsafeRunSync should be called only at the entry point of the application (main function, controller). Inside business logic, keep effects as IO values and compose them using IO.fx.

  5. Not using Arrow's KSP plugin for optics: Writing lenses manually for nested data classes is tedious and error-prone. The arrow-optics-ksp-plugin generates lenses at compile time, reducing boilerplate and ensuring correctness.

Practice Questions

  1. What is the difference between Option and Kotlin's nullable type T??
  2. Why does Validated use NonEmptyList for errors instead of just List?
  3. How does IO help with testing code that has side effects?
  4. What is the relationship between lenses and immutable data classes?
  5. Challenge: Implement a PaymentProcessor that validates a payment amount (positive), currency code (3-letter ISO code), and card number (Luhn algorithm). Use Validated to accumulate all validation errors. Use Either to 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 Validated to parse each configuration value and accumulate errors
  • Use Either to represent the overall Parsing result
  • Use IO for reading the configuration file
  • Use Resource to manage the file handle
  • Write tests for all parsing functions

FAQ

Is Arrow compatible with Kotlin coroutines?

Yes. Arrow Fx provides IO, Resource, and Ref that integrate directly with Kotlin coroutines. IO is a type alias for suspend () -> A, so it works with any coroutine dispatcher.

Do I need to know Haskell or Scala to use Arrow?

No. Arrow is designed for Kotlin developers with concepts explained in familiar terms. However, understanding basic functional programming concepts like immutability, pure functions, and higher-order functions is helpful.

How does Arrow compare to Kotlin's standard library?

Kotlin's standard library provides basic functional constructs like lambda expressions and immutable collections. Arrow adds type-safe error handling, effect types, optics, and validation patterns that go beyond the standard library.

Does Arrow add runtime overhead?

Arrow's core types are simple data classes and inline functions. The KSP plugin for optics generates code at compile time. The runtime overhead is minimal compared to the correctness benefits.

Can I use Arrow with Spring Boot?

Yes. Arrow integrates with any Kotlin framework. Use Either in service methods, Validated in controllers for request validation, and IO with coroutine support in reactive Spring WebFlux endpoints.

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