← notes

Kotlin Basics

Topics: functions · variables · string templates · classes · enums · when · smart casts · loops · exceptions


1. Functions

Syntax

fun max(a: Int, b: Int): Int {
    return if (a > b) a else b
}

Key points:

Expression body

When the body is a single expression, skip the braces and return:

fun max(a: Int, b: Int): Int = if (a > b) a else b

// Return type is optional. The compiler infers it from the expression:
fun max(a: Int, b: Int) = if (a > b) a else b

Type inference applies only to expression bodies. Functions with a block body that return a value must declare the return type explicitly.

Library authors: keep return types explicit. Inferred types can change silently and break consumers. The compiler's explicit API mode enforces this.

Control flow as expressions

if produces a value in Kotlin, so there is no ternary operator. when and try are expressions too; both are covered in detail in sections 6 and 9.

val x = if (myBoolean) 3 else 5

val direction = when (inputString) {
    "u" -> UP
    "d" -> DOWN
    else -> UNKNOWN
}

val number = try {
    inputString.toInt()
} catch (nfe: NumberFormatException) { -1 }

Assignments are not expressions. val b = i = getNumber() is a compile error. This eliminates the classic = versus == confusion.

main entry point

fun main() { /* no args */ }
fun main(args: Array<String>) { /* CLI args */ }

main returns nothing in either form.


2. Variables

val question: String = "The Answer"   // explicit type
val answer = 42                        // inferred as Int
var counter = 0                        // reassignable
Keyword Reassignable Java equivalent
val No final
var Yes regular field

Default to val. Switch to var only when mutation is required.

A val reference is read only, but the object it points to can be mutable:

val languages = mutableListOf("Java")
languages.add("Kotlin")   // fine. It mutates the list, not the reference

var locks the type, not just the name. After var answer = 42, assigning answer = "no answer" is a type mismatch error.

If you declare a variable without an initializer, you must state its type:

val answer: Int
answer = 42   // assigned later; still val

3. String Templates

Prefix a variable with $ to embed its value in a string literal:

val name = "Kotlin"
println("Hello, $name!")                     // Hello, Kotlin!
println("Hello, ${name.length}-letter name") // Hello, 6-letter name
println("Hello, ${if (name.isBlank()) "someone" else name}!")

Under the hood, templates compile to ordinary string concatenation; no template parsing happens at runtime.


4. Classes and Properties

Kotlin vs Java boilerplate

Java:

public class Person {
    private final String name;
    public Person(String name) { this.name = name; }
    public String getName() { return name; }
}

Kotlin:

class Person(val name: String)

public is the default visibility — no need to write it.

Properties

Declare with val for a read only property or var for a property that can be written:

class Person(
    val name: String,      // getter only
    var isStudent: Boolean // getter + setter
)

Kotlin generates the accessors automatically. From Java, name surfaces as getName(), and isStudent as isStudent() / setStudent() (the is prefix is kept for booleans).

From Kotlin, access properties directly — the compiler routes through the accessor:

val p = Person("Bob", true)
println(p.name)      // calls getName() under the hood
p.isStudent = false  // calls setStudent(false)

Custom accessors

Use a custom getter when a property is derived from others rather than stored:

class Rectangle(val height: Int, val width: Int) {
    val isSquare: Boolean
        get() = height == width   // computed on every access, no backing field
}

val r = Rectangle(41, 43)
println(r.isSquare)  // false

Rule of thumb: use a property for characteristics, a member function for behavior.

Packages and imports

A package is declared at the top of each file. Here, two files in different packages:

// geometry/shapes/Rectangle.kt
package geometry.shapes

class Rectangle(val height: Int, val width: Int) {
    val isSquare get() = height == width   // type inferred from the getter
}

fun createUnitSquare() = Rectangle(1, 1)
// geometry/example/Main.kt
package geometry.example

import geometry.shapes.Rectangle
import geometry.shapes.createUnitSquare   // functions import the same way as classes

fun main() {
    println(Rectangle(3, 4).isSquare)     // false
    println(createUnitSquare().isSquare)  // true
}

A wildcard import brings in every declaration from the package, and can replace the two explicit imports above:

import geometry.shapes.*

Unlike Java, Kotlin doesn't require the directory structure to mirror the package hierarchy, though following the convention helps in projects that mix languages.


5. Enums

enum class Color {
    RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET
}

enum is a modifier keyword: it only acts as a keyword before class, and can still be used as an identifier elsewhere. class is a hard keyword and can never be an identifier.

Enums can carry properties and methods:

enum class Color(val r: Int, val g: Int, val b: Int) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);                    // semicolon required before methods

    val rgb = (r * 256 + g) * 256 + b
    fun printColor() = println("$this is $rgb")
}

Color.GREEN.printColor()  // GREEN is 65280

The semicolon after the last constant is required whenever the enum body also declares properties or methods. It is the one place Kotlin needs a semicolon in ordinary code spanning multiple lines. You would also need one to put two statements on the same line.


6. when

when is Kotlin's replacement for switch. It is an expression, so it returns a value.

Matching enum constants

fun getMnemonic(color: Color) = when (color) {
    Color.RED    -> "Richard"
    Color.ORANGE -> "Of"
    Color.YELLOW -> "York"
    Color.GREEN  -> "Gave"
    Color.BLUE   -> "Battle"
    Color.INDIGO -> "In"
    Color.VIOLET -> "Vain"
}

No break is needed. Only the matched branch executes.

Note: the examples below write bare constant names (RED) instead of Color.RED. That requires import Color.* at the top of the file; otherwise qualify each constant.

Combining branches

fun getWarmth(color: Color) = when (color) {
    RED, ORANGE, YELLOW  -> "warm"
    GREEN                -> "neutral"
    BLUE, INDIGO, VIOLET -> "cold"
}

Capturing the subject in a variable

fun getWarmthFromSensor() =
    when (val color = measureColor()) {          // color scoped to this when block
        RED, ORANGE, YELLOW  -> "warm (red = ${color.r})"
        GREEN                -> "neutral (green = ${color.g})"
        BLUE, INDIGO, VIOLET -> "cold (blue = ${color.b})"
    }

Matching arbitrary objects

when checks branches for equality, so any object works:

fun mix(c1: Color, c2: Color) =
    when (setOf(c1, c2)) {
        setOf(RED, YELLOW)  -> ORANGE
        setOf(YELLOW, BLUE) -> GREEN
        setOf(BLUE, VIOLET) -> INDIGO
        else -> throw IllegalArgumentException("Dirty color")
    }

setOf(c1, c2) and setOf(RED, YELLOW) are equal regardless of order, because sets ignore order. The else branch is required here because the compiler can't verify exhaustiveness for arbitrary set combinations.

when without argument (boolean branches)

The version using sets allocates a Set on each call. If the function is on a hot path:

fun mixOptimized(c1: Color, c2: Color) =
    when {
        (c1 == RED && c2 == YELLOW) || (c1 == YELLOW && c2 == RED) -> ORANGE
        (c1 == YELLOW && c2 == BLUE) || (c1 == BLUE && c2 == YELLOW) -> GREEN
        (c1 == BLUE && c2 == VIOLET) || (c1 == VIOLET && c2 == BLUE) -> INDIGO
        else -> throw IllegalArgumentException("Dirty color")
    }

No argument means each branch is an arbitrary boolean expression. No objects allocated.

Branches can also be blocks; see Block branches in the next section, where the Expr example they rely on is introduced.


7. Smart Casts

After an is check, the compiler automatically casts the variable. No explicit as is needed.

Example: expression evaluator

interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

Sum(Sum(Num(1), Num(2)), Num(4)) encodes (1 + 2) + 4.

Without smart cast (Java style)

fun eval(e: Expr): Int {
    if (e is Num) {
        val n = e as Num   // explicit cast; redundant
        return n.value
    }
    if (e is Sum) {
        val s = e as Sum   // explicit cast; redundant
        return eval(s.left) + eval(s.right)
    }
    throw IllegalArgumentException("Unknown expression")
}

The compiler already knows the type after each is check, so the casts add nothing.

Idiomatic Kotlin: when + smart cast

fun eval(e: Expr): Int =
    when (e) {
        is Num -> e.value                        // e is Num here
        is Sum -> eval(e.left) + eval(e.right)   // e is Sum here
        else   -> throw IllegalArgumentException("Unknown expr")
    }

Block branches

When a when branch needs more than one statement, use a block. The last expression in the block is the branch's result:

fun evalWithLogging(e: Expr): Int =
    when (e) {
        is Num -> {
            println("num: ${e.value}")
            e.value              // returned
        }
        is Sum -> {
            val left = evalWithLogging(e.left)
            val right = evalWithLogging(e.right)
            println("sum: $left + $right")
            left + right         // returned
        }
        else -> throw IllegalArgumentException("Unknown expression")
    }

Smart cast requirements

The variable must be a val with no custom accessor — the compiler must guarantee the value can't change between the check and the use.

Explicit cast with as

val n = e as Num   // throws ClassCastException if e is not a Num

8. Loops

while and do-while

while (condition) {
    if (shouldExit) break
}

do {
    if (shouldSkip) continue
} while (condition)

Labeled break/continue for nested loops:

outer@ while (outerCondition) {
    while (innerCondition) {
        if (shouldExit)     break@outer      // exits outer loop
        if (shouldSkipOuter) continue@outer  // jumps to next outer iteration
    }
}

for and ranges

Kotlin has no for loop in the C style. Use ranges instead:

for (i in 1..10)       { }   // 1 to 10 inclusive
for (i in 1..<10)      { }   // 1 to 9 (upper bound excluded)
for (i in 10 downTo 1) { }   // 10, 9, ... 1
for (i in 100 downTo 1 step 2) { }   // 100, 98, 96, ...

Version note: the range operator ..<, which excludes the upper bound, was introduced as an experimental preview in Kotlin 1.7.20 and became stable in Kotlin 1.9.0. On older codebases, or if you see build errors with ..<, use the equivalent until function: for (i in 1 until 10) { } produces the same sequence from 1 through 9 and has been available since Kotlin 1.0.

Ranges work for characters too:

for (c in 'A'..'F') { /* A B C D E F */ }

Iterating over collections and maps

val list = listOf("red", "green", "blue")
for (color in list) { print("$color ") }

// Map destructuring
val binaryReps = mutableMapOf<Char, String>()
for (c in 'A'..'F') {
    binaryReps[c] = c.code.toString(radix = 2)
}
for ((letter, binary) in binaryReps) {
    println("$letter = $binary")
}

// Index + element
for ((index, element) in list.withIndex()) {
    println("$index: $element")
}

Map access: use map[key] to read and map[key] = value to write. No get or put calls are needed.

in membership check

fun isLetter(c: Char)  = c in 'a'..'z' || c in 'A'..'Z'
fun isNotDigit(c: Char) = c !in '0'..'9'

// c in 'a'..'z'  compiles to  'a' <= c && c <= 'z'

in works in when branches:

fun recognize(c: Char) = when (c) {
    in '0'..'9'              -> "digit"
    in 'a'..'z', in 'A'..'Z' -> "letter"
    else                     -> "other"
}

in also applies to any Comparable:

println("Kotlin" in "Java".."Scala")         // true, using lexicographic comparison
println("Kotlin" in setOf("Java", "Scala"))  // false, because this checks set membership

Caveat: string ranges compare lexicographically using String.compareTo, which is case sensitive and compares UTF-16 code units one by one. This is not dictionary ordering. "Kotlin" in "Java".."Scala" works here because 'J' < 'K' < 'S' holds for the first character, but results can surprise you once case differs: "apple" in "Apple".."Banana" is false, because every uppercase ASCII letter sorts before every lowercase one. Do not rely on string ranges for anything intended for users, such as alphabetizing names, without normalizing case first.


9. Exceptions

Throwing

if (percentage !in 0..100) {
    throw IllegalArgumentException(
        "A percentage value must be between 0 and 100: $percentage"
    )
}

No new keyword. throw is an expression:

val percentage =
    if (number in 0..100) number
    else throw IllegalArgumentException("Invalid: $number")

try / catch / finally

fun readNumber(reader: BufferedReader): Int? {
    try {
        val line = reader.readLine()
        return Integer.parseInt(line)
    } catch (e: NumberFormatException) {   // type annotation on the right
        return null
    } finally {
        reader.close()
    }
}

Int? is a nullable type: the function returns either an Int or null. Here null signals that the input wasn't a number.

Kotlin has no checked exceptions. There is no throws clause on function signatures. You decide which exceptions to handle; the compiler never forces you.

Rationale: Java's checked exceptions often led to boilerplate rethrows and empty catch blocks without improving reliability.

try as an expression

fun readNumber(reader: BufferedReader) {
    val number = try {
        Integer.parseInt(reader.readLine())   // value if no exception
    } catch (e: NumberFormatException) {
        null                                  // value if exception caught
    }
    println(number)
}

The value of the try expression is the last expression in the try block (normal path) or in the matching catch block (exception path). Unlike if/when, curly braces around the try body are always required.


Summary

Feature Key point
fun Top level, no class needed; an expression body (=) allows the return type to be inferred
val / var Prefer val; type is fixed at declaration, not reassignment
String templates $name, ${expression}. Checked at compile time
Properties Replace fields + getters/setters; val is read only, var is mutable
Custom getter Computed on access via get() = …, no backing field needed
Packages / imports Functions and classes import the same way; directories needn't mirror packages
Enums Can carry properties and methods; semicolon required after the last constant when a body follows
when Expression; matches constants, types, sets, or booleans
Smart cast After is check, no explicit as required
Loops / ranges No C style for; 1..10 (closed), 1..<10 / 1 until 10 (upper bound excluded), downTo, step
in Membership check in ranges, collections, any Comparable (string ranges are case sensitive)
Exceptions No checked exceptions; throw and try are expressions