Available for work
Ankit Ranjan
Back to Deep Dives

Kotlin's Type System — Null Safety, Nothing, and Variance

Why Kotlin has no primitives, what the strange types Unit and Nothing are for, how smart casts work, and how generic variance (in and out) keeps collections both safe and flexible.

June 4, 2026 8 topics 7 quiz questions
Share:
1

Everything is an object

Java has a split personality. It has primitivesint, double, boolean — which are fast but not objects, and it has their boxed object counterparts — Integer, Double, Boolean — which can be null and live on the heap. Every Java developer has been bitten by the difference (autoboxing surprises, NullPointerException on an Integer, == comparing references instead of values).

Kotlin erases that split, at least from the programmer's point of view. In Kotlin there are no primitive types. Int, Double, and Boolean are ordinary classes with methods, and they sit in a single, unified type hierarchy. You can call 42.toString() or 3.coerceAtLeast(5) because Int is a real type.

Does that mean Kotlin is slow, boxing every number? No — and this is the clever part. The compiler quietly compiles Int down to a JVM primitive int wherever it safely can, and only falls back to the boxed Integer when it must (for example, a nullable Int?, or an Int used as a generic type argument). You get the clean mental model of "everything is an object" with most of the performance of primitives.

At the very top of this unified hierarchy sits Any — the root of all non-nullable types, Kotlin's equivalent of Java's Object. Every class you write implicitly extends Any, which is where equals(), hashCode(), and toString() come from. But Any is not quite the top. Above it sits something stranger.

2

Top and bottom: Any?, Unit, and Nothing

A well-designed type system has a top type (something every value is) and a bottom type (a subtype of everything). Kotlin has both, plus a curious little type in between that replaces Java's void.

The type hierarchy — top to bottomAny?top type — every value is an Any?AnyNullString, Int…Unitthe "void" with one valueNothingbottom type — subtype of everything

Any? is the true top type: every possible value, including null, is an Any?. Its non-nullable sibling Any is the root of all non-nullable types.

Unit is Kotlin's replacement for void. The difference is subtle but important: void means "no return type at all," whereas Unit is a real type that has exactly one value, also written Unit. The name comes from type theory and functional languages, where the "unit type" is the canonical type with a single inhabitant. Because Unit is a real value, it can be used as a generic type argument — which matters a lot when you write generic code like Function<Unit>. A function that "returns nothing useful" actually returns Unit; you just never write it.

Nothing is the bottom type, and it is genuinely mind-bending the first time you meet it. It has no instances at all — you can never hold a value of type Nothing. It is a subtype of every other type. What could such a thing possibly be for?

3

The surprising power of Nothing

Nothing is the type of an expression that never produces a value — because it always throws, or loops forever. A function declared to return Nothing is promising the compiler "control will never return from here normally."

fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}
Why is that useful? Because Nothing is a subtype of everything, the compiler can slot a Nothing-returning call into any type context, and it understands that the code after it is unreachable. This is what makes the Elvis-with-throw idiom type-check so cleanly:

val name: String = user.name ?: fail("name is required")
// If user.name is null, fail() throws — so after this line,
// the compiler KNOWS name is a non-null String.
The standard library's TODO() function returns Nothing for the same reason — you can drop TODO() into a function of any return type while you stub it out, and it compiles.

Nothing also rescues type inference. What is the element type of an empty list? The library says emptyList() returns List<Nothing>, and because Nothing is a subtype of everything, that empty list is assignable to List<String>, List<Int>, anything. Likewise null's own type is Nothing?. These oddities — Unit with one value, Nothing with none — are not academic trivia; they are load-bearing parts of how everyday Kotlin code infers types correctly.

4

Smart casts and exhaustive when

In Java, checking a type and then using it means writing the type twice: if (x instanceof String) { String s = (String) x; ... }. Kotlin finds this redundant. Once you check a type, the compiler smart casts the value for you.

fun describe(x: Any): String {
    if (x is String) {
        // x is smart-cast to String here — no cast needed
        return "string of length ${x.length}"
    }
    return "not a string"
}
The same flow analysis powers null handling (after if (x != null), x is non-null) and pairs beautifully with when, Kotlin's vastly more powerful switch. A when can branch on types, ranges, and arbitrary conditions, smart-casting in each branch:

fun area(shape: Shape): Double = when (shape) {
    is Circle -> PI * shape.radius * shape.radius   // smart-cast to Circle
    is Rectangle -> shape.width * shape.height       // smart-cast to Rectangle
}
When when is used as an expression (its result is assigned or returned), the compiler insists it be exhaustive — every possible case must be covered, or you must add an else. Combined with sealed classes (covered in the Object Model episode), this gives you compile-time proof that you have handled every variant of a type. The same caveat from null safety applies: smart casts only work on values the compiler knows cannot change between the check and the use — local vals, not mutable properties that another thread could alter.

5

Generics and type erasure

Generics let you write one Box that works for any type, with full type safety:

class Box<T>(val value: T)
val intBox = Box(42)        // T inferred as Int
val strBox = Box("hello")   // T inferred as String
Kotlin runs on the JVM, and it inherits one of the JVM's most consequential design decisions: type erasure. At runtime, the type argument T is erased — a Box<Int> and a Box<String> are both just Box. The compiler uses the type information to check your code, then throws it away so the bytecode stays compatible with older Java.

This was a deliberate choice when generics were added to Java 5 in 2004: erasure preserved backward compatibility with pre-generics code, at the cost of losing type info at runtime. Kotlin lives with the consequence. You cannot, in ordinary code, ask "is this a List<String>?" at runtime — the <String> is gone.

fun <T> isType(value: Any): Boolean {
    // return value is T   // ERROR: cannot check erased type
    return false
}
Kotlin offers an escape hatch for this — reified type parameters — which we will reach shortly. But first, the most important thing generics force you to think about: variance.

6

Variance: in, out, and PECS

Here is a question that has confused programmers for decades. A Cat is an Animal. So is a List<Cat> a List<Animal>? It seems like it should be — but allowing it naively is unsafe, because if you could treat a list of cats as a list of animals, you could then add a Dog to it. This is the variance problem.

Kotlin solves it with two keywords you declare on the type parameter itself — declaration-site variance, which is cleaner than Java's wildcards (? extends / ? super) scattered at every use.

Producer-out, Consumer-in (PECS)out Tcovariant — a PRODUCERonly returns T, never accepts itinterface Source<out T>{ fun next(): T }Source<Cat> IS-A Source<Animal>in Tcontravariant — a CONSUMERonly accepts T, never returns itinterface Sink<in T>{ fun put(item: T) }Sink<Animal> IS-A Sink<Cat>

out T (covariance) means the type only ever produces T — it appears in return positions, never as a parameter. A producer of cats is safely a producer of animals, so Source<Cat> is a subtype of Source<Animal>. This is why Kotlin's read-only List<out E> is covariant: you can pass a List<Cat> where a List<Animal> is expected, because you can only read from it.

in T (contravariance) means the type only ever consumes T — it appears in parameter positions. A thing that can accept any animal can certainly accept a cat, so Sink<Animal> is a subtype of Sink<Cat>. This is the direction Comparable<in T> goes.

The mnemonic from Joshua Bloch's Effective Java is PECS: "Producer Extends, Consumer Super" — in Kotlin terms, "Producer out, Consumer in." When a class neither only-produces nor only-consumes (like MutableList, which does both), it must be invariant: MutableList<Cat> is not a MutableList<Animal>, and that is exactly the safety the compiler is protecting. When you do not know or care about the argument, the star projection List<*> says "a list of something."

7

Reified type parameters

Type erasure normally means a generic function cannot know its type argument at runtime. Kotlin punches a hole through this with a feature Java does not have: reified type parameters on inline functions.

Recall from the Functions episode that an inline function has its body copied into each call site at compile time. Because the body is inlined, the compiler knows the concrete type argument at every call — so it can substitute the real type directly into the generated code. Marking the type parameter reified unlocks that:

inline fun <reified T> Any.isA(): Boolean = this is T

println("hi".isA<String>())   // true
println(42.isA<String>())     // false

// The classic use: type-safe JSON parsing with no Class<T> argument
inline fun <reified T> String.fromJson(): T =
    gson.fromJson(this, T::class.java)

val user: User = jsonString.fromJson()   // T inferred, no boilerplate
Inside a reified function you can use T as if it were a real type: do is T checks, write T::class, create reflection references. This is why so many Kotlin libraries have such clean APIs — instead of forcing you to pass a Class<T> token around (as Java libraries must), they hide it behind a reified inline function. The limitation is the flip side of how it works: reified requires inline, so it cannot be used on regular functions or stored for later — the type is known only because the call was inlined.

8

AI prompts — exploring the type system

Use these prompts with an AI assistant to deepen your understanding of Kotlin's type system.

Prompt 1 — Unit vs Nothing:

Explain the difference between Unit and Nothing in Kotlin with
concrete examples. Show a function returning each, and explain
why Nothing being a subtype of every type is useful for the
Elvis-throw idiom and for inferring empty collection types.

Prompt 2 — Variance review:
Look at these generic classes and tell me whether each type
parameter should be out, in, or invariant, and why:
[paste your generic interfaces/classes]
Apply the producer-out, consumer-in rule.

Prompt 3 — Erasure pitfalls:
Review this generic code for type-erasure problems:
[paste code]
Flag any runtime type checks on generic parameters, and show
where a reified inline function would fix it.

Prompt 4 — Smart casts:
This code uses explicit casts and instanceof-style checks:
[paste code]
Rewrite it to rely on Kotlin smart casts and an exhaustive when,
and point out anywhere a smart cast won't apply and why.

Kotlin Type System — Quiz

7 questions

Seven questions on Kotlin's unified types, Unit and Nothing, smart casts, type erasure, and generic variance.

Search

Loading search...