Kotlin's type system distinguishes nullable types (String?) from non-nullable types (String) at compile time — calling a method on a nullable type without a null check is a compile error. Smart casts, safe calls (?.), the Elvis operator (?:), and let{} make null handling concise without null pointer exceptions.
In Java, every reference type can be null. user.getName().length() throws a NullPointerException at runtime if getName() returns null. In Kotlin, String can never be null — the compiler enforces this. If you want a variable that might be null, you must say so explicitly with String?, and then the compiler requires you to handle the null case before using the value.
Nullable types: String vs String?
fun main() {
// Non-nullable: String cannot hold null — compiler enforces this
val name: String = "Alice"
// val name: String = null // COMPILE ERROR: Null can not be a value of a non-null type String
// Nullable: String? can hold null
var nickname: String? = "Ali"
nickname = null // OK
// Safe call (?.) — returns null instead of throwing if receiver is null
val length = nickname?.length // Int? — null if nickname is null
println(length) // null
// Elvis operator (?:) — provide a default when null
val displayName = nickname ?: "Unknown" // "Unknown" if nickname is null
println(displayName) // Unknown
// Safe call chain
val user: String? = null
val upper = user?.uppercase()?.trimEnd() // null — chain short-circuits at first null
// let: run a block only when non-null
nickname?.let { n ->
println("Nickname has ${n.length} chars") // only runs if nickname != null
}
}Smart casts
After an is type check or a null check, the Kotlin compiler automatically casts the variable to the checked type within that scope — no explicit cast needed.
fun describe(obj: Any): String {
// Smart cast: after "is String" check, obj is automatically String in that branch
return when (obj) {
is String -> "String of length ${obj.length}" // obj is String here
is Int -> "Int: ${obj * 2}" // obj is Int here
is List<*> -> "List with ${obj.size} elements" // obj is List here
else -> "Unknown: ${obj::class.simpleName}"
}
}
fun processNullable(s: String?) {
// Null check smart cast
if (s == null) return // compiler knows s is non-null after this point
println(s.length) // no ?. needed — s is smart-cast to String
println(s.uppercase())
// also works with &&
if (s != null && s.length > 3) {
println(s.substring(0, 3)) // smart cast in both conditions
}
}
fun main() {
println(describe("hello")) // String of length 5
println(describe(42)) // Int: 84
println(describe(listOf(1,2,3))) // List with 3 elements
processNullable("Kotlin")
processNullable(null)
}Sealed classes: exhaustive type hierarchies
A sealed class restricts which classes can extend it — all subclasses must be in the same package. Combined with when, this creates exhaustive pattern matching: the compiler warns if any subclass case is missing.
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
fun handleResult(result: NetworkResult<String>) {
// when on a sealed class is exhaustive — compiler warns if a case is missing
when (result) {
is NetworkResult.Success -> println("Got: ${result.data}")
is NetworkResult.Error -> println("Error ${result.code}: ${result.message}")
NetworkResult.Loading -> println("Loading...")
// no else needed — all cases covered; add a new subclass and compiler warns here
}
}
fun main() {
handleResult(NetworkResult.Success("Hello Kotlin"))
handleResult(NetworkResult.Error(404, "Not found"))
handleResult(NetworkResult.Loading)
}Platform types: Kotlin's Java interop compromise
When calling Java APIs from Kotlin, the compiler can't know whether a Java value can be null — Java's type system doesn't distinguish. These values have platform type (String! in error messages). Platform types can be assigned to either String or String?. Assigning to String and getting a null value causes a NullPointerException — the only way to get one in Kotlin. The safe practice: always assign Java return values to nullable types (String?) when unsure, or use @NonNull/@Nullable annotations in Java code, which Kotlin respects.
// System.getenv returns String? in Kotlin — JetBrains annotates the JDK
val path: String? = System.getenv("PATH") // safe — treated as nullable
val home: String = System.getenv("HOME") // risky — NPE if HOME not set
// requireNotNull and checkNotNull: fail fast with clear message
val env = System.getenv("APP_ENV")
val appEnv: String = requireNotNull(env) { "APP_ENV environment variable must be set" }
// also / let / run / apply — scope functions for null-safe operations
val user: String? = "alice"
// let: transform if non-null
val greeting = user?.let { "Hello, $it!" } ?: "Hello, stranger!"
// also: side effect while passing through
user?.also { println("Processing user: $it") }Generics and variance: out and in
Kotlin uses declaration-site variance — the class itself declares whether its type parameter is covariant (out T) or contravariant (in T). out T: T is only produced (returned), never consumed — List<String> is a subtype of List<Any>. in T: T is only consumed (parameter), never produced — Comparator<Any> is a subtype of Comparator<String>. This is safer and more expressive than Java's use-site <? extends T> wildcards.
!!) converts a nullable type to non-null and throws NullPointerException if the value is null. Using !! widely defeats the purpose of Kotlin's null safety. It is appropriate only when you have external knowledge that the value cannot be null and want a crash rather than silent continuation. Prefer safe calls (?.), Elvis (?:), or smart casts.val list = mutableListOf(1, 2, 3) — you cannot reassign list to point at a different list, but you can still call list.add(4). For truly immutable collections, use listOf(), mapOf() etc., which return read-only views. The object the val points to can still be mutated if it's a mutable type.enum class: each value is a singleton — no state per instance. sealed class: each subclass is a full class that can hold data and have multiple instances. Use enum for fixed, stateless choices (days, directions). Use sealed class for typed results with payloads (Success(data), Error(message)).Null safety implementation: nullable types at the JVM level
On the JVM, Kotlin's nullable types are implemented without boxing for primitives — Int? compiles to Integer (boxed), while Int compiles to the JVM primitive int. For reference types, String? and String are both java.lang.String at the bytecode level — the distinction is enforced entirely by the Kotlin compiler through @Nullable/@NotNull annotations and compile-time type checking. This means Kotlin's null safety has zero runtime overhead for reference types — it is purely a compile-time guarantee. The only runtime cost is for primitive nullable types which require boxing.
Sealed classes and the Kotlin 1.5+ sealed interfaces
Kotlin 1.5 extended the sealed concept to sealed interfaces — a class can implement multiple sealed interfaces, enabling more flexible algebraic data type hierarchies than sealed classes (which use single inheritance). Kotlin 1.5 also relaxed the restriction that sealed subclasses must be in the same file — they can now be in the same package within the same compilation unit. This change was motivated by Kotlin Multiplatform, where sealed hierarchies may be split across platform-specific source sets but still need exhaustive when checks. In Kotlin 2.0's K2 compiler, exhaustiveness checking was significantly improved — it now handles more complex conditions involving smart casts and type intersections.
Kotlin documentation. kotlinlang.org/docs/null-safety.html. Jemerov, D. & Isakova, S. (2017). Kotlin in Action. Manning Publications. Chapter 6 (Null safety). Kotlin language specification. kotlinlang.org/spec/. Kotlin Evolution and Enhancement Process (KEEP). github.com/Kotlin/KEEP.