Story Opening

Kabir’s first Kotlin pull request formatted a price in paise as rupees for the new catalog service. He wrote it the way he had written that kind of thing for fourteen years:

// Fragment of story/PriceUtils.kt
class PriceUtils {
companion object {
@JvmStatic
fun format(paise: Long): String {
var result: String;
if (paise < 0) {
result = "-";
} else {
result = "₹" + paise / 100 + "." + String.format("%02d", paise % 100);
}
return result;
}
}
}

It compiled, semicolons and all, and the tests passed. Lena didn’t leave a comment. She pushed a commit to his branch: the class deleted, and a new file, PriceFormat.kt, holding two lines:

// Fragment of toplevel/PriceFormat.kt
fun formatPrice(paise: Long): String =
if (paise < 0) "-" else "₹${paise / 100}.${(paise % 100).toString().padStart(2, '0')}"

An hour later, profiling the label job, Kabir saw the hot frame: com.shelfwise.pricing.PriceFormatKt.formatPrice(PriceFormat.kt:6). He had never written a class called PriceFormatKt.

The syntax wasn’t what tripped Kabir up. Two habits did: putting every function inside a class, and treating if as a statement. This part replaces both.


Java → Kotlin: The Quick Map

JavaKotlinNote
public static void main(String[] args)fun main()Top level, in any file, importable
Static utility classTop-level functionCompiled into a class named FileNameKt
final var total = 1;val total = 1A final reference, not an immutable object
int / IntegerInt / Int?One type; the compiler picks the representation
long l = i; (widening)val l = i.toLong()No implicit numeric conversions at all
static final int MAX = 24;const val MAX = 24Compile-time constants only
"₹" + rupees, String.format"₹$rupees", "${a + b}"String templates
Text block """Raw string """ + trimIndent()No escapes, and no automatic indentation stripping
Overloads, buildersDefault and named argumentsOne declaration
cond ? a : bif (cond) a else bif is an expression
Arrow-form switchwhenAlso takes ranges, in, conditions; exhaustive even as a statement over enums
for (int i = 0; i < n; i++)for (i in 0..<n)Ranges and progressions
voidUnitA real type with one value
import staticimportOne kind of import, plus as aliases

Conceptual Deep-Dive

Shift 1: files and functions, not classes

In Java, a reusable function must live in a class. JDK 25 relaxed this for one case: a compact source file can declare void main() with no class around it. But that implicit class can’t be named or imported, so it’s for scripts and teaching, not for an API. A formatPrice that other code calls still needs a PriceUtils around it.

Kotlin drops that rule. Functions, properties and constants can be declared directly in a package, and they are imported by name like anything else (import com.shelfwise.pricing.formatPrice). The JVM still needs a class, so the compiler generates one per file and names it after the file: PriceFormat.kt becomes PriceFormatKt. That was the class in Kabir’s profiler.

The unit of organisation becomes the package. PriceUtils, StringHelper and DateUtil exist in Java only because Java had nowhere else to put a function. In Kotlin, a class has to earn its place with state or behaviour.

Shift 2: expressions, not statements

Kabir’s var result: String;, assigned on two branches and returned at the end, is the shape Java’s statements force on you: to get a value out of an if, you assign to a variable declared beforehand. Java 14’s switch expressions fixed this for switch, but not for if or try.

In Kotlin, if, when and try are all expressions. Lena’s commit is the result: the if is the function body, both branches produce the value, and no variable is ever unset. That’s why Kotlin has no ternary operator, and why idiomatic Kotlin is mostly val: when every branch produces a value, nothing needs to be reassigned.

Shift 3: types describe values; the compiler picks the representation

Java has two parallel type systems, primitives (int) and objects (Integer), joined by autoboxing and widening rules. Kotlin has one. Int, Long and Double are ordinary types with methods. The compiler emits a JVM primitive wherever it can, and a box only where the JVM forces one: for nullable values and generic type arguments.

Because Int and Long are now simply different types, nothing converts between them implicitly, any more than Java silently turns a String into a StringBuilder.

Where kotlinc fits in the build

kotlinc runs before javac and reads the Java sources too, so in a mixed module each language can call the other (Part 12). K2, the rewritten compiler frontend, has been the default since Kotlin 2.0 and is much faster than the old one, but a Kotlin build is still slower than an equivalent javac build. The output is ordinary JVM bytecode plus a dependency on kotlin-stdlib.


Technical Explanation

The build

The smallest useful Kotlin/JVM project is two files. This is the companion repository’s standalone/ build, verbatim:

plugins {
// Lets Gradle download a JDK that matches jvmToolchain(...) if none is installed.
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "shelf-labels"
plugins {
kotlin("jvm") version "2.4.20" // the Kotlin Gradle plugin; adds kotlin-stdlib automatically
application
}
group = "com.shelfwise"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test")) // kotlin.test on top of JUnit
}
kotlin {
jvmToolchain(25) // compile and run on JDK 25 (installed or downloaded), whatever JDK launched Gradle
}
application {
mainClass = "com.shelfwise.MainKt" // the class the compiler generates for Main.kt
}
tasks.test {
useJUnitPlatform()
}
  • kotlin("jvm") is shorthand for the plugin org.jetbrains.kotlin.jvm. It adds kotlin-stdlib to every source set, at the plugin’s own version, so you never declare it.
  • jvmToolchain(25) picks the JDK for both kotlinc and javac, and aligns their bytecode targets. With the foojay resolver in settings.gradle.kts, Gradle downloads that JDK if it isn’t installed.
  • build.gradle.kts is Kotlin, with type checking and IDE completion in the build script. Sources go in src/main/kotlin, and Java files can sit alongside them in src/main/java.

The companion repository’s own modules get the same settings from a version catalog and a small convention plugin, so each part’s build file is three lines. Part 12 covers that setup.

Top-level functions and the FileNameKt class

// No class, no static: a function can live directly in a package.
// The compiler puts it in a class named after the file: PriceFormatKt.
fun formatPrice(paise: Long): String =
if (paise < 0) "-" else "₹${paise / 100}.${(paise % 100).toString().padStart(2, '0')}"
fun main() {
println(formatPrice(19_900)) // -> ₹199.00
println(formatPrice(4_550)) // -> ₹45.50
println(formatPrice(-1)) // -> -
}

javap -p on the compiled class shows exactly what Java would see:

public final class com.shelfwise.part01.toplevel.PriceFormatKt {
public static final java.lang.String formatPrice(long);
public static final void main();
public static void main(java.lang.String[]);
}
  • Top-level functions become public static final methods. Java calls them as PriceFormatKt.formatPrice(19900L).
  • fun main() with no parameters gets a synthetic main(String[]) bridge, so the JVM launcher still finds it.
  • The class is final and can’t be instantiated. It is a namespace, not a type.

Since Java callers see the file name, renaming PriceFormat.kt silently renames the class Java depends on. Pin the name with a file annotation, which must come before package:

@file:JvmName("ShelfPrices") // Java callers see ShelfPrices.perKilo(...), not ShelfPricesKt
package com.shelfwise.part01.toplevel
fun perKilo(paise: Long, grams: Int): Long = paise * 1_000 / grams
fun main() {
println(perKilo(paise = 8_990, grams = 500)) // -> 17980
}

val, var and inference

val is Java’s final, not immutability. val aisles = mutableListOf("Dairy") can’t be pointed at another list, but aisles += "Frozen" still changes the one it has. Immutability comes from the type (List vs MutableList, Part 7). Inference works like Java’s var, but it reaches further: locals, properties and expression-bodied return types. The type is still fixed at compile time. Reassigning a val fails with 'val' cannot be reassigned., and stock = "ten" on an Int fails with assignment type mismatch: actual type is 'String', but 'Int' was expected.

No primitives in source, plenty in bytecode

fun main() {
val unitsSold: Int = 1_250
val paisePerUnit = 19_900L // Long literal
// No implicit widening, not even Int to Long:
// val revenue: Long = unitsSold // error: initializer type mismatch: expected 'Long', actual 'Int'.
val revenue: Long = unitsSold.toLong() * paisePerUnit
println(revenue) // -> 24875000
// Arithmetic across types is fine: Int.times(Long) is an overload that returns Long.
println(unitsSold * paisePerUnit) // -> 24875000
// Equality across types is not:
// println(unitsSold == 1_250L) // error: operator '==' cannot be applied to 'Int' and 'Long'.
println(unitsSold.toLong() == 1_250L) // -> true
// Char is not a number.
val grade = 'A'
// val code: Int = grade // error: initializer type mismatch: expected 'Int', actual 'Char'.
println(grade.code) // -> 65
println(grade + 1) // -> B
// The JVM is still underneath: same overflow, same integer division.
println(Int.MAX_VALUE + 1) // -> -2147483648
println(7 / 2) // -> 3
}

Literals follow the same rule: val price: Double = 199 fails (expected 'Double', actual 'Int'), so write 199.0.

So where do the primitives go? Look at a signature:

// Fragment of types/Boxing.kt
// Inspect with: ./gradlew :language:part01-life-after-semicolons:javap -Pclass=com.shelfwise.part01.types.BoxingKt
fun restock(onShelf: Int, incoming: Int?, history: List<Int>): Int =
onShelf + (incoming ?: 0) + history.size
public static final int restock(int, java.lang.Integer, java.util.List<java.lang.Integer>);

Int became int. Int?, which can hold null, became Integer, because only an object can be null. List<Int> became List<Integer>, because JVM generics need objects. These are Java’s boxing rules. The difference is that the compiler applies them from the type you wrote, instead of you choosing int or Integer. (?: means “if null, use this”. Part 2 covers it.)

Strings: templates and raw strings

fun main() {
val name = "Basmati Rice 5kg"
val pricePaise = 89_900L
// $name for a simple name, ${...} for any expression.
println("$name costs ₹${pricePaise / 100}") // -> Basmati Rice 5kg costs ₹899
// Raw strings: no escaping, real newlines. trimMargin() strips everything up to '|'.
val label = """
|SHELFWISE
|$name
|₹${pricePaise / 100}
""".trimMargin()
println(label)
// -> SHELFWISE
// -> Basmati Rice 5kg
// -> ₹899
// Raw strings have no escapes, so a literal "$schema" is read as a template:
// val broken = """{"$schema": "..."}""" // error: unresolved reference 'schema'.
}

Templates compile to the same invokedynamic makeConcatWithConstants call that javac has emitted for + since Java 9, so there is no performance reason to avoid them.

Raw strings differ from Java text blocks in two ways: they process no escape sequences ("""\n""" is a backslash and an n), and they keep their indentation unless you trim it (see the Gotchas).

That leaves $ as the only special character, which collides with JSON Schema’s $schema, Jackson’s $type and GraphQL variables. Multi-dollar interpolation (Stable since Kotlin 2.2) fixes it: prefix the literal with $$ and only $$ starts a template. Step 5 of the hands-on uses it.

Functions: expression bodies, defaults and named arguments

// One function with defaults replaces a telescope of Java overloads.
fun discounted(pricePaise: Long, percent: Int = 10, roundToRupee: Boolean = false): Long {
val raw = pricePaise * (100 - percent) / 100
return if (roundToRupee) raw / 100 * 100 else raw
}
// Expression body: '=' instead of braces and return. The return type (Unit) is inferred.
fun log(message: String) = println("[labels] $message")
fun main() {
println(discounted(19_900)) // -> 17910
println(discounted(19_900, percent = 25)) // -> 14925
println(discounted(19_900, roundToRupee = true)) // -> 17900
println(discounted(roundToRupee = true, percent = 5, pricePaise = 19_900)) // -> 18900
// discounted(percent = 5, 19_900) // error: mixing named and positional arguments is not allowed unless the order of the arguments matches the order of the parameters.
// Unit is a real object, not a keyword like void.
val result: Unit = log("printed") // -> [labels] printed
println(result) // -> kotlin.Unit
}

Defaults and named arguments replace overload telescopes and most builders for plain parameter lists. discounted(price, roundToRupee = true) reads like a builder call, with no builder class. The compiler emits one extra method, not one per combination:

public static final long discounted(long, int, boolean);
public static long discounted$default(long, int, boolean, int, java.lang.Object); // ACC_SYNTHETIC

Kotlin call sites that leave out an argument call discounted$default, passing a bitmask of the missing ones. Complete calls, even with named arguments in a different order, go straight to discounted. The $default method is synthetic, so javac can’t call it, and Java code sees only the three-argument version. Part 12 shows @JvmOverloads, which generates real overloads for Java.

Unit is Kotlin’s void, but as a real type with one value it can be a generic argument. A () -> Unit function type needs none of the Callable<Void> / return null workarounds. A function returning Unit still compiles to a void method.

if, when and try as expressions

// when without a subject: an if/else-if chain that produces a value.
fun stockBand(units: Int): String = when {
units == 0 -> "OUT"
units < 10 -> "LOW"
else -> "OK"
}
// when with a subject: constants, several values per branch, ranges and collections.
fun aisleFor(category: String): Int = when (category) {
"Dairy", "Eggs" -> 1
"Bakery" -> 2
in setOf("Frozen", "Ice Cream") -> 7
else -> 99
}
// Type checks smart-cast the subject inside the branch: no explicit cast needed.
fun describe(value: Any): String = when (value) {
is String -> "text of length ${value.length}"
is Int -> "number ${value + 1}"
else -> "something else"
}
// Guard conditions (Stable since Kotlin 2.2): Java 21's 'case Integer i when i < 0'.
fun quantityLabel(value: Any): String = when (value) {
is Int if value < 0 -> "invalid quantity"
is Int -> "$value units"
else -> "unknown"
}
// try is an expression too: its value is the last expression of try or of the catch that ran.
fun parseQuantity(raw: String): Int = try {
raw.trim().toInt()
} catch (e: NumberFormatException) {
0
}
fun main() {
val units = 4
val shelfMessage = if (units > 0) "In stock" else "Sold out" // no ?: ternary needed
println(shelfMessage) // -> In stock
println(stockBand(0)) // -> OUT
println(stockBand(units)) // -> LOW
println(aisleFor("Eggs")) // -> 1
println(aisleFor("Ice Cream")) // -> 7
println(describe("Atta")) // -> text of length 4
println(describe(41)) // -> number 42
println(quantityLabel(-3)) // -> invalid quantity
println(quantityLabel(12)) // -> 12 units
println(parseQuantity(" 12 ")) // -> 12
println(parseQuantity("twelve")) // -> 0
// val label = if (units > 0) "on" // error: 'if' must have both main and 'else' branches when used as an expression.
}

The rules that differ from Java:

  • No fall-through, no break. The first matching branch wins, so order matters, as in stockBand.
  • Branch conditions are not just constants. Ranges (in 1..9), collections, type checks with smart casts, guards (if …), and arbitrary boolean conditions in the subject-less form are all allowed.
  • As an expression, when must be exhaustive. That means an else, unless the subject is an enum, a sealed type or a Boolean and every case is listed. A when statement over those subjects must be exhaustive too (see the Gotchas).
  • try produces a value: the last expression of the try block, or of the catch that ran. A finally block runs, but its value is ignored.

Under the hood, a when made only of type checks compiles to the same machinery as Java 21’s pattern-matching switch when the JVM target is 21 or later. This is Stable and on by default since Kotlin 2.4.20:

public static final java.lang.String describe(java.lang.Object);
Code:
8: invokedynamic #72, 0 // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
13: tableswitch { 0: 36 1: 51 default: 68 }
36: aload_0
37: checkcast #17 // class java/lang/String
40: invokevirtual #76 // Method java/lang/String.length:()I
...

The smart cast in the is String branch is just a checkcast the compiler inserts for you. Add a guard, as in quantityLabel, and 2.4.20 falls back to a plain chain of instanceof checks. The semantics are identical; only the bytecode shape changes.

Ranges, progressions and loops

fun main() {
// 1..5 includes both ends; 0..<5 excludes the end (the Java for-loop shape).
println((1..5).toList()) // -> [1, 2, 3, 4, 5]
println((0..<5).toList()) // -> [0, 1, 2, 3, 4]
// Counting down needs downTo: a range whose start is above its end is simply empty.
println((5..1).toList()) // -> []
println((10 downTo 0 step 5).toList()) // -> [10, 5, 0]
// 'in' works on any range, including chars, and inside if and when.
println(7 in 1..10) // -> true
println('q' !in 'a'..'m') // -> true
val aisles = listOf("Dairy", "Bakery", "Frozen")
val numbered = mutableListOf<String>()
for ((index, aisle) in aisles.withIndex()) {
numbered += "${index + 1}:$aisle"
}
println(numbered) // -> [1:Dairy, 2:Bakery, 3:Frozen]
}

Kotlin has no C-style for (init; condition; step). A for loop iterates over anything with an iterator(): ranges, collections, strings, arrays. withIndex() provides the index, and (index, aisle) unpacks each pair (destructuring, Part 4).

Ranges look like they allocate. When the range is written in the for header, they don’t:

// A range written in the for header compiles to a plain int counter: no IntRange is allocated.
fun totalUnits(perShelf: IntArray): Int {
var total = 0
for (i in perShelf.indices) total += perShelf[i]
return total
}
fun main() {
println(totalUnits(intArrayOf(12, 0, 7))) // -> 19
// repeat(n) when you need a count but not an index.
val chimes = StringBuilder()
repeat(3) { chimes.append("ding ") }
println(chimes.trim()) // -> ding ding ding
}
public static final int totalUnits(int[]);
Code:
8: iconst_0
9: istore_2 // i = 0
10: aload_0
11: arraylength
12: istore_3 // end = perShelf.length
13: iload_2
14: iload_3
15: if_icmpge 30 // i >= end ? exit
...
24: iinc 2, 1 // i++
27: goto 13

That is the loop javac generates for for (int i = 0; i < perShelf.length; i++). repeat(3) { … } compiles to the same kind of counter, because repeat is an inline function (Part 5). The .toList() calls in the previous example do create range objects, because there the range is a value.

while, do/while, break and continue behave as in Java. Labels exist too, with reversed syntax: rows@ for (…) to declare, break@rows to jump. The hands-on uses one. They become far more important in Part 5, where return@forEach decides whether a return inside a lambda exits the lambda or the whole function.

Packages, imports and constants

There is one import for everything: classes, top-level functions and object members (what import static was for). import java.sql.Date as SqlDate resolves a clash without fully qualified names. Several packages are imported by default, among them kotlin.*, kotlin.collections.*, kotlin.text.* and java.lang.*, which is why listOf and println need no import. The package doesn’t have to match the directory, but keep them aligned.

Top-level constants come in two kinds:

// const val: a compile-time constant (primitives and String only), inlined at every use site.
const val MAX_LABELS_PER_SHELF = 24
// Plain top-level val: computed when the file's class is initialised, read through a getter.
val PILOT_REGIONS = listOf("Pune", "Bengaluru")
// const val PILOT = listOf("Pune") // error: const 'val' has type 'List<String>'. Only primitive types and 'String' are allowed.
fun main() {
println(MAX_LABELS_PER_SHELF * 6) // -> 144
println(PILOT_REGIONS.size) // -> 2
}
public final class com.shelfwise.part01.constants.ConstantsKt {
public static final int MAX_LABELS_PER_SHELF;
private static final java.util.List<java.lang.String> PILOT_REGIONS;
public static final java.util.List<java.lang.String> getPILOT_REGIONS();
...
}

const val is exactly Java’s static final compile-time constant: usable in annotation arguments, and inlined into callers, so changing it means recompiling them. A plain val is a private field behind a getter. Part 3 builds on that: every Kotlin property is a pair of accessors, usually but not always backed by a field.


Step-by-Step Hands-On: Shelf Labels for Aisle Seven

Code: kotlin-for-java-survivors/language/part01-life-after-semicolons (file labels/ShelfLabels.kt). Run it with ./gradlew :language:part01-life-after-semicolons:test, or from the gutter icon next to main in IntelliJ.

The Pune pilot store needs printed shelf-edge labels: name, price and a badge for low stock or fresh bakery items, laid out shelf by shelf. Kabir writes it as top-level functions in one file.

Step 1 — A product and a price formatter. The class gets one line of explanation now and a full part later:

// Fragment of labels/ShelfLabels.kt
// Part 3 explains this line in full: a class whose constructor declares read-only properties.
class Product(val name: String, val pricePaise: Long, val unitsOnShelf: Int, val category: String)
const val STORE_CODE = "PUN-014"
// Step 1: one formatting function with a default instead of overloads.
fun formatPrice(paise: Long, currency: String = "₹"): String =
"$currency${paise / 100}.${(paise % 100).toString().padStart(2, '0')}"

Step 2 — Badge rules as one when. The branch order is the business rule: a sold-out croissant shows SOLD OUT, not FRESH TODAY.

// Fragment of labels/ShelfLabels.kt
// Step 2: the badge rules as a single when expression. First matching branch wins.
fun badgeFor(product: Product): String = when {
product.unitsOnShelf == 0 -> "SOLD OUT"
product.unitsOnShelf < 10 -> "LAST FEW"
product.category == "Bakery" -> "FRESH TODAY"
else -> ""
}

Step 3 — One label line. A template does the layout, and padEnd/padStart line the prices up:

// Fragment of labels/ShelfLabels.kt
// Step 3: one label line, padded so prices line up on the shelf edge.
fun renderLabel(product: Product, nameWidth: Int = 20): String {
val name = product.name.padEnd(nameWidth)
val price = formatPrice(product.pricePaise).padStart(9)
return "$name$price ${badgeFor(product)}".trimEnd()
}

Step 4 — Fill the shelves. Two nested ranges walk the slots. A labelled break leaves both loops as soon as the products run out:

// Fragment of labels/ShelfLabels.kt
// Step 4: fill shelves slot by slot; a labelled break stops both loops when products run out.
fun planAisle(products: List<Product>, shelves: Int, slotsPerShelf: Int): List<String> {
val plan = mutableListOf<String>()
var next = 0
shelves@ for (shelf in 1..shelves) {
for (slot in 1..slotsPerShelf) {
if (next == products.size) break@shelves
plan += "S$shelf/$slot ${renderLabel(products[next])}"
next++
}
}
val unplaced = products.size - next
if (unplaced > 0) plan += "No slot for $unplaced product(s)"
return plan
}

Step 5 — The printer payload. The label printer takes JSON with a literal "$type" key. In a $$ raw string, $type stays text and $$STORE_CODE is a template:

// Fragment of labels/ShelfLabels.kt
// Step 5: the label printer speaks JSON with a literal "$type" key, so use a $$ raw string.
fun printerPayload(product: Product): String =
$$"""{"$type": "shelf-label", "store": "$$STORE_CODE", "name": "$${product.name}", "price": "$${formatPrice(product.pricePaise)}"}"""

Run it:

// Fragment of labels/ShelfLabels.kt
fun main() {
val aisleSeven = listOf(
Product("Salted Butter 500g", 28_500, 42, "Dairy"),
Product("Sourdough Loaf", 18_000, 12, "Bakery"),
Product("Paneer 200g", 9_500, 6, "Dairy"),
Product("Greek Yoghurt 400g", 7_000, 0, "Dairy"),
Product("Masala Oats 1kg", 32_000, 20, "Breakfast"),
)
for (line in planAisle(aisleSeven, shelves = 2, slotsPerShelf = 2)) {
println(line)
}
// -> S1/1 Salted Butter 500g ₹285.00
// -> S1/2 Sourdough Loaf ₹180.00 FRESH TODAY
// -> S2/1 Paneer 200g ₹95.00 LAST FEW
// -> S2/2 Greek Yoghurt 400g ₹70.00 SOLD OUT
// -> No slot for 1 product(s)
println(printerPayload(aisleSeven[1]))
// -> {"$type": "shelf-label", "store": "PUN-014", "name": "Sourdough Loaf", "price": "₹180.00"}
}

Apart from the one-line data holder there is no class in the file, and the only var is the slot counter. The rules live in small functions (badgeFor, renderLabel) that you can test without setting anything up.


Tips, Tricks & Gotchas

Gotcha — no implicit widening, anywhere. val total: Long = count and count == 3L don’t compile when count is an Int. Convert with toLong(). Arithmetic still works across types through operator overloads.

Gotcha — a when statement must be exhaustive over enums. A Java switch statement over an enum can quietly skip constants. Kotlin’s when can’t, even when it’s a statement whose value nobody uses. Over an enum, sealed type or Boolean subject, every case must be listed or an else added. The error message still says “expression”:

enum class Band { OUT, LOW, OK } // enums are Part 3's topic; this one is just three constants
fun reorderAction(band: Band) {
// A when *statement* over an enum must still cover every constant, unlike a Java switch statement:
// when (band) { Band.OUT -> println("reorder now") } // error: 'when' expression must be exhaustive. Add the 'LOW', 'OK' branches or an 'else' branch.
when (band) {
Band.OUT -> println("reorder now")
Band.LOW -> println("reorder this week")
Band.OK -> {} // deliberately nothing, and now that is visible in the code
}
}
fun main() {
reorderAction(Band.OUT) // -> reorder now
reorderAction(Band.OK)
reorderAction(Band.LOW) // -> reorder this week
}

That is a feature: add a fourth constant to Band and every when over it stops compiling until someone decides what it means.

Gotcha — == means equals(), and === means identity. Comparing strings with == is now correct, not a bug; read === as Java’s ==. Part 2 covers equality, including where the Integer cache comes back.

Gotcha — raw strings keep their indentation. Java text blocks strip incidental leading whitespace for you; Kotlin raw strings don’t. End a multi-line SQL or JSON literal with .trimIndent() (or .trimMargin() with | markers).

Tip — named arguments for booleans and look-alike numbers. discounted(19_900, 25, true) is unreadable. discounted(19_900, percent = 25, roundToRupee = true) documents itself, and the compiler checks the names. Two adjacent Int or Boolean parameters are a cue to name them at the call site.

Tip — ..< over until. 0..<n and 0 until n mean the same thing. ..< (Stable since Kotlin 1.9) reads like the maths, and IntelliJ suggests it. For indices, list.indices is clearer still.


Debugging: First-Week Build Failures

SymptomCauseFix
Error: Could not find or load main class com.shelfwise.MainThe class for Main.kt is MainKtmainClass = "com.shelfwise.MainKt", or @file:JvmName("Main")
Cannot find a Java installation on your machine … matching: {languageVersion=25, …}. Toolchain download repositories have not been configured.No JDK 25 installed and no resolverInstall JDK 25, or add the foojay resolver plugin to settings.gradle.kts
Inconsistent JVM-target compatibility detected for tasks 'compileJava' … and 'compileKotlin' …Java and Kotlin targeting different bytecode versionskotlin { jvmToolchain(25) } instead of setting targets by hand

Key Takeaways

ConceptRemember
Top-level declarationsFunctions live in packages; each file compiles to FileNameKt (rename with @file:JvmName)
val / varval = final reference, not an immutable object; prefer val
Number typesInt → int; Int? and generic arguments → Integer; no implicit conversions
Strings$x / ${expr} templates; raw strings have no escapes and keep indentation; $$"""…""" when $ is literal
FunctionsExpression bodies, defaults, named arguments; one synthetic $default method that Java can’t call
Expressionsif, when, try return values; when never falls through and is exhaustive over enums, sealed types and Boolean
Ranges.. inclusive, ..< exclusive, downTo to count down; ranges in a for header compile to int counters
Constantsconst val = Java compile-time constant; plain val = field + getter
Buildkotlin("jvm") adds the stdlib; jvmToolchain(25) aligns Kotlin and Java; foojay downloads the JDK

Story Closing

The second version of the pull request had no class, no semicolons and a single var. formatPrice was one expression, badgeFor was one when, and the label job printed aisle seven correctly on the first run. Lena approved it without a word.

The good mood lasted until Thursday. The staging catalog service crashed with a NullPointerException, in Kotlin code, on a line with no !! and no nullable type in sight. The null had come from the supplier’s Java SDK, and Kotlin had let it straight through.

“Kotlin doesn’t have NPEs,” Kabir said.

“Kotlin doesn’t have NPEs it can see,” said Lena. “Let’s talk about platform types.”

In Part 2, Kabir learns what null safety actually guarantees, and where it stops.


This is Part 1 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”