Toolmingo
Guides24 min read

Kotlin Tutorial for Beginners (2025): Learn Kotlin Step by Step

Complete Kotlin tutorial for beginners. Learn Kotlin syntax, null safety, coroutines, and build real projects. Free guide with code examples for 2025.

Kotlin is a modern, concise programming language that runs on the JVM, compiles to JavaScript, and targets native platforms. It's Google's preferred language for Android development and increasingly popular for server-side development with frameworks like Spring Boot and Ktor. This tutorial takes you from zero to writing real Kotlin programs.

What you'll learn

Topic What you'll be able to do
Setup Install Kotlin and write your first program
Syntax & variables Understand Kotlin's concise syntax vs Java
Null safety Eliminate NullPointerExceptions at compile time
OOP Write classes, data classes, sealed classes
Functional programming Use lambdas, higher-order functions, collections
Coroutines Write async code that reads like sync code
Real projects Build CLI apps and REST APIs

Kotlin vs Java vs Python

Feature Kotlin Java Python
Verbosity Concise Verbose Concise
Null safety Built-in Optional (annotations) Runtime only
Type system Static, inferred Static, explicit Dynamic
Interoperability 100% Java compat Limited JVM
Android Preferred Legacy Limited
Learning curve Moderate Moderate Easy
Performance JVM speed JVM speed Slower
Coroutines First-class Project Loom asyncio

Setup

Option 1: IntelliJ IDEA (recommended)

  1. Download IntelliJ IDEA Community (free)
  2. Create a new Kotlin project (File → New → Project → Kotlin)
  3. IntelliJ bundles the Kotlin compiler — no separate install needed

Option 2: Command line

macOS (Homebrew):

brew install kotlin

Linux (SDKMAN):

curl -s "https://get.sdkman.io" | bash
sdk install kotlin

Windows: Download from kotlinlang.org/docs/command-line.html

Verify:

kotlinc -version
# kotlin-compiler 2.0.x

Option 3: Online playground

Try Kotlin instantly at play.kotlinlang.org — no install needed.

Your first Kotlin program

fun main() {
    println("Hello, Kotlin!")
}

Compile and run:

kotlinc hello.kt -include-runtime -d hello.jar
java -jar hello.jar
# Hello, Kotlin!

Notice: no semicolons, no public static void main, no System.out.println — Kotlin is much more concise than Java.

Variables and Types

Kotlin has two keywords for variables:

val name = "Alice"    // val = immutable (like final in Java)
var age = 30          // var = mutable
age = 31              // OK
// name = "Bob"       // Compile error — val cannot be reassigned

Explicit types:

val message: String = "Hello"
var count: Int = 0
val pi: Double = 3.14159
val isActive: Boolean = true
val letter: Char = 'K'

Type inference — Kotlin infers the type from the value:

val city = "Podgorica"     // String inferred
val temperature = 36.5     // Double inferred
val population = 1_000_000 // Int inferred (underscores OK in numbers)

All basic types

Type Size Range / Notes
Byte 8-bit -128 to 127
Short 16-bit -32,768 to 32,767
Int 32-bit -2^31 to 2^31-1
Long 64-bit -2^63 to 2^63-1
Float 32-bit 6-7 decimal digits
Double 64-bit 15-16 decimal digits
Char 16-bit Single Unicode character
Boolean true or false
String Immutable text

Number literals:

val million = 1_000_000       // readable with underscores
val hexColor = 0xFF_EC_D2     // hex
val binary = 0b0001_0101      // binary
val bigLong = 1_000_000_000L  // Long suffix
val pi = 3.14f                // Float suffix

Strings

val first = "Alice"
val last = "Smith"

// String templates — use $ and ${}
val greeting = "Hello, $first $last!"
val upper = "Name in uppercase: ${first.uppercase()}"

// Multi-line strings
val poem = """
    Roses are red,
    Kotlin is great,
    No NPEs today.
""".trimIndent()

println(greeting)  // Hello, Alice Smith!
println(upper)     // Name in uppercase: ALICE

Common string operations:

val s = "  Hello, World!  "

println(s.trim())              // "Hello, World!"
println(s.lowercase())         // "  hello, world!  "
println(s.uppercase())         // "  HELLO, WORLD!  "
println(s.replace("World", "Kotlin"))  // "  Hello, Kotlin!  "
println(s.contains("World"))   // true
println(s.startsWith("  H"))   // true
println(s.split(", "))         // [  Hello, World!  ]
println(s.length)              // 17
println(s.trim().substring(0, 5))  // "Hello"

Null Safety

Null safety is Kotlin's killer feature — it eliminates NullPointerException at compile time.

// Non-nullable: cannot hold null
var name: String = "Alice"
// name = null  // Compile error!

// Nullable: add ? to allow null
var nickname: String? = null
nickname = "Ali"
nickname = null  // OK

Safe call operator ?.

val name: String? = null

// Safe call — returns null if name is null
println(name?.length)  // null (no crash)
println(name?.uppercase()?.trimEnd())  // can chain

Elvis operator ?:

val name: String? = null
val displayName = name ?: "Anonymous"  // use "Anonymous" if null
println(displayName)  // Anonymous

val length = name?.length ?: 0  // 0 if name is null

Non-null assertion !!

val name: String? = "Alice"
val length = name!!.length  // throws NPE if name is null — use sparingly!

Safe cast as?

val obj: Any = "Hello"
val str: String? = obj as? String  // null if cast fails, no exception
val num: Int? = obj as? Int        // null — obj is not Int

Let scope function

val email: String? = getUserEmail()

email?.let { address ->
    sendEmail(address)  // only runs if email is not null
    println("Sent to $address")
}

Null safety summary

Operator Meaning
T? Nullable type
?. Safe call — returns null if receiver is null
?: Elvis — provide default if null
!! Non-null assertion — throws if null
as? Safe cast — returns null if cast fails

Control Flow

if / else (also an expression)

val temperature = 25

// Statement form
if (temperature > 30) {
    println("Hot!")
} else if (temperature > 20) {
    println("Warm")
} else {
    println("Cool")
}

// Expression form — if returns a value
val description = if (temperature > 30) "Hot" else "Not hot"
println(description)  // Not hot

when (Kotlin's switch, but much more powerful)

val day = 3

val dayName = when (day) {
    1 -> "Monday"
    2 -> "Tuesday"
    3 -> "Wednesday"
    4, 5 -> "Thursday or Friday"
    in 6..7 -> "Weekend"
    else -> "Invalid"
}
println(dayName)  // Wednesday

// when with no argument (like if-else chain)
val score = 75
val grade = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    score >= 60 -> "D"
    else -> "F"
}

// when with type checking
fun describe(obj: Any): String = when (obj) {
    is Int -> "Integer: $obj"
    is String -> "String of length ${obj.length}"
    is List<*> -> "List with ${obj.size} elements"
    else -> "Unknown"
}
println(describe(42))         // Integer: 42
println(describe("Hello"))    // String of length 5

Loops

// for loop with range
for (i in 1..5) {
    print("$i ")  // 1 2 3 4 5
}

// Exclusive range
for (i in 1 until 5) {
    print("$i ")  // 1 2 3 4
}

// Step
for (i in 0..10 step 2) {
    print("$i ")  // 0 2 4 6 8 10
}

// Countdown
for (i in 5 downTo 1) {
    print("$i ")  // 5 4 3 2 1
}

// Iterate over collection
val fruits = listOf("apple", "banana", "cherry")
for (fruit in fruits) {
    println(fruit)
}

// With index
for ((index, fruit) in fruits.withIndex()) {
    println("$index: $fruit")
}
// while
var count = 0
while (count < 5) {
    println(count)
    count++
}

// do-while
do {
    println("Runs at least once")
    count--
} while (count > 0)

// break and continue
for (i in 1..10) {
    if (i == 3) continue  // skip 3
    if (i == 7) break     // stop at 7
    print("$i ")  // 1 2 4 5 6
}

Functions

// Basic function
fun greet(name: String): String {
    return "Hello, $name!"
}

// Single-expression function
fun greet(name: String) = "Hello, $name!"

// Default parameters
fun greet(name: String, greeting: String = "Hello") = "$greeting, $name!"

// Named arguments
println(greet(name = "Alice", greeting = "Hi"))
println(greet(greeting = "Bonjour", name = "Pierre"))

// Vararg
fun sum(vararg numbers: Int): Int = numbers.sum()
println(sum(1, 2, 3, 4, 5))  // 15

// Spread operator for arrays
val nums = intArrayOf(1, 2, 3)
println(sum(*nums))  // 6

Extension functions

Extend existing classes without inheriting from them:

// Add a function to String
fun String.isPalindrome(): Boolean {
    return this == this.reversed()
}

println("racecar".isPalindrome())  // true
println("hello".isPalindrome())    // false

// Add a function to Int
fun Int.isEven() = this % 2 == 0
println(4.isEven())  // true

// Extension on nullable type
fun String?.orEmpty() = this ?: ""
val name: String? = null
println(name.orEmpty())  // ""  (standard library already has this)

Infix functions

infix fun Int.plus(other: Int) = this + other

val result = 3 plus 4  // reads like natural language
println(result)  // 7

// Standard library infix: to (for pairs)
val pair = "key" to "value"  // Pair<String, String>
val map = mapOf("a" to 1, "b" to 2)

Higher-order functions

Functions that take functions as parameters or return functions:

fun applyTwice(x: Int, operation: (Int) -> Int): Int {
    return operation(operation(x))
}

val double = { n: Int -> n * 2 }
println(applyTwice(3, double))  // 12

// Lambda shorthand — last parameter can be outside parentheses
println(applyTwice(3) { it * 2 })  // 12

Classes and OOP

class Person(val name: String, var age: Int) {
    // Secondary constructor
    constructor(name: String) : this(name, 0)

    // Method
    fun greet() = "Hi, I'm $name, $age years old."

    // Property with custom getter
    val isAdult: Boolean
        get() = age >= 18

    // Property with getter and setter
    var email: String = ""
        set(value) {
            field = value.trim().lowercase()
        }

    override fun toString() = "Person(name=$name, age=$age)"
}

val alice = Person("Alice", 30)
println(alice.greet())    // Hi, I'm Alice, 30 years old.
println(alice.isAdult)    // true
alice.email = "  Alice@Example.com  "
println(alice.email)      // alice@example.com

Data classes

Auto-generate equals(), hashCode(), toString(), copy(), and destructuring:

data class Point(val x: Int, val y: Int)

val p1 = Point(1, 2)
val p2 = Point(1, 2)
val p3 = p1.copy(y = 10)

println(p1)          // Point(x=1, y=2)
println(p1 == p2)    // true (structural equality)
println(p3)          // Point(x=1, y=10)

// Destructuring
val (x, y) = p1
println("x=$x, y=$y")  // x=1, y=2

Inheritance

open class Animal(val name: String) {
    open fun sound(): String = "..."

    fun describe() = "$name says ${sound()}"
}

class Dog(name: String) : Animal(name) {
    override fun sound() = "Woof"
}

class Cat(name: String) : Animal(name) {
    override fun sound() = "Meow"
}

val dog = Dog("Rex")
val cat = Cat("Whiskers")
println(dog.describe())  // Rex says Woof
println(cat.describe())  // Whiskers says Meow

Abstract classes and interfaces

abstract class Shape {
    abstract fun area(): Double
    abstract fun perimeter(): Double

    fun describe() = "Area: ${area()}, Perimeter: ${perimeter()}"
}

class Circle(val radius: Double) : Shape() {
    override fun area() = Math.PI * radius * radius
    override fun perimeter() = 2 * Math.PI * radius
}

class Rectangle(val width: Double, val height: Double) : Shape() {
    override fun area() = width * height
    override fun perimeter() = 2 * (width + height)
}

// Interface — can have default implementations
interface Drawable {
    fun draw()
    fun resize(factor: Double) {
        println("Resizing by $factor")  // default implementation
    }
}

interface Colorable {
    var color: String
}

class Square(val side: Double) : Shape(), Drawable, Colorable {
    override var color = "blue"
    override fun area() = side * side
    override fun perimeter() = 4 * side
    override fun draw() = println("Drawing a $color square")
}

Object declarations and companion objects

// Singleton
object DatabaseConfig {
    val host = "localhost"
    val port = 5432

    fun connectionString() = "jdbc:postgresql://$host:$port/mydb"
}

println(DatabaseConfig.connectionString())

// Companion object (like static in Java)
class MathUtils {
    companion object {
        const val PI = 3.14159

        fun circleArea(radius: Double) = PI * radius * radius

        // Factory method
        fun create() = MathUtils()
    }
}

println(MathUtils.PI)
println(MathUtils.circleArea(5.0))
val instance = MathUtils.create()

Sealed classes

Restrict class hierarchies — all subclasses must be in the same file:

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
    object Loading : Result<Nothing>()
}

fun handleResult(result: Result<String>) = when (result) {
    is Result.Success -> println("Got: ${result.data}")
    is Result.Error -> println("Error: ${result.message}")
    Result.Loading -> println("Loading...")
    // No else needed — compiler knows all cases
}

handleResult(Result.Success("Hello"))  // Got: Hello
handleResult(Result.Error("Network error"))  // Error: Network error
handleResult(Result.Loading)  // Loading...

Enum classes

enum class Direction {
    NORTH, SOUTH, EAST, WEST;

    fun opposite(): Direction = when (this) {
        NORTH -> SOUTH
        SOUTH -> NORTH
        EAST -> WEST
        WEST -> EAST
    }
}

val dir = Direction.NORTH
println(dir.opposite())  // SOUTH
println(Direction.values().toList())  // [NORTH, SOUTH, EAST, WEST]

// Enum with properties
enum class Planet(val mass: Double, val radius: Double) {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS(4.869e+24, 6.0518e6),
    EARTH(5.976e+24, 6.37814e6);

    val gravity = 6.67300e-11 * mass / (radius * radius)
}

println(Planet.EARTH.gravity)  // ~9.8

Collections

Kotlin distinguishes between read-only and mutable collections:

// Read-only
val names = listOf("Alice", "Bob", "Charlie")
val scores = mapOf("Alice" to 95, "Bob" to 87)
val tags = setOf("kotlin", "jvm", "android")

// Mutable
val mutableNames = mutableListOf("Alice", "Bob")
mutableNames.add("Charlie")
mutableNames.remove("Bob")

val mutableMap = mutableMapOf("a" to 1)
mutableMap["b"] = 2
mutableMap.remove("a")

val mutableSet = mutableSetOf(1, 2, 3)
mutableSet.add(4)

List operations

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

// filter — keep elements that match predicate
val evens = numbers.filter { it % 2 == 0 }
println(evens)  // [2, 4, 6, 8, 10]

// map — transform each element
val doubled = numbers.map { it * 2 }
println(doubled)  // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

// reduce / fold
val sum = numbers.reduce { acc, n -> acc + n }
println(sum)  // 55

val sumWithFold = numbers.fold(0) { acc, n -> acc + n }

// any / all / none
println(numbers.any { it > 9 })   // true
println(numbers.all { it > 0 })   // true
println(numbers.none { it < 0 })  // true

// find / firstOrNull
val firstEven = numbers.firstOrNull { it % 2 == 0 }
println(firstEven)  // 2

// groupBy
val grouped = numbers.groupBy { if (it % 2 == 0) "even" else "odd" }
println(grouped)  // {odd=[1, 3, 5, 7, 9], even=[2, 4, 6, 8, 10]}

// sortedBy / sortedByDescending
data class Person(val name: String, val age: Int)
val people = listOf(Person("Bob", 30), Person("Alice", 25), Person("Charlie", 35))
val sorted = people.sortedBy { it.name }
val byAgeDesc = people.sortedByDescending { it.age }

// flatMap — map then flatten
val words = listOf("Hello World", "Kotlin Is Great")
val letters = words.flatMap { it.split(" ") }
println(letters)  // [Hello, World, Kotlin, Is, Great]

// take / drop
println(numbers.take(3))   // [1, 2, 3]
println(numbers.drop(7))   // [8, 9, 10]

// zip
val keys = listOf("a", "b", "c")
val values = listOf(1, 2, 3)
val zipped = keys.zip(values)
println(zipped)  // [(a, 1), (b, 2), (c, 3)]

Map operations

val inventory = mapOf("apple" to 50, "banana" to 30, "cherry" to 20)

// Access
println(inventory["apple"])           // 50
println(inventory.getOrDefault("mango", 0))  // 0
println(inventory.getOrElse("mango") { 0 })  // 0

// Iteration
for ((item, count) in inventory) {
    println("$item: $count")
}

// Filter and transform
val abundant = inventory.filter { (_, count) -> count > 25 }
println(abundant)  // {apple=50, banana=30}

val doubled = inventory.mapValues { (_, v) -> v * 2 }
println(doubled)  // {apple=100, banana=60, cherry=40}

// Convert to list of pairs
val pairs = inventory.toList()
val sorted = inventory.toSortedMap()

Lambdas and Functional Programming

// Lambda syntax
val square: (Int) -> Int = { x -> x * x }
val add: (Int, Int) -> Int = { a, b -> a + b }

// Single parameter: use 'it'
val isEven: (Int) -> Boolean = { it % 2 == 0 }

println(square(5))      // 25
println(add(3, 4))      // 7
println(isEven(6))      // true

// Function references
fun triple(x: Int) = x * 3
val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.map(::triple))  // [3, 6, 9, 12, 15]
println(numbers.map { it * 3 }) // same result

// Returning functions
fun multiplier(factor: Int): (Int) -> Int = { it * factor }
val double = multiplier(2)
val triple2 = multiplier(3)
println(double(5))   // 10
println(triple2(5))  // 15

Scope functions

Kotlin's scope functions run a block on an object:

Function Context Returns Use when
let it Lambda result Transform nullable, chain calls
run this Lambda result Object config + result
with this Lambda result Multiple operations on object
apply this Object itself Object initialization
also it Object itself Side effects (logging)
data class Person(var name: String = "", var age: Int = 0, var email: String = "")

// apply — configure an object
val alice = Person().apply {
    name = "Alice"
    age = 30
    email = "alice@example.com"
}
println(alice)

// let — transform or run on non-null
val result = "hello world"
    .let { it.split(" ") }
    .let { it.map { word -> word.capitalize() } }
    .let { it.joinToString(" ") }
println(result)  // Hello World

// also — side effects, returns original
val numbers = mutableListOf(1, 2, 3)
    .also { println("Before: $it") }  // Before: [1, 2, 3]
    .apply { add(4) }
    .also { println("After: $it") }   // After: [1, 2, 3, 4]

// with — multiple operations on an object
val html = with(StringBuilder()) {
    append("<html>")
    append("<body>Hello</body>")
    append("</html>")
    toString()
}
println(html)

// run — config + return result
val port = System.getenv("PORT")?.run { toInt() } ?: 8080

Generics

// Generic function
fun <T> swap(a: T, b: T): Pair<T, T> = Pair(b, a)

val swapped = swap(1, 2)
println(swapped)  // (2, 1)

// Generic class
class Box<T>(var value: T) {
    fun get(): T = value
    fun set(newValue: T) { value = newValue }
    override fun toString() = "Box($value)"
}

val intBox = Box(42)
val strBox = Box("Hello")
println(intBox)  // Box(42)

// Type constraints
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b

println(max(3, 7))      // 7
println(max("abc", "xyz"))  // xyz

// In / Out variance
interface Producer<out T> {
    fun produce(): T
}

interface Consumer<in T> {
    fun consume(item: T)
}

Coroutines

Kotlin coroutines make asynchronous programming simple — write async code that looks synchronous.

Setup

Add to build.gradle.kts:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}

Basic coroutines

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start")

    // launch — fire and forget
    val job = launch {
        delay(1000L)  // non-blocking delay
        println("Coroutine done!")
    }

    println("Waiting...")
    job.join()  // wait for job to complete
    println("End")
}
// Output:
// Start
// Waiting...
// (1 second pause)
// Coroutine done!
// End

async / await

import kotlinx.coroutines.*

suspend fun fetchUser(): String {
    delay(1000L)  // simulate network call
    return "Alice"
}

suspend fun fetchScore(): Int {
    delay(500L)
    return 95
}

fun main() = runBlocking {
    // Sequential — takes 1500ms
    val user = fetchUser()
    val score = fetchScore()

    // Parallel with async — takes ~1000ms (longest)
    val userDeferred = async { fetchUser() }
    val scoreDeferred = async { fetchScore() }

    val user2 = userDeferred.await()
    val score2 = scoreDeferred.await()
    println("$user2: $score2")
}

Suspend functions

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// suspend — can be paused without blocking a thread
suspend fun downloadFile(url: String): ByteArray {
    delay(2000L)  // simulate download
    return ByteArray(1024)
}

// Flow — cold stream of values
fun countFrom(start: Int): Flow<Int> = flow {
    for (i in start..start + 4) {
        delay(100L)
        emit(i)
    }
}

fun main() = runBlocking {
    // Collect flow values
    countFrom(1).collect { value ->
        println("Got: $value")
    }
    // Got: 1, Got: 2, Got: 3, Got: 4, Got: 5
}

Coroutine context and dispatchers

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Dispatchers.Default — CPU-intensive work
    launch(Dispatchers.Default) {
        val result = (1..1_000_000).sum()
        println("Sum: $result")
    }

    // Dispatchers.IO — network/disk operations
    launch(Dispatchers.IO) {
        // database query, HTTP request, file I/O
        println("IO work on: ${Thread.currentThread().name}")
    }

    // Dispatchers.Main — UI thread (Android)
    // launch(Dispatchers.Main) { updateUI() }
}

Error Handling

// try-catch-finally
fun divide(a: Int, b: Int): Int {
    return try {
        a / b
    } catch (e: ArithmeticException) {
        println("Error: ${e.message}")
        -1
    } finally {
        println("Always runs")
    }
}

println(divide(10, 2))  // 5
println(divide(10, 0))  // Error: / by zero, -1

// Custom exception
class ValidationException(message: String, val field: String) : Exception(message)

fun validateAge(age: Int) {
    if (age < 0 || age > 150) {
        throw ValidationException("Invalid age: $age", "age")
    }
}

try {
    validateAge(-5)
} catch (e: ValidationException) {
    println("Validation failed on ${e.field}: ${e.message}")
}

Result type pattern

fun parseIntSafely(s: String): Result<Int> {
    return try {
        Result.success(s.toInt())
    } catch (e: NumberFormatException) {
        Result.failure(e)
    }
}

val result = parseIntSafely("42")
result.fold(
    onSuccess = { println("Parsed: $it") },
    onFailure = { println("Failed: ${it.message}") }
)

// getOrDefault, getOrElse, getOrThrow
val num = parseIntSafely("abc").getOrDefault(0)
println(num)  // 0

File I/O

import java.io.File

// Write a file
File("output.txt").writeText("Hello, Kotlin!")

// Append to a file
File("output.txt").appendText("\nSecond line")

// Read entire file
val content = File("output.txt").readText()
println(content)

// Read lines
val lines = File("output.txt").readLines()
lines.forEachIndexed { i, line -> println("$i: $line") }

// Read line by line (memory-efficient for large files)
File("large.txt").forEachLine { line ->
    println(line)
}

// Check existence, create directories
val dir = File("data/reports")
dir.mkdirs()
println(dir.exists())  // true

// List files
File(".").listFiles()
    ?.filter { it.extension == "txt" }
    ?.forEach { println(it.name) }

Three Real Projects

Project 1: CLI Todo Manager

import java.io.File
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString

@Serializable
data class Todo(
    val id: Int,
    val text: String,
    var done: Boolean = false
)

object TodoManager {
    private val file = File("todos.json")
    private val json = Json { prettyPrint = true }

    private fun load(): MutableList<Todo> {
        if (!file.exists()) return mutableListOf()
        return json.decodeFromString(file.readText())
    }

    private fun save(todos: List<Todo>) {
        file.writeText(json.encodeToString(todos))
    }

    fun add(text: String) {
        val todos = load()
        val id = (todos.maxOfOrNull { it.id } ?: 0) + 1
        todos.add(Todo(id, text))
        save(todos)
        println("Added: [$id] $text")
    }

    fun list() {
        val todos = load()
        if (todos.isEmpty()) {
            println("No todos yet. Add one with: add <text>")
            return
        }
        todos.forEach { todo ->
            val status = if (todo.done) "✓" else "○"
            println("[$status] ${todo.id}. ${todo.text}")
        }
    }

    fun complete(id: Int) {
        val todos = load()
        val todo = todos.firstOrNull { it.id == id }
        if (todo == null) {
            println("Todo $id not found")
            return
        }
        todo.done = true
        save(todos)
        println("Completed: ${todo.text}")
    }

    fun delete(id: Int) {
        val todos = load()
        val removed = todos.removeIf { it.id == id }
        if (removed) {
            save(todos)
            println("Deleted todo $id")
        } else {
            println("Todo $id not found")
        }
    }
}

fun main(args: Array<String>) {
    when {
        args.isEmpty() -> TodoManager.list()
        args[0] == "add" && args.size > 1 -> TodoManager.add(args.drop(1).joinToString(" "))
        args[0] == "done" && args.size > 1 -> TodoManager.complete(args[1].toInt())
        args[0] == "delete" && args.size > 1 -> TodoManager.delete(args[1].toInt())
        args[0] == "list" -> TodoManager.list()
        else -> println("Usage: todo [list|add <text>|done <id>|delete <id>]")
    }
}
# Usage:
kotlin TodoCLI.kt add "Buy groceries"
kotlin TodoCLI.kt add "Write Kotlin tutorial"
kotlin TodoCLI.kt list
# [○] 1. Buy groceries
# [○] 2. Write Kotlin tutorial
kotlin TodoCLI.kt done 1
kotlin TodoCLI.kt list
# [✓] 1. Buy groceries
# [○] 2. Write Kotlin tutorial

Project 2: REST API with Ktor

// build.gradle.kts
dependencies {
    implementation("io.ktor:ktor-server-netty:2.3.11")
    implementation("io.ktor:ktor-server-content-negotiation:2.3.11")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
    implementation("ch.qos.logback:logback-classic:1.4.14")
}
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

@Serializable
data class Note(val id: Int, val title: String, val content: String)

@Serializable
data class CreateNoteRequest(val title: String, val content: String)

object NoteRepository {
    private val notes = ConcurrentHashMap<Int, Note>()
    private val counter = AtomicInteger(0)

    fun getAll() = notes.values.sortedBy { it.id }
    fun get(id: Int) = notes[id]
    fun create(req: CreateNoteRequest): Note {
        val note = Note(counter.incrementAndGet(), req.title, req.content)
        notes[note.id] = note
        return note
    }
    fun delete(id: Int) = notes.remove(id) != null
}

fun main() {
    embeddedServer(Netty, port = 8080) {
        install(ContentNegotiation) { json() }

        routing {
            get("/notes") {
                call.respond(NoteRepository.getAll())
            }

            get("/notes/{id}") {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
                val note = NoteRepository.get(id)
                    ?: return@get call.respond(HttpStatusCode.NotFound, "Note not found")
                call.respond(note)
            }

            post("/notes") {
                val req = call.receive<CreateNoteRequest>()
                val note = NoteRepository.create(req)
                call.respond(HttpStatusCode.Created, note)
            }

            delete("/notes/{id}") {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@delete call.respond(HttpStatusCode.BadRequest)
                if (NoteRepository.delete(id)) {
                    call.respond(HttpStatusCode.NoContent)
                } else {
                    call.respond(HttpStatusCode.NotFound)
                }
            }
        }
    }.start(wait = true)
}
# Test the API:
curl http://localhost:8080/notes
# []

curl -X POST http://localhost:8080/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"First note","content":"Hello Kotlin!"}'
# {"id":1,"title":"First note","content":"Hello Kotlin!"}

curl http://localhost:8080/notes/1
# {"id":1,"title":"First note","content":"Hello Kotlin!"}

curl -X DELETE http://localhost:8080/notes/1
# (204 No Content)

Project 3: Coroutines — Parallel Data Fetcher

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.net.URL
import kotlin.time.measureTime

data class StockPrice(val symbol: String, val price: Double)

// Simulate fetching stock price from an API
suspend fun fetchStockPrice(symbol: String): StockPrice {
    delay((200..800).random().toLong())  // simulate network latency
    val fakePrice = mapOf(
        "AAPL" to 182.50, "GOOGL" to 175.30, "MSFT" to 420.10,
        "AMZN" to 195.80, "TSLA" to 248.60
    )
    return StockPrice(symbol, fakePrice[symbol] ?: 0.0)
}

suspend fun fetchAllPrices(symbols: List<String>): List<StockPrice> = coroutineScope {
    symbols.map { symbol ->
        async { fetchStockPrice(symbol) }  // launch all concurrently
    }.awaitAll()  // wait for all to complete
}

fun portfolioValue(prices: List<StockPrice>, holdings: Map<String, Int>): Double {
    return prices.sumOf { stock ->
        stock.price * (holdings[stock.symbol] ?: 0)
    }
}

fun main() = runBlocking {
    val symbols = listOf("AAPL", "GOOGL", "MSFT", "AMZN", "TSLA")
    val holdings = mapOf("AAPL" to 10, "GOOGL" to 5, "MSFT" to 8, "AMZN" to 3, "TSLA" to 15)

    println("Fetching prices for ${symbols.size} stocks...")

    val elapsed = measureTime {
        val prices = fetchAllPrices(symbols)
        prices.forEach { println("${it.symbol}: \$${it.price}") }

        val total = portfolioValue(prices, holdings)
        println("\nPortfolio value: \$${String.format("%.2f", total)}")
    }

    println("Fetched in $elapsed (parallel)")
}

Kotlin vs Related Terms

Term Relationship
Kotlin Programming language targeting JVM, JS, Native
JVM Java Virtual Machine — Kotlin compiles to JVM bytecode
Java Kotlin is 100% interoperable with Java code
Android Google's preferred language for Android since 2017
Kotlin Multiplatform (KMP) Share business logic across Android, iOS, web
Coroutines Kotlin's structured concurrency library
Ktor Kotlin-native web framework by JetBrains
Exposed Kotlin SQL framework by JetBrains
Arrow Functional programming library for Kotlin
Gradle (Kotlin DSL) Build tool using Kotlin syntax (.kts files)

Learning Path

Stage Topics Timeframe
1. Foundations Variables, types, null safety, control flow 1-2 weeks
2. Functions Lambdas, extension functions, higher-order functions 1-2 weeks
3. OOP Classes, data classes, sealed classes, inheritance 2 weeks
4. Collections List/Map/Set operations, functional transformations 1 week
5. Coroutines suspend, launch, async, Flow 2-3 weeks
6. Choose path Android (Jetpack Compose) or Backend (Ktor/Spring Boot) Ongoing
7. KMP Share code across platforms Month 4+

Free resources:

Common Mistakes

Mistake Problem Fix
Using !! everywhere Runtime NPE — defeats null safety Use ?., ?:, or let
var instead of val Unnecessary mutability Default to val, use var only when needed
Copying Java style Verbose, un-idiomatic Use data classes, extension functions, scope functions
Ignoring data class Manual equals/hashCode for value objects Use data class for POJOs
Blocking threads in coroutines Defeats concurrency, causes deadlocks Use suspend + Dispatchers.IO for blocking calls
Not using sealed class Incomplete when branches Use sealed classes for exhaustive hierarchies
List vs MutableList confusion Mutating read-only lists (compile error) Use mutableListOf() when mutation is needed
Skipping coroutines Callback hell or blocking threads Learn coroutines early — they're central to Kotlin

Kotlin vs Java: Key Differences

// Java
String name = null;
if (name != null) {
    System.out.println(name.toUpperCase());
}

// Kotlin
val name: String? = null
println(name?.uppercase())  // null — safe, no NPE

// Java
Person person = new Person();
person.setName("Alice");
person.setAge(30);

// Kotlin
val person = Person(name = "Alice", age = 30)
// or with apply:
val person2 = Person().apply { name = "Alice"; age = 30 }

// Java
List<String> names = Arrays.asList("a", "b", "c");
List<String> upper = new ArrayList<>();
for (String s : names) {
    upper.add(s.toUpperCase());
}

// Kotlin
val names = listOf("a", "b", "c")
val upper = names.map { it.uppercase() }

Quick Reference

// Variables
val x = 10              // immutable
var y = 20              // mutable
val name: String = "hi" // explicit type

// Null safety
val s: String? = null
val len = s?.length ?: 0
val forced = s!!.length  // throws if null

// String templates
"Hello, $name — length ${name.length}"

// Functions
fun add(a: Int, b: Int) = a + b
val greet = { name: String -> "Hello, $name" }

// Collections
val list = listOf(1, 2, 3)
val map = mapOf("a" to 1, "b" to 2)
val set = setOf(1, 2, 3)
val mList = mutableListOf<Int>()

// Data class
data class Point(val x: Int, val y: Int)
val p = Point(1, 2)
val p2 = p.copy(y = 10)

// Sealed class
sealed class State
object Loading : State()
data class Success(val data: String) : State()
data class Error(val msg: String) : State()

// Coroutines
suspend fun fetch(): String { delay(100); return "data" }
runBlocking { println(fetch()) }

6 FAQ

Q: Should I learn Java before Kotlin? Not required. Kotlin is a great first JVM language. However, understanding Java helps when working with Java libraries (which Kotlin interops with seamlessly), so some Java exposure is beneficial over time.

Q: Is Kotlin only for Android? No. Kotlin is used for server-side development (Spring Boot, Ktor), Kotlin Multiplatform (iOS + Android + web from shared code), data engineering, and CLI tools. The fastest-growing area is backend and KMP.

Q: How does Kotlin compare to Swift? Both are modern, null-safe languages — Kotlin for Android/JVM, Swift for iOS/macOS. The syntax is similar in many ways. If you know one, learning the other is much easier. Kotlin Multiplatform lets you share business logic between the two.

Q: What's Kotlin Multiplatform? KMP lets you write shared business logic (networking, data models, repositories) in Kotlin and use it on Android, iOS (via Kotlin/Native), web (via Kotlin/JS), and server. Each platform still uses its native UI framework (Compose, SwiftUI, React). Gaining adoption at major companies (Netflix, VMware, Philips).

Q: How hard are coroutines to learn? The basic patterns (launch, async, delay) are easy — much simpler than Java threads. The advanced patterns (Flow, channels, structured concurrency) take a few weeks to internalize. Start with simple suspend functions and work up.

Q: Is Kotlin a good first programming language? Yes, especially if you're interested in Android development. Its null safety catches real bugs that beginners make constantly, and the syntax is clean and readable. Python might be slightly easier for pure beginners, but Kotlin produces production-ready applications from day one.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools