Kotlin is a modern language that runs on the JVM. Google made it the official language for Android in 2019, but Kotlin is not only for Android — it runs backends with Ktor and Spring Boot, desktop apps with Compose, and scripts, all with the same syntax.
This guide takes you from your first println to a working project: TaskFlow, a task manager with a domain model, JSON persistence, a colored CLI, and a tested REST API built with Ktor. Every section adds a concept. The final part connects them into one project — nothing here is a disconnected snippet.
What you will have by the end: a solid grip on null safety, coroutines, and Flow, comfort with Kotlin’s functional style (lambdas, scope functions, sequences), and a real full-stack Kotlin project — a CLI tool and a REST API sharing the same domain code, tested with JUnit 5 and MockK.
Two things before you start:
- No prior Kotlin experience needed. Some programming background helps, especially with basic types and functions. If you already know Java, most of Part 1 will feel familiar — skim it for the differences.
- Full source code for the capstone project is on GitHub: github.com/kemalcodes/kotlin-tutorial.
Need a quick syntax lookup instead of a full tutorial? See the Kotlin Cheat Sheet.
Part 1: Foundations
Why Kotlin
Kotlin was created by JetBrains, the company behind IntelliJ IDEA. It reached version 1.0 in 2016 and compiles to JVM bytecode, so it runs anywhere Java runs and can call Java code directly — you can mix both languages in one project.
The biggest reason developers switch is less code with fewer crashes. Compare a simple data holder:
// Java — 30+ lines for a simple data holder
public class User {
private String name;
private int age;
public User(String name, int age) { this.name = name; this.age = age; }
public String getName() { return name; }
public int getAge() { return age; }
@Override public boolean equals(Object o) { /* ... */ }
@Override public int hashCode() { /* ... */ }
@Override public String toString() { /* ... */ }
}
// Kotlin — 1 line does the same thing
data class User(val name: String, val age: Int)
That one line generates equals(), hashCode(), toString(), and copy() for you. Kotlin’s other headline feature is null safety: in Java, String name = null; name.length(); compiles and crashes at runtime. In Kotlin, val name: String = null is a compile error — you have to opt into nullability with String?, and the compiler then forces you to handle the null case before you can use the value. That single design decision eliminates most NullPointerExceptions before the code ever runs.
| Java | Kotlin | |
|---|---|---|
| Null safety | Runtime crashes | Compile-time checks |
| Data classes | Manual, 30+ lines | One line |
| Type inference | Limited | Full |
| Extension functions | No | Yes |
| Default parameters | No (needs overloading) | Yes |
| Coroutines | No (needs threads) | Built-in |
Kotlin’s reach outside Android is what makes it worth learning even if mobile isn’t your focus: Ktor and Spring Boot both run production backends in it, Compose Multiplatform shares UI code across Android, iOS, and desktop from one codebase, and because it’s 100% interoperable with Java, teams can adopt it file-by-file inside an existing Java codebase rather than needing a full rewrite to start using it.
Setup
The fastest way to try Kotlin is the Kotlin Playground — no install, just type and run. For a real project, install IntelliJ IDEA Community Edition (free) from jetbrains.com/idea/download, then New Project → Kotlin → Gradle. If you prefer the command line:
# macOS
brew install kotlin
# Linux (SDKMAN)
curl -s "https://get.sdkman.io" | bash && sdk install kotlin
# verify
kotlin -version
Every Kotlin program starts with a main function:
fun main() {
println("Hello, World!")
}
No class wrapper, no public static void. println() prints with a newline; print() stays on the same line. Gradle projects use ./gradlew build to compile, ./gradlew run to execute, and ./gradlew test to run tests — the same three commands you will use throughout this guide.
Variables and Types
Kotlin has two ways to declare a variable: val (cannot be reassigned, like Java’s final) and var (can be reassigned). Default to val. Only reach for var when the value truly needs to change — this single habit removes a large class of bugs.
val name = "Alex" // cannot reassign
var age = 25 // can reassign
age = 26 // fine
val freezes the reference, not necessarily the object. A val list can still have items added to it:
val numbers = mutableListOf(1, 2, 3)
numbers.add(4) // fine — the list itself changed
println(numbers) // [1, 2, 3, 4]
Kotlin infers types from the value you assign, so you rarely write them out:
val name = "Sam" // String
val age = 30 // Int
val height = 1.82 // Double
val active = true // Boolean
The basic types are Byte, Short, Int, Long (integers, Int is the default), Float and Double (decimals, Double is the default), Boolean, and Char (single quotes, not to be confused with String). Numbers accept underscores for readability (1_000_000), and Kotlin never converts types implicitly — you must call toDouble(), toInt(), and so on. Converting Double to Int truncates rather than rounds (3.99.toInt() is 3, not 4 — use kotlin.math.roundToInt() if you want rounding).
String templates put variables directly inside strings with $name or ${expression}:
val first = "Jordan"
val age = 30
println("$first is ${age * 365} days old")
Triple-quoted strings ("""...""") don’t need escaping and support multi-line text; trimIndent() removes the shared leading whitespace. For values known at compile time, use const val at the top level or in a companion object; for values computed once at runtime, val is enough.
Converting a String to a number can fail on bad input, so Kotlin gives you a safe variant that returns null instead of crashing rather than an exception you have to catch:
val ok = "123".toIntOrNull() // 123
val bad = "abc".toIntOrNull() // null — no exception thrown
val length = "abc".toIntOrNull() ?: 0 // combine with Elvis for a default
Reach for toIntOrNull()/toDoubleOrNull() whenever the string comes from outside your program — user input, a file, a network response — and reserve the throwing toInt() for values you already know are valid.
Null Safety
This is the feature every Kotlin explainer leads with, and for good reason. Every type is non-nullable by default. To allow null, add ? to the type — String and String? are different types, and the compiler tracks the difference through your whole program.
val name: String = null // compile error
val name: String? = null // fine — explicitly nullable
Five operators cover almost every situation:
val name: String? = null
name?.length // safe call — null instead of a crash
name?.length ?: 0 // Elvis — default value if null
name!!.length // not-null assertion — crashes if null; avoid in production
name?.let { println(it) } // runs the block only if not null
(value as? String)?.length // safe cast — null if the cast fails, instead of crashing
After an explicit null check, Kotlin smart-casts the variable to non-null for the rest of that scope:
if (name != null) {
println(name.length) // no ?. needed here — Kotlin knows it's not null
}
For a property you cannot initialize immediately but promise to set before first use, use lateinit var (non-primitive types only, and only with var). Reading a lateinit property before it’s set throws UninitializedPropertyAccessException — check ::propertyName.isInitialized first if there’s a real chance a caller reaches it too early:
class UserSession {
lateinit var token: String
fun status() = if (::token.isInitialized) "Logged in" else "Not logged in"
}
The rule of thumb for the whole feature: use ?. and ?: for almost everything, reach for let when you need the non-null value in a block, and treat !! as a smell — every !! is a spot where you told the compiler “trust me,” which is exactly the kind of claim that used to cause NullPointerExceptions in Java.
Functions
Functions start with fun. A function with no return type has Unit, Kotlin’s equivalent of void, which you rarely write explicitly:
fun add(a: Int, b: Int): Int {
return a + b
}
// single-expression form — same thing, shorter
fun add(a: Int, b: Int) = a + b
Default parameters replace Java-style method overloading, and named arguments let you skip any parameter that has a default or reorder the ones you do pass:
fun greet(name: String, greeting: String = "Hello", punctuation: String = "!") =
"$greeting, $name$punctuation"
greet("Alex") // Hello, Alex!
greet("Sam", "Hi") // Hi, Sam!
greet("Jordan", punctuation = "?") // Hello, Jordan?
vararg accepts a variable number of arguments, and the spread operator * passes an existing array into one:
fun sum(vararg numbers: Int) = numbers.sum()
sum(1, 2, 3) // 6
val nums = intArrayOf(1, 2, 3)
sum(*nums) // 15
Functions can be nested (local functions), passed around as values, and returned from other functions — this is what a higher-order function means: a function that takes or returns another function. You’ll use this constantly with collections in the next section.
fun calculate(a: Int, b: Int, op: (Int, Int) -> Int) = op(a, b)
calculate(10, 5) { a, b -> a - b } // 5 — trailing lambda syntax
A local function — one declared inside another function — is useful for helper logic that only makes sense in one place, and it can see the outer function’s parameters directly without you passing them in:
fun processOrder(items: List<String>, discount: Double): String {
fun applyDiscount(price: Double) = price * (1 - discount) // sees `discount` directly
return items.joinToString { "$it: ${applyDiscount(10.0)}" }
}
::functionName turns an existing function into a value you can pass around the same way as a lambda — numbers.map(::square) reads more clearly than numbers.map { square(it) } once the function already has a name.
Control Flow
if is an expression in Kotlin — it returns a value, so there is no separate ternary operator:
val max = if (a > b) a else b
when replaces switch and is far more capable. It matches values, ranges, and types, and — since Kotlin 2.2 — supports guard conditions with if:
fun describe(value: Any): String = when {
value is String && value.length > 10 -> "Long string"
value is Int && value < 0 -> "Negative int"
value is Int -> "Positive int"
else -> "Something else"
}
sealed class Status
data class Error(val code: Int) : Status()
fun handle(s: Status) = when (s) {
is Error if s.code == 404 -> "Not found" // guard condition, Kotlin 2.2+
is Error -> "Error ${s.code}"
else -> "OK"
}
Ranges (1..5, 1..<5 exclusive, 5 downTo 1, 0..10 step 2) drive most loops:
for (i in 1..5) print("$i ") // 1 2 3 4 5
for (fruit in listOf("A", "B")) { }
for ((i, v) in list.withIndex()) { } // with index
while and do-while work as in any C-family language; break and continue support labels for escaping nested loops, since a bare break only exits the innermost one:
outer@ for (i in 1..3) {
for (j in 1..3) {
if (i == 2 && j == 2) break@outer // exits BOTH loops, not just the inner one
}
}
There is no exhaustive-when-without-else requirement for Any, but there is for enum and sealed types — the compiler forces you to handle every case, which becomes very useful once you meet sealed classes below.
Classes, Objects, and Data Classes
A class’s primary constructor lives right in the header:
class Person(val name: String, var age: Int) {
fun introduce() = "Hi, I'm $name and I'm $age years old."
}
val p = Person("Alex", 25)
println(p.introduce())
Classes are final by default — you must mark a class open to allow subclassing, and a function open to allow overriding:
open class Animal(val name: String) {
open fun sound() = "..."
}
class Dog(name: String) : Animal(name) {
override fun sound() = "Woof"
}
abstract class cannot be instantiated and can mix abstract members with concrete ones. interface supports default implementations and multiple inheritance — a class can implement several interfaces at once. When two interfaces provide the same default, you resolve the conflict explicitly with super<InterfaceName>.method().
Beyond the primary constructor, a secondary constructor offers an alternative way to build an object, and must delegate to the primary one with this(...); an init block runs during construction, the natural place for validation that isn’t just “assign this parameter to this property”:
class Rectangle(val width: Double, val height: Double) {
constructor(side: Double) : this(side, side) // secondary — builds a square
init {
require(width > 0 && height > 0) { "Dimensions must be positive" }
}
}
val square = Rectangle(7.0) // uses the secondary constructor
A property can define a custom getter and setter instead of just holding a value — private set restricts writes to inside the class while keeping the property publicly readable, and a computed property (getter only, no backing field) recalculates on every access:
class BankAccount(initial: Double) {
var balance = initial
private set // readable everywhere, writable only in this class
val isOverdrawn: Boolean // computed — no stored value
get() = balance < 0
}
object builds a singleton in one declaration — no separate class plus a manually-guarded instance field the way Java’s singleton pattern needs:
object AppConfig {
const val VERSION = "1.0"
var debug = false
}
AppConfig.debug = true // one shared instance, accessed like a namespace
Data classes are the feature you will use constantly for anything that just holds values:
data class Task(val id: Int, val title: String, val done: Boolean = false)
val t1 = Task(1, "Learn Kotlin")
val t2 = t1.copy(done = true) // new object, one field changed
val (id, title, done) = t1 // destructuring
copy() is the idiomatic way to “modify” an immutable object — you get a new instance with just the fields you name changed. Enum classes model a fixed set of values, optionally with their own properties and methods:
enum class Priority(val weight: Int) { LOW(1), MEDIUM(2), HIGH(3) }
Sealed classes are like enums but let each case carry different data, and — like enums — force the compiler to check every branch of a when:
sealed class Result
data class Success(val data: String) : Result()
data class Failure(val message: String) : Result()
fun handle(r: Result) = when (r) {
is Success -> "Got: ${r.data}"
is Failure -> "Error: ${r.message}"
// no else needed — the compiler knows there are exactly two cases
}
Use an enum when every value has the same shape (Direction.NORTH); use a sealed class when different values carry different data (Success(data) vs Failure(message)). object declares a singleton with exactly one instance, and companion object gives you Java-style static members attached to a class.
Collections
List, Set, and Map come in read-only (listOf, setOf, mapOf) and mutable (mutableListOf, …) flavors. The read-only versions are the default — reach for mutable* only when you actually need to change the collection after creating it.
The real power is the operation chain. These five cover most real code:
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
numbers.filter { it % 2 == 0 } // [2, 4, 6, 8, 10] — keep matches
numbers.map { it * it } // [1, 4, 9, ...] — transform each
numbers.groupBy { if (it % 2 == 0) "even" else "odd" } // {odd=[...], even=[...]}
numbers.sortedByDescending { it } // sort by a key
numbers.reduce { acc, n -> acc + n } // 55 — fold into one value
flatMap transforms each element into a collection and flattens the result — useful for pulling every item out of a list of orders. associate turns a list into a map. zip pairs two collections element by element. And chaining is where Kotlin collections read almost like SQL:
data class Student(val name: String, val grade: Int, val score: Double)
val topGrade10 = students
.filter { it.grade == 10 }
.sortedByDescending { it.score }
.take(3)
.map { it.name }
Other functions worth knowing: any/all/none for boolean checks, take/drop/chunked/windowed for slicing, distinct for deduplication, and partition to split a list into two by a predicate in one pass:
val (passing, failing) = scores.partition { it >= 60 }
val batches = orders.chunked(50) // process in batches of 50
val movingAverage = prices.windowed(3) { it.average() }
Map deserves its own look. mapOf builds a read-only key-value collection with key to value pairs; mutableMapOf lets you write with map[key] = value. Iterating destructures each entry directly:
val scores = mapOf("Alex" to 95, "Sam" to 87, "Jordan" to 92)
for ((name, score) in scores) println("$name: $score")
scores.filter { (_, score) -> score >= 90 } // filter by value
scores.mapValues { (_, score) -> if (score >= 90) "A" else "B" }
scores.getOrDefault("Taylor", 0) // 0 — no crash on a missing key
zip pairs two collections element by element (stopping at the shorter one), and associate turns a list into a map by computing a key-value pair per element — both come up constantly once you’re combining two related lists into one structure:
val names = listOf("Alex", "Sam")
val scores = listOf(95, 87)
names.zip(scores) // [(Alex, 95), (Sam, 87)]
names.associateWith { it.length } // {Alex=4, Sam=3}
That covers the foundation — by this point you can model data, branch and loop, and process collections. Everything from here builds on it.
Part 2: Working With Data, the Kotlin Way
Lambdas and Higher-Order Functions
A lambda is a function with no name, written between { } with -> separating parameters from the body:
val add = { a: Int, b: Int -> a + b }
println(add(3, 5)) // 8
When a lambda takes exactly one parameter, you can skip naming it and use it:
val evens = numbers.filter { it % 2 == 0 }
When the lambda is the last parameter of a function, you can pull it outside the parentheses — a trailing lambda — and drop the parentheses entirely if it’s the only argument. This is why numbers.filter { it > 3 } looks nothing like a normal function call: it’s the same mechanism as if/else blocks, just applied to function calls.
A function’s type looks like (Int, Int) -> Int, and you can store, pass, or return values of that type:
fun createMultiplier(factor: Int): (Int) -> Int = { it * factor }
val triple = createMultiplier(3)
println(triple(7)) // 21
That lambda closes over factor from the outer scope — a closure. Use ::functionName to pass an existing function instead of writing a lambda (numbers.filter(::isEven)), and mark small, frequently-called higher-order functions inline so the compiler copies the function body at the call site instead of allocating a lambda object — more on this in Part 3.
Extension Functions
Extension functions add a function to an existing type without touching its source or subclassing it:
fun String.toSlug(): String =
lowercase().replace(Regex("[^a-z0-9\\s-]"), "").trim().replace(Regex("\\s+"), "-")
println("Hello World!".toSlug()) // hello-world
Inside the function, this is the receiver (the object you called it on) — you can use it explicitly or omit it. Extension properties work the same way but must be computed (a get(), never stored state):
val String.isEmail: Boolean
get() = contains("@") && contains(".")
Two important limits: extensions cannot access private members of the class, and they are resolved at compile time based on the declared type, not the runtime type — so they don’t participate in polymorphism the way overridden methods do:
open class Shape
class Circle : Shape()
fun Shape.describe() = "a shape"
fun Circle.describe() = "a circle"
fun printIt(s: Shape) = println(s.describe())
printIt(Circle()) // prints "a shape" — resolved by the declared type Shape, not the actual Circle
In practice this rarely matters; extensions are for utility functions (String.wordCount(), List<T>.secondOrNull()), not for core business logic that needs overriding. You can extend a nullable type directly — inside the function, this can be null, so the extension itself becomes the null check:
fun String?.orDefault(): String = this ?: "N/A"
val name: String? = null
println(name.orDefault()) // "N/A" — no separate null check needed at the call site
Extensions on a companion object (the class needs at least an empty companion object {} to hang them off) are how you add factory-style functions that read like static methods: fun Color.Companion.fromHex(hex: String): Color, called as Color.fromHex("#FF0000"). Most of Kotlin’s standard library — .uppercase(), .filter {}, .sum() — is itself just extension functions on String, Iterable, and friends.
Scope Functions
Kotlin has five scope functions — let, run, with, apply, also — that all run a block against an object, differing only in how they refer to it (it vs this) and what they return (the lambda’s result vs the object itself):
| Function | Refers to object as | Returns | Best for |
|---|---|---|---|
let | it | lambda result | null checks, transforms |
run | this | lambda result | computing a result |
with | this | lambda result | grouping calls (not an extension, takes the object as a parameter) |
apply | this | the object | configuring an object |
also | it | the object | side effects, logging |
val user = User().apply { // configure, returns the User
name = "Alex"
email = "alex@mail.com"
}.also { println("Created: ${it.name}") } // side effect, still returns the User
val length = "Hello".let { it.length } // 5 — transform, returns the result
A simple rule that covers most cases: need to configure an object? apply. Need to log or debug in a chain? also. Need a null check? let. Need to compute a result from an object’s properties? run. Related helpers takeIf/takeUnless return the object itself (or null) based on a predicate — useful right before a ?.let:
val validEmail = input.takeIf { it.contains("@") }?.let { "Sending to $it" } ?: "Invalid email"
Don’t overuse these: if (name != null) println(name) is clearer than name?.let { println(it) } when there’s no transformation happening, and nesting several scope functions inside each other (user?.let { addr -> addr.city?.let { ... } }) is exactly what chained safe calls (user?.address?.city) already solve more simply — reach for the scope function only once a plain ?. chain stops being enough.
Sealed Classes, Enums, and Value Classes — Choosing the Right Tool
You met sealed classes and enums in Part 1. A third option, value classes, wraps a single value with zero runtime cost — the compiler erases the wrapper and treats it as the raw type underneath, but the type system still keeps it distinct:
@JvmInline
value class UserId(val value: Long) {
init { require(value > 0) { "UserId must be positive" } }
}
@JvmInline
value class Email(val value: String) {
init { require(value.contains("@")) }
}
fun findUser(id: UserId): String = "User #${id.value}"
Without UserId, a raw Long for a user ID and a raw Long for an order ID are interchangeable to the compiler — you could pass one where the other belongs and never get an error. value class makes that a compile error, for free at runtime. Sealed interfaces extend the sealed-class idea to multiple inheritance: a class can implement several sealed interfaces where a sealed class hierarchy only allows one parent.
Enums aren’t limited to flat constants — they can implement an interface, and each constant can override a method with its own body, which is how you model a fixed set of behaviors, not just values:
enum class OrderStatus {
PENDING, SHIPPED, DELIVERED, CANCELLED;
fun canTransitionTo(next: OrderStatus) = when (this) {
PENDING -> next == SHIPPED || next == CANCELLED
SHIPPED -> next == DELIVERED
else -> false
}
}
That’s a state machine expressed entirely in the enum itself — status.canTransitionTo(OrderStatus.DELIVERED) reads directly as a business rule, with the compiler guaranteeing every state was considered because when over an enum requires exhaustiveness.
The decision in one line: same shape for every value → enum. Different data per case → sealed class. Need multiple inheritance → sealed interface. Type-safety for a primitive that’s easy to mix up → value class.
Interfaces, Generics, and Type Constraints
Generics let one function or class work with any type while the compiler still checks you use it consistently:
class Box<T>(val value: T)
fun <T> singletonList(item: T): List<T> = listOf(item)
Type constraints restrict which types are allowed. <T : Comparable<T>> means “T must be comparable to itself,” which is what lets a generic findMax use >:
fun <T : Comparable<T>> findMax(a: T, b: T): T = if (a > b) a else b
findMax(10, 20) // 20
findMax("apple", "banana") // banana
Variance controls how generic types relate in a hierarchy. out T (covariant) means the type is only produced — this is why List<Dog> can be used where List<Animal> is expected (List<out E> in the standard library). in T (contravariant) means the type is only consumed. If you never remember the keywords, remember the rule: out produces, in consumes. Star projection List<*> means “a list of something, I don’t care what,” useful when you only need to iterate without caring about the element type:
fun describeList(list: List<*>): String = when {
list.isEmpty() -> "empty"
else -> "${list.size} items of unknown type"
}
Items read back from a List<*> come out as Any? — you’ve lost the specific element type, which is the trade-off for accepting “any list at all” as a parameter.
Generic type information is normally erased at runtime — the JVM can’t tell List<String> from List<Int> once compiled. inline fun <reified T> is the one exception: because the function body is inlined at every call site, the compiler can substitute the real type in, which is what makes list.filterIsInstance<String>() and value is T inside a generic function possible.
Interfaces plus generics is how you write reusable infrastructure. A single generic repository works for any type without repeating the CRUD boilerplate for each one:
interface Repository<T> {
fun getById(id: Int): T?
fun save(item: T)
}
class InMemoryRepository<T> : Repository<T> {
private val items = mutableMapOf<Int, T>()
private var nextId = 1
override fun getById(id: Int): T? = items[id]
override fun save(item: T) { items[nextId++] = item }
}
val tasks = InMemoryRepository<Task>() // works for any T, no repeated code
You’ll use exactly this shape in the capstone project’s TaskStore.
Error Handling
try/catch in Kotlin is an expression — the last line of either branch becomes the value:
val number = try { "42".toInt() } catch (e: NumberFormatException) { 0 }
All exceptions in Kotlin are unchecked, so there’s no throws declaration to maintain. require(), check(), and requireNotNull() throw IllegalArgumentException/IllegalStateException with a clear message — use them for precondition checks instead of hand-written if (...) throw ....
For functional-style error handling, Result<T> wraps either a success value or an exception:
fun parseNumber(text: String): Result<Int> = runCatching { text.toInt() }
parseNumber("42")
.map { it * 2 }
.fold(onSuccess = { println("Got $it") }, onFailure = { println("Error: ${it.message}") })
Result has a small, composable API worth knowing beyond fold: getOrNull()/getOrElse { default } extract the value without a when; onSuccess { }/onFailure { } run a side effect and return the same Result unchanged, so they chain; recover { } turns a failure back into a success with a fallback value, letting the rest of a chain continue as if nothing failed.
For error types the caller must handle explicitly, model them as a sealed class instead of exceptions — the compiler then forces exhaustive handling, the same benefit you saw with when in Part 1:
sealed class ApiResult<out T>
data class Success<T>(val data: T) : ApiResult<T>()
data class Failure(val error: String) : ApiResult<Nothing>()
Custom exceptions carry structured information a plain message string can’t — inherit from Exception, and callers can catch a shared base type broadly or a specific subtype narrowly, exactly the same trade-off sealed classes offer but using the exception-throwing mechanism instead:
open class AppError(message: String) : Exception(message)
class NotFoundError(val resourceId: Int) : AppError("Resource $resourceId not found")
try {
findResource(42)
} catch (e: NotFoundError) {
println("Missing id: ${e.resourceId}") // structured field, not just e.message
}
The rule of thumb across all three approaches: use null/OrNull functions for expected, recoverable absence (parsing user input); use exceptions for genuinely unexpected failures; use sealed classes when the caller needs to distinguish several well-defined error cases and you want the compiler checking you covered them all.
Delegation
by hands off a property or an entire interface implementation to another object, and the compiler writes the boilerplate. by lazy computes a value once, on first access, and caches it — ideal for expensive initialization you might never need:
class DatabaseConnection {
val connection: String by lazy {
println("Connecting...")
"Connected to PostgreSQL"
}
}
Delegates.observable fires a callback on every change (old value, new value) — good for logging or syncing UI state. Delegates.vetoable runs before the change and can reject it by returning false:
var health: Int by Delegates.vetoable(100) { _, _, new -> new >= 0 }
Class delegation is the most impactful form: class Service(logger: Logger) : Logger by logger implements every method of Logger by forwarding to the logger object, without you writing a single override. This is composition without the manual forwarding boilerplate that composition usually requires — you get the flexibility of “has-a” with almost none of the ceremony.
interface Logger { fun log(msg: String) }
class ConsoleLogger : Logger { override fun log(msg: String) = println("[LOG] $msg") }
// Delegates every Logger method to `logger` — zero overrides written
class Service(logger: Logger) : Logger by logger {
fun doWork() { log("Starting work...") }
}
There’s also map delegation, which is handy for parsing loosely-typed data like configuration or JSON: the property name becomes the map key, so a class can read straight from a Map<String, Any?> without a manual conversion step.
class UserFromMap(map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}
val user = UserFromMap(mapOf("name" to "Alex", "age" to 25))
Writing your own delegate means implementing ReadWriteProperty with getValue/setValue — useful when you want the same custom behavior (clamping, trimming, logging) applied to several properties without repeating it in every setter:
class ClampedInt(private var value: Int, private val min: Int, private val max: Int)
: ReadWriteProperty<Any?, Int> {
override fun getValue(thisRef: Any?, property: KProperty<*>) = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
this.value = value.coerceIn(min, max)
}
}
var volume: Int by ClampedInt(50, 0, 100)
volume = 150 // silently clamped to 100 — the delegate enforces the rule everywhere it's used
Sequences
Lists are eager: each operation (filter, map, …) runs to completion over the whole collection before the next one starts, building an intermediate list every time. Sequences are lazy: each element flows through the entire chain before the next element starts.
val result = largeList
.asSequence()
.filter { it % 2 == 0 }
.map { it * 10 }
.first()
For a list of a million items where you only need the first match, the eager version filters and maps all one million; the sequence version processes just enough elements to find one match and stops. generateSequence(seed) { next } builds sequences from a rule:
val fibonacci = generateSequence(0 to 1) { it.second to it.first + it.second }
.map { it.first }
.take(10)
.toList()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
val firstPrimeAbove1000 = generateSequence(1001) { it + 1 }
.filter { n -> (2..Math.sqrt(n.toDouble()).toInt()).none { n % it == 0 } }
.first()
The prime search never checks a bound — it stops the instant first() finds a match, which is impossible to express cleanly with an eager list. sequence { } builds one imperatively with yield, which is lazy in the same way a generator function is in other languages — the block only runs as far as it needs to satisfy whatever’s consuming it:
val naturals = sequence {
var n = 1
while (true) { yield(n); n++ } // infinite — fine, because nothing forces it to run to completion
}
naturals.take(5).toList() // [1, 2, 3, 4, 5]
The rule: reach for a sequence when the collection is large, you chain several operations, and you don’t need the full result (first(), take(n)) — otherwise a plain list is simpler and just as fast.
Part 3: Concurrency and Advanced Kotlin
Coroutines
A coroutine is a lightweight, suspendable unit of work — you can run thousands of them on a handful of real threads because a suspended coroutine doesn’t block the thread it was running on. Mark a function suspend to allow it to pause:
suspend fun fetchUserName(): String {
delay(100) // suspends, does not block the thread
return "Alex"
}
launch starts a coroutine and returns a Job — use it for side effects where you don’t need a result. async starts a coroutine and returns a Deferred<T> — call .await() to get the value, and use it when you need the result back, especially to run work in parallel:
suspend fun fetchProfile() = coroutineScope {
val name = async { fetchUserName() } // starts immediately
val age = async { fetchUserAge() } // runs concurrently with the line above
"${name.await()}, ${age.await()}" // total time ≈ max(both), not the sum
}
coroutineScope is the building block of structured concurrency: every coroutine has a parent, the parent waits for all its children, cancelling the parent cancels every child, and one child failing cancels its siblings too. This is what makes coroutines safe to reason about — you never lose track of a “forgotten” background task the way you can with raw threads or callbacks.
| Dispatcher | Runs on | Use for |
|---|---|---|
Dispatchers.Default | Shared CPU-sized thread pool | Sorting, parsing, any CPU-bound work |
Dispatchers.IO | Larger shared pool | Network calls, file access, database queries |
Dispatchers.Main | The UI thread (Android only) | Touching UI elements |
withContext(dispatcher) switches which one a block of code runs on for its duration, then returns to the original dispatcher — the standard way to fetch on IO and immediately process the result on Default in the same suspend function. withTimeout/withTimeoutOrNull cancel a coroutine that runs too long.
Cancellation in Kotlin is cooperative — a coroutine has to check whether it’s been cancelled, it doesn’t happen automatically mid-statement. Every suspending call from kotlinx.coroutines (delay, yield, …) checks for you; a tight CPU-bound loop with no suspend points needs an explicit isActive check to be cancellable at all:
val job = launch {
var i = 0
while (isActive) { i++ } // without this check, cancel() would have no effect here
}
Wrap cleanup in try/finally inside a coroutine and it still runs on cancellation, the same guarantee finally gives you for exceptions — if that cleanup itself needs to suspend (writing final state to disk, say), wrap it in withContext(NonCancellable), since an already-cancelled coroutine can’t normally call more suspend functions.
Two more tools matter once tasks are genuinely independent. SupervisorJob (and its suspend-friendly form supervisorScope) changes the default: one child failing no longer cancels its siblings, which is what you want when loading several unrelated sections of a screen:
suspend fun loadDashboard() = supervisorScope {
val news = async { fetchNews() }
val weather = async { fetchWeather() }
// if fetchNews() throws, weather still completes — with coroutineScope it wouldn't
Dashboard(
news = runCatching { news.await() }.getOrDefault(emptyList()),
weather = runCatching { weather.await() }.getOrNull()
)
}
launch and async disagree on when an uncaught exception surfaces: launch propagates it immediately to the parent scope (cancelling siblings, per structured concurrency above); async stores it in the Deferred and only throws when you call .await(). A CoroutineExceptionHandler installed on a scope is the last-resort catch for exceptions from launch that nothing else handled — treat it as crash reporting, not your primary error handling, since ordinary try/catch inside the coroutine is still the right tool for expected failures.
Channel is a queue between coroutines — one side sends, the other receives — useful for fan-out (several workers pulling from one channel) or fan-in (several producers feeding one consumer):
val channel = Channel<Int>()
launch { for (i in 1..5) channel.send(i); channel.close() } // producer
for (value in channel) println(value) // consumer — 1, 2, 3, 4, 5
A channel’s buffering strategy is configurable: RENDEZVOUS (the default, no buffer — send suspends until a receive is ready) forces producer and consumer to stay in lockstep; BUFFERED holds a fixed number of pending values before send starts suspending; CONFLATED keeps only the most recent value, silently dropping anything the consumer didn’t get to in time — the right choice for something like live sensor readings where only the latest value matters, never a queue of history.
select waits on several suspending operations at once and proceeds with whichever is ready first — useful for racing two sources and taking the fastest, like a cache lookup against a network fetch:
val result = select<String> {
cacheChannel.onReceive { "from cache: $it" }
networkChannel.onReceive { "from network: $it" }
}
Whichever channel produces a value first wins; the other operation is simply not taken this time.
For shared mutable state accessed from multiple coroutines, use Mutex().withLock { } instead of Java’s synchronized, because Mutex suspends the coroutine instead of blocking the thread it’s running on:
val mutex = Mutex()
var counter = 0
coroutineScope {
repeat(1000) { launch { mutex.withLock { counter++ } } }
}
println(counter) // always 1000 — without the mutex, some increments would be lost
Flow
Flow is Kotlin’s stream type: cold (nothing runs until you collect) and built entirely on coroutines, so every operator can suspend.
fun countdown(): Flow<Int> = flow {
for (i in 5 downTo 1) { delay(200); emit(i) }
}
countdown().collect { println(it) }
The operators mirror collection operators (map, filter, take) but run lazily and support suspending code inside them. flowOn(dispatcher) moves the upstream work to a different dispatcher without affecting the collector — never call withContext directly inside a flow { } builder, use flowOn instead. combine merges several flows using the latest value from each; catch and retry handle upstream errors without a try/catch around collect. conflate() is the Flow equivalent of a CONFLATED channel — if the collector is slower than the producer, it skips intermediate values and always processes the latest one instead of queueing a backlog.
When each upstream value produces its own inner flow, three operators pick a different strategy for handling overlap: flatMapConcat processes inner flows one at a time, strictly in order; flatMapMerge runs them all concurrently, interleaving whichever completes first; flatMapLatest cancels the previous inner flow the instant a new upstream value arrives, which is exactly what searchAsYouType above relies on to drop a search that’s already stale by the time it would finish.
A search box is the textbook example that shows several operators working together — debounce waits for a pause in typing, distinctUntilChanged skips a repeated query, and flatMapLatest cancels the previous search the moment a new one starts:
fun searchAsYouType(queries: Flow<String>): Flow<List<Task>> = queries
.debounce(300)
.distinctUntilChanged()
.filter { it.isNotBlank() }
.flatMapLatest { query -> flow { emit(searchTasks(query)) } }
Two flow subtypes are hot rather than cold: StateFlow always holds a current value and only emits on change (a reactive variable — expose the read-only StateFlow from a class while keeping a private MutableStateFlow internally), and SharedFlow broadcasts events to every active collector without requiring an initial value (good for one-time events like navigation or error toasts). stateIn/shareIn convert a cold flow into one of these hot ones when you need to share a single upstream computation across multiple collectors.
class TaskRepository {
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
fun add(task: Task) { _tasks.value = _tasks.value + task }
}
Inline, Reified, and Contracts
inline tells the compiler to paste a function’s body directly at every call site instead of creating a lambda object — worth it for small, frequently-called functions that take lambda parameters (which is exactly what most of Kotlin’s own scope functions and collection operators are). It also unlocks two things ordinary functions can’t do: non-local returns, where return inside a lambda passed to an inline function exits the enclosing function, and reified generics, where inline fun <reified T> preserves the real type at runtime, letting you write value is T or T::class — both illegal in a normal generic function because of type erasure.
inline fun <reified T> List<Any>.filterByType(): List<T> = filterIsInstance<T>()
val mixed: List<Any> = listOf(1, "a", 2, "b")
val strings: List<String> = mixed.filterByType() // [a, b]
Use noinline on a specific lambda parameter when you need to store it or pass it to a non-inline function, and crossinline when the lambda runs in a different execution context (like a new Thread) where a non-local return wouldn’t make sense. Contracts are the mechanism behind functions like run and requireNotNull that let the compiler smart-cast or treat a val as initialized across a lambda boundary:
@OptIn(ExperimentalContracts::class)
inline fun <T> runOnce(block: () -> T): T {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
return block()
}
val value: Int
runOnce { value = 42 } // compiles — the contract tells the compiler this runs exactly once
println(value)
Without the callsInPlace contract, the compiler can’t prove value was definitely assigned before the println, and refuses to compile it — mostly relevant if you’re writing your own standard-library-style utilities, but worth recognizing when you see it in the standard library’s own source.
DSLs
A DSL (domain-specific language) in Kotlin is built from one core trick: a lambda with receiver, written ReceiverType.() -> Unit. Inside the lambda, this is the receiver, so you call its members with no prefix at all:
class ServerConfig { var host = "localhost"; var port = 8080 }
fun serverConfig(block: ServerConfig.() -> Unit): ServerConfig =
ServerConfig().apply(block)
val server = serverConfig {
host = "api.example.com" // this.host, no prefix needed
port = 443
}
This is exactly the mechanism behind build.gradle.kts, Ktor’s routing { get("/") { } }, and Jetpack Compose’s Column { Text("Hi") } — none of them are special syntax, all of them are ordinary functions taking a lambda with receiver. Nesting builders gives you structure:
class AppConfig { var name = ""; var server = ServerConfig() }
@DslMarker
annotation class ConfigDsl
fun appConfig(block: AppConfig.() -> Unit) = AppConfig().apply(block)
val app = appConfig {
name = "TaskFlow"
server { host = "0.0.0.0"; port = 8080 } // nested builder
}
Annotate each builder class with a custom @DslMarker annotation (like @ConfigDsl above, applied to both AppConfig and ServerConfig) to stop an inner block from accidentally calling members of an outer receiver — without it, Kotlin lets nested receivers “leak” into each other, which is confusing and easy to misuse by accident.
A query builder is a good second example, because it shows a DSL producing a real output value (a SQL string) instead of just configuring an object in place:
class QueryBuilder {
private val conditions = mutableListOf<String>()
private var table = ""
fun from(t: String) { table = t }
fun where(condition: String) { conditions.add(condition) }
fun build() = "SELECT * FROM $table" +
if (conditions.isEmpty()) "" else " WHERE " + conditions.joinToString(" AND ")
}
fun query(block: QueryBuilder.() -> Unit) = QueryBuilder().apply(block).build()
val sql = query {
from("users")
where("age > 18")
where("active = true")
}
// SELECT * FROM users WHERE age > 18 AND active = true
Multiple calls to where accumulate rather than overwrite, because each call appends to the same mutable list — a small design choice, but it’s what makes the DSL feel like it’s building something up rather than just setting properties. Once you’ve built one DSL, you start recognizing the same lambda-with-receiver shape everywhere in the Kotlin ecosystem: build.gradle.kts’s dependencies { } block, Ktor’s routing { get("/") { } }, Compose’s Column { Text("Hi") }, and the Exposed SQL library’s Users.select { Users.name eq "Alex" } are all ordinary functions using this exact mechanism — no special compiler support beyond what you’ve just learned.
Serialization
kotlinx.serialization is the official JSON library. Mark a class @Serializable and the compiler generates the (de)serializer at compile time — no reflection, and it understands Kotlin’s null safety and default values, unlike Gson.
@Serializable
data class Task(val id: Int, val title: String, val done: Boolean = false)
val json = Json.encodeToString(Task(1, "Learn Kotlin"))
// {"id":1,"title":"Learn Kotlin","done":false}
val task = Json.decodeFromString<Task>(json)
@Transient excludes a field entirely (passwords, tokens — it must have a default value). @SerialName("snake_case_name") maps a Kotlin property to a differently-named JSON field, common when talking to APIs that don’t use camelCase. The setting you’ll reach for constantly when consuming real APIs is ignoreUnknownKeys = true — without it, any field the API adds later that your class doesn’t know about throws instead of being silently skipped:
val api = Json { ignoreUnknownKeys = true; coerceInputValues = true }
For inheritance, mark the parent sealed (or sealed interface) and each subclass @Serializable: encoding adds a "type" discriminator field automatically, and decoding reads it back to pick the right subclass — this is how you serialize a Result-style hierarchy without hand-writing a custom serializer.
@Serializable sealed class Shape
@Serializable @SerialName("circle") data class Circle(val radius: Double) : Shape()
@Serializable @SerialName("square") data class Square(val side: Double) : Shape()
Json.encodeToString<Shape>(Circle(5.0)) // {"type":"circle","radius":5.0}
When the JSON shape is unknown or you only need a couple of fields from a large response, Json.parseToJsonElement(text) gives you a navigable tree (element.jsonObject["name"]?.jsonPrimitive?.content) without defining a matching data class at all — useful for quick exploration of an API response before you commit to modeling it properly.
Part 4: Build TaskFlow — a CLI and a REST API Sharing One Domain
Everything above is enough to build something real. TaskFlow is a task manager: the same domain model (Task, TaskStore) backs a colored command-line tool and a tested REST API, so you see how a sealed class, a data class, and a Result type designed in isolation actually hold up once two different front ends depend on them.
The Domain Model
@Serializable
data class Task(
val id: Int,
val title: String,
val priority: Priority = Priority.MEDIUM,
val done: Boolean = false
)
@Serializable
enum class Priority { LOW, MEDIUM, HIGH }
@Serializable
data class CreateTaskRequest(val title: String, val priority: Priority = Priority.MEDIUM)
class TaskStore {
private val tasks = mutableMapOf<Int, Task>()
private var nextId = 1
fun getAll(): List<Task> = tasks.values.toList()
fun getById(id: Int): Task? = tasks[id]
fun create(request: CreateTaskRequest): Task {
require(request.title.isNotBlank()) { "Title cannot be empty" }
val task = Task(id = nextId++, title = request.title, priority = request.priority)
tasks[task.id] = task
return task
}
fun complete(id: Int): Task? {
val task = tasks[id] ?: return null
val updated = task.copy(done = true)
tasks[id] = updated
return updated
}
fun topByPriority(): List<Task> =
tasks.values.filter { !it.done }.sortedByDescending { it.priority.ordinal }
}
Notice how much of Part 1 and 2 shows up here without any new concepts: a data class for the shape, an enum for the fixed set of priorities, require() for validation, copy() for the immutable “update,” and a filter/sortedByDescending chain to compute the priority list. @Serializable on both Task and Priority is all that’s needed for the REST API to speak JSON later.
For anything more than one filter condition, a small DSL (from Part 3) reads better than a chain of if statements:
class TaskQuery { var priority: Priority? = null; var doneOnly: Boolean? = null }
fun TaskStore.query(block: TaskQuery.() -> Unit): List<Task> {
val q = TaskQuery().apply(block)
return getAll().filter { t ->
(q.priority == null || t.priority == q.priority) &&
(q.doneOnly == null || t.done == q.doneOnly)
}
}
// Usage — reads like a query, not a chain of null checks
val urgent = store.query { priority = Priority.HIGH; doneOnly = false }
A Colored CLI Front End
object Colors {
private const val RESET = "\u001B[0m"
fun green(s: String) = "\u001B[32m$s$RESET"
fun red(s: String) = "\u001B[31m$s$RESET"
}
fun runCli(args: Array<String>, store: TaskStore): String {
val command = args.getOrNull(0) ?: "help"
return when (command) {
"add" -> {
val title = args.drop(1).joinToString(" ")
runCatching { store.create(CreateTaskRequest(title)) }
.fold(
onSuccess = { Colors.green("Added #${it.id}: ${it.title}") },
onFailure = { Colors.red("Error: ${it.message}") }
)
}
"done" -> {
val id = args.getOrNull(1)?.toIntOrNull()
?: return Colors.red("Usage: taskflow done <id>")
store.complete(id)?.let { Colors.green("Completed: ${it.title}") }
?: Colors.red("No task with id $id")
}
"list" -> store.getAll().joinToString("\n") { t ->
val mark = if (t.done) "x" else " "
"[$mark] #${t.id} ${t.title} (${t.priority})"
}
else -> "Usage: taskflow <add|done|list> [args]"
}
}
fun main(args: Array<String>) = println(runCli(args, TaskStore()))
runCli returns a String instead of printing directly — the same trick from Part 4’s testing chapter that makes command logic trivial to unit test without capturing stdout. Result/fold handles the one place a command can fail (an empty title), and ?.let { } ?: ... handles the one place a lookup can fail (an unknown id) — the same null-safety patterns from Part 1, now doing real work.
The Same Domain, Exposed as a REST API
Ktor is Kotlin’s own async web framework, built on coroutines. install(ContentNegotiation) { json() } gives every route automatic Task ↔ JSON conversion using the @Serializable classes you already wrote:
fun Application.configureApi(store: TaskStore = TaskStore()) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, mapOf("error" to cause.message))
}
}
routing {
route("/tasks") {
get { call.respond(store.getAll()) }
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid id")
store.getById(id)?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
}
post {
val request = call.receive<CreateTaskRequest>()
call.respond(HttpStatusCode.Created, store.create(request))
}
post("/{id}/complete") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("Invalid id")
store.complete(id)?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
}
}
}
}
fun main() {
embeddedServer(Netty, port = 8080) { configureApi() }.start(wait = true)
}
StatusPages turns the same require() validation the CLI used into an automatic 400 response — one validation rule, two front ends, no duplicated error handling. call.receive<CreateTaskRequest>() and call.respond(task) are doing the deserializing and serializing entirely through the @Serializable annotations from the domain model — nothing route-specific to write.
Testing Both
Ktor’s testApplication runs the whole route tree in-memory, no real socket involved, and MockK plus runTest cover the coroutine-based parts:
class TaskApiTest {
@Test
fun `POST tasks creates a task`() = testApplication {
application { configureApi() }
val client = createClient { install(ContentNegotiation) { json() } }
val response = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody(CreateTaskRequest("Write tests"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
@Test
fun `GET tasks id returns 404 for missing task`() = testApplication {
application { configureApi() }
val response = client.get("/tasks/999")
assertEquals(HttpStatusCode.NotFound, response.status)
}
}
class TaskStoreTest {
private val store = TaskStore()
@Test
fun `create rejects blank title`() {
assertThrows<IllegalArgumentException> {
store.create(CreateTaskRequest(""))
}
}
@Test
fun `complete returns null for unknown id`() {
assertNull(store.complete(999))
}
}
Every test targets behavior — status codes and return values — not implementation details, which is what keeps them useful when you refactor the internals later.
If TaskStore depended on a collaborator — say a Notifier interface that emails someone when a task completes — MockK replaces it with a fake for the test, verifying the interaction happened without sending a real email:
interface Notifier { fun notify(message: String) }
class TaskStoreNotifierTest {
private val notifier = mockk<Notifier>(relaxed = true)
private val store = TaskStore(notifier)
@Test
fun `complete notifies once`() {
val task = store.create(CreateTaskRequest("Ship it"))
store.complete(task.id)
verify(exactly = 1) { notifier.notify(match { it.contains("Ship it") }) }
}
}
relaxed = true gives every unstubbed method a harmless default return value, so the mock doesn’t need every call explicitly programmed — useful when you only care about verifying one specific interaction, not controlling every response.
Project Layout
taskflow/
├── build.gradle.kts
└── src/
├── main/kotlin/
│ ├── Task.kt # domain model — Task, Priority, TaskStore
│ ├── Cli.kt # CLI front end
│ └── Api.kt # Ktor REST API
└── test/kotlin/
├── TaskStoreTest.kt
└── TaskApiTest.kt
One TaskStore backs both front ends. That’s the actual payoff of everything in Parts 1–3: null safety and sealed classes made the domain model hard to misuse, coroutines and Ktor made the API async by default with no extra code, and Result/require() gave both front ends the same validation logic for free. Natural next steps from here: swap TaskStore’s in-memory map for a real database, add StateFlow so the CLI can watch for changes made through the API, or add JWT authentication to the routes.
Where to Go From Here
- Kotlin Cheat Sheet — bookmark this for quick syntax lookups
- Kotlin vs Java 2026 — deeper comparison if you’re deciding between them
- Kotlin Interview Questions 2026 — practice for interviews
- Kotlin Developer Salary 2026 — market data if you’re job hunting
- Ktor Tutorial: Build a Production Backend — go further with Ktor: auth, WebSockets, Docker, and CI/CD
- Jetpack Compose Tutorial — put Kotlin to work building Android UIs
- KMP Tutorial — share Kotlin code across Android, iOS, and desktop
The complete, working code for the capstone project (TaskFlow) is on GitHub: github.com/kemalcodes/kotlin-tutorial.