"Life After Semicolons" — Syntax, Control Flow and the Build
Kabir's first Kotlin pull request is Java with the semicolons left in. We set up a Kotlin 2.4 Gradle build, see what top-level functions compile to, and cover val/var, types without primitives, strings, default arguments, if/when/try as expressions, ranges and loops.
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.ktclass 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.ktfun 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
| Java | Kotlin | Note |
|---|---|---|
public static void main(String[] args) | fun main() | Top level, in any file, importable |
| Static utility class | Top-level function | Compiled into a class named FileNameKt |
final var total = 1; | val total = 1 | A final reference, not an immutable object |
int / Integer | Int / 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 = 24 | Compile-time constants only |
"₹" + rupees, String.format | "₹$rupees", "${a + b}" | String templates |
Text block """ | Raw string """ + trimIndent() | No escapes, and no automatic indentation stripping |
| Overloads, builders | Default and named arguments | One declaration |
cond ? a : b | if (cond) a else b | if is an expression |
Arrow-form switch | when | Also 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 |
void | Unit | A real type with one value |
import static | import | One 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 pluginorg.jetbrains.kotlin.jvm. It addskotlin-stdlibto every source set, at the plugin’s own version, so you never declare it.jvmToolchain(25)picks the JDK for bothkotlincandjavac, and aligns their bytecode targets. With the foojay resolver insettings.gradle.kts, Gradle downloads that JDK if it isn’t installed.build.gradle.ktsis Kotlin, with type checking and IDE completion in the build script. Sources go insrc/main/kotlin, and Java files can sit alongside them insrc/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 finalmethods. Java calls them asPriceFormatKt.formatPrice(19900L). fun main()with no parameters gets a syntheticmain(String[])bridge, so the JVM launcher still finds it.- The class is
finaland 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 ShelfPricesKtpackage 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.BoxingKtfun restock(onShelf: Int, incoming: Int?, history: List<Int>): Int = onShelf + (incoming ?: 0) + history.sizepublic 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_SYNTHETICKotlin 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 instockBand. - 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,
whenmust be exhaustive. That means anelse, unless the subject is an enum, a sealed type or aBooleanand every case is listed. Awhenstatement over those subjects must be exhaustive too (see the Gotchas). tryproduces a value: the last expression of thetryblock, or of thecatchthat ran. Afinallyblock 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 13That 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.ktfun 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 = countandcount == 3Ldon’t compile whencountis anInt. Convert withtoLong(). Arithmetic still works across types through operator overloads.
Gotcha — a
whenstatement must be exhaustive over enums. A Javaswitchstatement over an enum can quietly skip constants. Kotlin’swhencan’t, even when it’s a statement whose value nobody uses. Over an enum, sealed type orBooleansubject, every case must be listed or anelseadded. 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 —
==meansequals(), and===means identity. Comparing strings with==is now correct, not a bug; read===as Java’s==. Part 2 covers equality, including where theIntegercache 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 adjacentIntorBooleanparameters are a cue to name them at the call site.
Tip —
..<overuntil.0..<nand0 until nmean the same thing...<(Stable since Kotlin 1.9) reads like the maths, and IntelliJ suggests it. For indices,list.indicesis clearer still.
Debugging: First-Week Build Failures
| Symptom | Cause | Fix |
|---|---|---|
Error: Could not find or load main class com.shelfwise.Main | The class for Main.kt is MainKt | mainClass = "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 resolver | Install 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 versions | kotlin { jvmToolchain(25) } instead of setting targets by hand |
Key Takeaways
| Concept | Remember |
|---|---|
| Top-level declarations | Functions live in packages; each file compiles to FileNameKt (rename with @file:JvmName) |
val / var | val = final reference, not an immutable object; prefer val |
| Number types | Int → int; Int? and generic arguments → Integer; no implicit conversions |
| Strings | $x / ${expr} templates; raw strings have no escapes and keep indentation; $$"""…""" when $ is literal |
| Functions | Expression bodies, defaults, named arguments; one synthetic $default method that Java can’t call |
| Expressions | if, 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 |
| Constants | const val = Java compile-time constant; plain val = field + getter |
| Build | kotlin("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.”