Kotlin's two best ideas are both moves in the type system, and both get sold as conveniences, which is why people miss them. Nullability is part of a type rather than a habit, so keeping null out of places it does not belong is the compiler's job and not your discipline. And concurrency is structured: a coroutine is a child of a scope that cannot finish while the child runs, so background work cannot leak. The rest is comfort. Read this for those two, and for where the surface looks like Java but behaves differently underneath.
Each chapter opens with the short version. Tap one to read the detail.
Almost everything produces a value
~3 min
val fixes the reference, not the object behind it, and most constructs — if, when, a function body — produce a value instead of performing an action. Two sharp edges: only the expression form of when is checked for exhaustiveness, and numbers never widen implicitly.
val and var differ in exactly one thing: whether the name can be reassigned. A val list can still have items added to it — the binding is fixed, the object is not. That is the first thing most arrivals get wrong, because "immutable" is the word usually attached to it.
Types come from the initializer, so a declaration without one must state its type. A function whose body is a single expression infers its return type too, and one returning nothing returns Unit — an ordinary type with a single value, not a hole in the type system.
Control flow follows. An if used as an expression produces a value and requires an else — the entire reason there is no ternary operator. when generalizes it: with a subject each branch is compared against it, without one each branch is an independent condition. Branches can test membership with in, a type with is, or several comma-separated values.
val label = when (code) {
in 200..299 -> "ok"
301, 302, 307 -> "redirect"
else -> "other"
}
Exhaustiveness depends on how you use when and on what you match. As an expression it must always cover every case. As a plain statement it must still be exhaustive over a Boolean, an enum or a sealed type — the compiler knows every possibility and insists you handle them, which is the payoff of modeling states as a sealed hierarchy. Only an open-ended subject like Int may skip cases. Close the type and the compiler starts finding the case you forgot. Loops are unremarkable by comparison: for walks anything supplying an iterator(), so there is no three-part C-style loop, and .. includes its end where ..< excludes it.
Numbers are the other edge, and here Kotlin is stricter than Java. Numeric types are not subtypes of one another and none widens implicitly; you call toLong() yourself. Equality is two operators answering different questions: == calls equals and handles a null left-hand side, while === asks whether two references are the same object. On boxed numbers they diverge:
val a: Int? = 10_000
val b: Int? = 10_000
println(a == b) // true
println(a === b) // false — two separate boxes
Boxes for small values are cached and reused, so the same code with 100 prints true twice. That is worse than a consistent answer: it passes in testing and fails on real data. Current compilers now warn on this exact comparison.
Rule: prefer the form that produces a value — an expression when is checked for you and a statement when is not — and compare values with ==, never ===.
Nullability is a type, and the compiler follows your control flow
~2 min
String and String? are different types, so a null check is assignability rather than discipline. The same flow analysis narrows a type after an is check — and where it refuses to narrow, the reason is always that the value could change between the check and the use.
String cannot hold null and String? can, and neither stands in for the other. Reading .length from a String? is a compile error, not a lint warning. That one decision turns an entire class of runtime crash into a build failure, and it is why porting Java's defensive null checks across is wasted work.
Four operators cover daily use. ?. evaluates to null instead of calling, and a chain short-circuits at the first null. ?: supplies a fallback whose right-hand side may be return or throw, because throw has type Nothing and fits into any expression. as? yields null instead of throwing when a cast does not match. And !! deliberately throws — an assertion you are personally making rather than a conversion, whose whole value is that it stays visible in the source.
The same analysis narrows types generally. After an is check the compiler uses the narrower type with no cast — in a when branch, or after an early return:
fun length(x: Any): Int {
if (x !is String) return 0
return x.length // smart cast to String
}
Where it refuses, it is always because it cannot prove the value is unchanged between check and use. A var property is never smart-cast — other code could write it between the two lines — and neither is a custom-getter property, which could return something different on each read. Copy it into a local val and the narrowing works.
The hole is Java: a value from unannotated Java code has a platform type, so no compile-time check applies and a null lands as a crash at the boundary.
Rule: decide nullability at your edges and keep the interior non-null — a ? deep inside a codebase is usually a decision nobody made.
Functions, lambdas, and where return actually returns from
~2 min
Default and named arguments replace most overload sets. Inside a lambda the last expression is the value, and there is no local return — a bare return exits the enclosing function, which the compiler permits only when the lambda is being inlined.
Default argument values belong to the declaration, and an override must omit them rather than restate them. With named arguments — which let you skip earlier defaults and reorder for readability — they remove most of the overload sets a Java codebase carries around. Parameters are read-only inside the body.
Lambdas have no return: the last expression is the value. A bare return inside one is not a return from the lambda at all. It exits the enclosing function, which is why the compiler only accepts it when the lambda is being inlined into that function. For an ordinary local return, use an anonymous function, where return means what it looks like:
val f = fun(x: Int): Int {
if (x < 0) return 0 // returns from f, not from the caller
return x * 2
}
The feature worth learning early is the receiver lambda. A parameter typed A.() -> Unit makes this inside the block the receiver rather than a parameter, so members are reachable unqualified. That one type is the mechanism behind apply, behind configuration blocks, and behind every nested-block Kotlin library you will read.
Two smaller surprises for Java arrivals: a lambda may reassign a variable captured from the enclosing scope, and a lambda in the final argument position moves outside the parentheses — which is why so much Kotlin reads like a language construct when it is an ordinary call.
Rule: a lambda's return is not local; reach for an anonymous function the moment you want one.
Classes: final by default, constructed from the header
~2 min
The class header is the primary constructor, and val/var there also declares a property. Classes and members are final unless marked open, and override is mandatory. Initialization runs base-first, which is why calling an open member from a constructor sees uninitialized state.
The class header is the primary constructor, and marking a parameter val or var declares a property at the same time. Property initializers and init blocks then run in declaration order, interleaved, and all of them run before any secondary constructor body — a secondary constructor must delegate to the primary. There is no new, so a constructor and a factory function look identical at the call site.
Inheritance is closed by default. A class cannot be subclassed unless declared open, a member cannot be overridden unless declared open, and override is required rather than advisory. An overriding member is itself open unless marked final. A val may be overridden by a var but never the reverse, because val promises only a getter.
Initialization order is the trap:
open class Base {
init { render() } // runs during Base construction
open fun render() {}
}
class Child(private val name: String) : Base() {
override fun render() = println(name.length) // NullPointerException
}
A base class is fully initialized before the derived class's properties are, so an open member called during base construction runs against a half-built object. name is declared non-null and is still null here, throwing a NullPointerException — construction order is the one hole null safety cannot close.
Visibility differs from Java in one place worth knowing cold: protected means subclasses only and grants no package access at all, while internal means visible throughout the compilation module.
Rule: never call an open member from a constructor, an init block, or a property initializer.
The class flavors: data, sealed, value, object
~2 min
data generates equality, toString, copy and destructuring — but only from primary-constructor properties, and copy() is shallow. sealed hands the compiler a closed set it can check a when against. object is a lazily initialized, thread-safe singleton.
A data class generates equals, hashCode, toString, componentN and copy — but only from primary-constructor properties, and copy() is shallow:
data class Cart(val id: String, val items: MutableList<String>) {
var lastViewed: Long = 0 // in the body: excluded from equals and copy
}
val a = Cart("c1", mutableListOf("hat"))
val b = a.copy()
b.items.add("scarf") // a.items holds it too — the list is shared
Both are one trap seen twice: the generated members look deeper than they are. Two carts differing only in lastViewed compare equal, and a copy shares every nested object with the original.
sealed is what models a closed set of states. Direct subclasses live in the same package and module and are all known at compile time, so a when over the type is exhaustive with no else — and adding a case makes every such when expression fail to compile until you handle it. That is the whole payoff, and an else branch throws it away by absorbing the new case silently. A sealed interface adds the flexibility that one class can belong to several hierarchies at once.
object declares a singleton, initialized lazily and thread-safely on first access. A companion object is the per-class one, reached through the class name — its members are not compiled to Java statics unless annotated, which regularly surprises Java callers.
A value class wraps one property with no wrapper allocation in the common case, but boxes whenever it is used as another type — as an interface, a generic argument, or nullable. The zero-cost claim holds right up until you put one in a list.
Rule: put every property that defines identity in the primary constructor, because anything in the class body is invisible to equality.
Adding behavior without inheritance: extensions, delegates, scope functions
~2 min
An extension is a function with a receiver, resolved statically on the declared type — so it never overrides anything and never behaves polymorphically. Delegates move accessor logic into a reusable object. The five scope functions differ on exactly two axes, not five.
An extension function is not added to the class. It compiles to a function taking the receiver as an argument, and three consequences follow. It resolves on the declared type of the expression rather than the runtime type. A member function with the same signature always wins. And it sees only the receiver's public members.
open class Shape
class Rectangle : Shape()
fun Shape.name() = "Shape"
fun Rectangle.name() = "Rectangle"
fun show(s: Shape) = println(s.name()) // "Shape", even given a Rectangle
Extension properties fall out of the same fact: with nowhere to keep a field, they must be computed by an accessor.
Delegated properties are the other half. by hands a property's get and set to an object exposing getValue and setValue, so recurring accessor logic gets written once. by lazy computes on first read and caches, synchronized by default; Delegates.observable reports a change after it lands, while vetoable can reject it first.
Scope functions look like five things to memorize and are really a two-by-two table — how the object arrives, and what comes back.
•
apply — this, returns the object. Configuration.
•
also — it, returns the object. Side effects and logging.
•
let — it, returns the lambda's result. The standard null guard, as x?.let { }.
•
run and with — this, return the lambda's result.
Choose by the axes, not by taste: if the next line needs the object, you needed one of the two returning it. with is the odd one out — it takes its argument normally instead of as an extension, so it cannot be chained onto a nullable.
Rule: pick a scope function from the two axes, and avoid nesting two — inside the second, this and it quietly mean something else.
Generics, variance, and the types that vanish
~2 min
Type arguments are erased at runtime, so x is List<String> will not compile — only List<*> is checkable. out marks a producer and in a consumer, which is why read-only List is covariant and MutableList is not. reified in an inline function is the escape hatch.
Generics are invariant by default, and the reason is writes: if MutableList<String> were a MutableList<Any>, you could insert an Int into a list of strings. Variance annotations declare which half of the contract a type parameter actually uses. out T means the type only ever comes out, so Source<String> is usable as a Source<Any>; in T means it only ever goes in, so a Comparable<Number> is usable as a Comparable<Double>. This is exactly why the read-only List is covariant while MutableList is not.
Use-site projections apply the same restriction to one usage instead of a whole declaration, doing the job Java's wildcards do. List<*> is the case where the argument is unknown: elements read as Any?, and writing is refused.
Then the runtime disappears. Type arguments are erased, so x is List<String> does not compile; only x is List<*> is checkable, and casting back to a concrete argument is unchecked — it will not fail where you wrote it.
reified is the way out, and it is why inlining is a language feature rather than an optimization. Inlining copies the body into the call site, where the actual type argument is known and can be baked in:
inline fun <reified T> Any.isA(): Boolean = this is T
A non-inline function cannot. Copying the body is also what permits non-local return from an inlined lambda — and what it costs: duplicated code at every call site.
Rule: annotate variance where a type only produces or only consumes, and reach for reified the moment you need a type argument at runtime.
Collections: read-only is not immutable
~2 min
List is a read-only interface, not an immutable object — the data can still change through a mutable reference to it. Chained operations build a whole intermediate list at each step, unless you switch to a sequence, which pushes one element through the entire chain at a time.
Every collection type comes as a pair: a read-only interface and a mutable one extending it. Read-only means the interface offers no write operations. It does not mean the object cannot change. Pass a MutableList where a List is expected and you have handed over a view — whoever still holds the mutable reference can change it underneath you. Read-only types are also covariant, letting a List<Rectangle> satisfy a List<Shape>; mutable ones are not, for the same reason mutable generics never are.
Chained operations are eager. Each map or filter builds a complete intermediate list before the next step begins. A sequence inverts that: it pushes one element through the whole chain, then the next, and does nothing until a terminal operation asks for a result. That is what makes short-circuiting cheap — a take(4) stops the upstream work early — and why sequences win on long chains over large inputs and lose on small ones, where the laziness is pure overhead.
Accessors then come in two families. first, last, reduce and maxOf throw when there is nothing to return; firstOrNull, lastOrNull, reduceOrNull and maxOrNull return null instead. fold is reduce given a starting value, which also frees the result to be a different type from the elements.
val total = items.fold(0) { sum, x -> sum + x.price }
// on an empty list this is the initial value; reduce would throw
Rule: treat a read-only type as a promise about your own code, never a guarantee about the data behind it.
Coroutines and flows: suspension is not threading
~3 min
A suspending function pauses without holding its thread, so very many coroutines share very few. Every one is a child of a scope that cannot complete while a child runs, so work cannot leak — and cancellation travels that tree, cooperatively. A flow is the streaming form of the same rules.
A suspend function pauses and resumes without blocking its thread. That is the whole trick: the thread is released for other work, so coroutines are cheap enough to run in numbers threads never could. Call one only from another suspending function or a coroutine builder.
Structured concurrency is the part that rearranges how you think. A coroutine started with launch or async is a child of the scope that started it, and that scope does not complete until every child has. Failure travels the tree too: canceling a parent cancels the whole subtree, and under coroutineScope a failing child cancels its siblings — supervisorScope is the variant where it does not. Nothing here resembles a thread you start and forget.
launch returns a Job and no value; async returns a Deferred you await. The classic mistake is awaiting inline: async { one() }.await() on one line and the next runs them one after the other, since the second coroutine is not created until the first finishes. Start both, then await both:
val a = async { one() } // both are running from here
val b = async { two() }
a.await() + b.await()
A child inherits its parent's context but always gets a fresh Job, which keeps the tree intact. The dispatcher in that context decides which thread runs the work, and withContext switches it for one block and returns that block's value — a sequential switch, not a new branch.
Cancellation is cooperative: a coroutine notices only when it suspends or checks, so a tight computation loop keeps running after cancellation until it calls yield() or ensureActive(). And because cancellation arrives as a CancellationException, catching a broad Exception swallows it and breaks the structure.
Flows apply the same rules to streams. A cold flow does nothing until collected, and each collector re-runs the builder from the beginning. What makes one worth using instead of a sequence is that its operators may call suspending functions. Context is preserved, so a builder must emit from the context it runs in — wrapping an emit in withContext fails at runtime. Move upstream work with flowOn, which changes the context of everything above it and leaves everything below in the collector's:
catch is upstream-only in exactly the same way — an exception thrown inside the collect lambda needs an ordinary try/catch around the collection. Backpressure is tuned with buffer (let the producer run ahead), conflate (drop all but the newest) and collectLatest (cancel a collector still working).
Rule: never start a coroutine outside a scope that owns it, never catch broadly around suspending code without rethrowing cancellation, and remember that flowOn and catch act only upstream.
Living on the Java platform: interop and exceptions
~2 min
Kotlin has no checked exceptions, and throw is an expression of type Nothing. Most interop friction is two guarantees Java does not make: an unannotated Java type carries no null check, and a read-only Kotlin List is a java.util.List that Java can mutate.
Kotlin treats every exception as unchecked, so nothing forces you to catch one. throw is an expression of type Nothing, a subtype of every type, which is why it fits on the right of an elvis operator or in a when branch. try is an expression too, yielding whichever block ran. @Throws exists only so Java callers see a declared exception.
Interop friction is mostly two guarantees the other side does not make. Nullability: a type arriving from unannotated Java code carries no compile-time check at all, so the failure lands at the boundary — annotate the Java side, or narrow it yourself on the way in. Mutability: a read-only Kotlin List is the very same java.util.List at runtime, so Java code holding it can add to it. Read-only is a Kotlin-side promise, not a runtime guarantee.
The rest is naming and shape. A Java getX/setX pair appears in Kotlin as a property. Top-level declarations in Util.kt become members of a class named UtilKt. Default arguments do not reach Java callers unless you ask for the overloads:
@JvmOverloads
fun greet(name: String, greeting: String = "Hello") { }
// Java sees greet(name, greeting) and greet(name)
Kotlin lambdas convert automatically to Java interfaces with a single abstract method; going the other way, a Kotlin interface must be declared fun interface first.
Rule: assume neither null safety nor read-only survives the trip into Java, and annotate the crossings deliberately.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.