The Kotlin Type System

Kotlin

2026-08-21 09:00

Where we are

Unit 1 promised safety

And gave NullPointerException as the example.

This unit delivers it.

And this is where interop costs most

Every guarantee here is bought by making the type system carry more information.

Java’s type system carries none of it.

So each half of the unit ends at the same boundary.

What you will be able to do

  1. Declare nullable types.
  2. Use the four null-safety operators.
  3. Use let and lateinit.
  4. Write extensions on nullable types.
  5. Explain platform types.
  6. Explain primitives and boxing.
  7. Convert numbers explicitly.
  8. Distinguish Any, Unit and Nothing.
  9. Distinguish read-only from mutable collections.
  10. Handle collections and arrays across Java.

Nullable types

Part of the type

fun strLen(s: String) = s.length          // never null
fun strLenSafe(s: String?) = s.length     // will not compile

They are different types.

What the compiler refuses

To call methods on it.

To assign it to a non-null variable.

To pass it where a non-null value is expected.

Until you handle it

fun strLenSafe(s: String?): Int =
    if (s != null) s.length else 0        // smart cast

The meaning of types

A type describes the possible values and the operations available on them.

Java’s String claims to be a string and may be null.

So .length works sometimes and throws other times.

That fails the definition

A type admitting a value on which none of its operations work

is not describing its values honestly.

The runtime cost

None.

No wrapper objects. Checks at compile time.

The bytecode is what you would have written by hand.

The four operators

?. — safe call

Chained

fun Person.countryName(): String? =
    company?.address?.country

Java needs three nested ifs, or one long && chain.

?: — Elvis

The complete idiom

val length = s?.length ?: 0
val address = person.company?.address
    ?: throw IllegalArgumentException("No address")

That second one works because of Nothing. Later in this unit.

as? — safe cast

Its best use

override fun equals(o: Any?): Boolean {
    val otherPerson = o as? Person ?: return false
    return otherPerson.firstName == firstName &&
           otherPerson.lastName == lastName
}

One line replaces the instanceof-then-cast dance.

!! — not-null assertion

Deliberately ugly

The syntax was chosen to look like shouting.

Not a joke — it should be visible in review.

Two rules

Use it only where you know something the compiler cannot.

Never chain it. a!!.b!!.c throws on a line —

and the stack trace tells you the line, not which assertion failed.

let, lateinit, nullable receivers

let

For a single use

email?.let { sendEmailTo(it) }

For anything longer, if (x != null) with a smart cast reads better.

lateinit

class MyTest {
    private lateinit var myService: MyService

    @Before fun setUp() { myService = MyService() }
    @Test fun testAction() {
        Assert.assertEquals("foo", myService.performAction())   // no !!
    }
}

The cost is honest

kotlin.UninitializedPropertyAccessException:
    lateinit property myService has not been initialized

Names the property. A great deal better than a bare NPE.

Nullable receivers

Why it works

fun String?.isNullOrBlank(): Boolean =
    this == null || this.isBlank()

An extension is a static function taking the receiver as a parameter.

A parameter can be null. No dispatch, nothing to fail.

A reading tip

If a call on a nullable value compiles without ?.,

the function has a nullable receiver.

Platform types

The conflict

Kotlin’s null safety needs to know whether a value can be null.

Java does not record that.

Two clean answers, both rejected

Everything from Java is nullable. Safe, and unusable — every call would need a safe call, including the vast majority that never return null.

Everything from Java is non-null. Convenient, and a lie — an NPE becomes possible in code the compiler certified safe.

Kotlin’s answer

String!

A platform type. You cannot declare one.

It means “nullability unknown”, and Kotlin allows both uses.

Where the check happens

Not at the call site.

At the point where a platform value is assigned to a non-null type.

So the error surfaces near the mistake.

Annotations are honoured

The lesson beyond the mechanics

The guarantee is complete within Kotlin.

At the boundary it degrades to a documented, visible compromise.

A language insisting on purity here would be safer on paper and adopted by nobody.

Primitives and conversions

One type instead of two

Java has int and Integer and makes you choose.

Kotlin has Int, and emits a JVM int wherever it can.

Where a wrapper appears anyway

collections · generics · nullable types

One cause: the JVM cannot put a primitive there.

So Int? is Integer — a nullable number is a boxed number.

No implicit conversions

val i = 1
val l: Long = i            // will not compile
val l: Long = i.toLong()   // this is how

Not even widening. Java does it silently.

Why

val x = 1
val list = listOf(1L, 2L, 3L)
x in list      // false

With implicit conversion, whether these compare equal depends on rules most people cannot recite.

The same trade as nullability

A small daily cost.

A whole category of confusing bug, removed.

The exception

Arithmetic operators are overloaded for mixed types.

1L + 1 works. The rule bites on assignment and comparison.

Any, Unit and Nothing

Any

The supertype of all non-null types.

java.lang.Object, minus the null.

Java’s Object might be nothing at all. Any cannot be.

Unit

fun f(): Unit { }
fun f() { }              // the same

Unlike void, it is a real type with a value.

Which matters for generics

class NoResultProcessor : Processor<Unit> {
    override fun process() {
        // no return statement needed
    }
}

Java needs Void and an explicit return null.

Nothing

fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}

The type of an expression that never returns.

No values, therefore a subtype of everything

val address = company.address ?: fail("No address")
println(address.city)      // non-null

Elvis needs compatible types. Nothing fits with anything.

Which is why throw on the right worked earlier. That was Nothing all along.

Collections

Nullability first

And the other way

Read them slowly

List<Int?>     // the list exists; elements may be null
List<Int>?     // the list may be null; elements may not
List<Int?>?    // both

The separation

Why it matters at signatures

fun <T> copyElements(source: Collection<T>,
                     target: MutableCollection<T>)

A statement about behaviour, checked by the compiler.

Not documentation that may be wrong.

The limit

Read-only is not immutable

The same object may be referenced elsewhere as mutable.

Read-only is a view, not a property of the object.

It is not a thread-safety guarantee.

Same trap as val, one level up

The reference is restricted.

The object is not.

And Java ignores it

Three decisions at every boundary

Can it be null?

Read-only or mutable?

Can its elements be null?

Arrays

Creating them

val letters = Array<String>(26) { i -> ('a' + i).toString() }
val strings = arrayOf("a", "b", "c")

The lambda form removes the loop-and-assign pattern entirely.

Boxing again

Array<Int>        // boxed Integer objects
IntArray(5)       // a real int[]
IntArray(5) { it * it }

Collection functions work

filter and map are available on arrays.

They return lists — which is usually what you wanted.

When you actually need one

Java interop. vararg.

Otherwise: no read-only variant, no useful toString, identity equality.

Three reasons a list is nearly always better.

Summary

The six things to carry away

  • Every guarantee is bought by putting information into the type.
  • A type admitting a value none of its operations work on is dishonest.
  • Two commitments collide at the Java boundary, twice — visibly.
  • Read-only is a view, not a promise about the object.
  • Explicit number conversion buys what nullability buys.
  • Nothing is not a curiosity — it is why ?: throw compiles.

Where next

Operator Overloading and Other Conventions.

in did double duty. to was a function, not syntax.

Both are one mechanism: a specifically named function giving your type built-in syntax.