Lecture notes — The Kotlin Type System
ver. 1.0.0
Where we are
Unit 1 listed safety among Kotlin’s four commitments and gave NullPointerException as the example. This unit delivers it.
It is also the unit where the interoperability commitment costs the most. Every guarantee here is bought by making the type system carry more information — nullability in the type, mutability in the interface — and Java’s type system carries none of it. So each half of the unit ends at the same boundary, and the compromises there are the most instructive part.
What you will be able to do
declare-nullable-types— Declare nullable types and explain what the compiler then refuses.use-the-null-safety-operators— Use the safe-call, Elvis, safe-cast and not-null-assertion operators.use-let-and-lateinit— Use let to run code only on a non-null value, and lateinit for deferred initialization.write-extensions-on-nullable-types— Write an extension function whose receiver may itself be null.handle-platform-types— Explain platform types and how to defend against Java’s missing nullability information.explain-primitive-types-and-boxing— Explain how Kotlin unifies primitive and wrapper types, and where boxing occurs.convert-numbers-explicitly— Convert between number types explicitly and say why implicit conversion was excluded.use-any-unit-and-nothing— Distinguish Any, Unit and Nothing, and say what each is for.distinguish-read-only-and-mutable-collections— Distinguish read-only from mutable collection interfaces and say what the distinction guarantees.handle-collections-and-arrays-across-java— Explain how collections cross the Java boundary, and when to use an array.
What we will cover
- Nullability — nullable types, four operators,
letandlateinit, nullable receivers. - Platform types — the most interesting compromise in the language.
- Basic types — primitives and boxing, explicit conversions,
Any,UnitandNothing. - Collections — read-only versus mutable, and what survives contact with Java.
- Arrays — and why you rarely need them.
Nullable types
Nullability is part of the type, not something discovered at runtime:
fun strLen(s: String) = s.length // s can never be null
fun strLenSafe(s: String?) = s.length // will not compileString is a type whose values are never null. String? is a type whose values may be. They are different types, and the question mark is how you say which you mean.
On a nullable value 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 have handled the null case:
fun strLenSafe(s: String?): Int =
if (s != null) s.length else 0 // smart cast makes s a String hereThe meaning of types
This is the argument worth understanding rather than accepting on authority.
A type describes the possible values and the operations available on them. In Java, String claims to be a string and may be null — so a variable of that type supports .length sometimes and throws other times, and the type tells you nothing about which. Every dereference is a small act of faith.
A type that admits a value on which none of its operations work is not describing its values honestly.
Kotlin’s split makes the type honest. String genuinely means “a string”. String? means “a string, or nothing” — and the compiler can then check every use.
That is the whole idea. Everything else in this half of the unit is machinery for working with the second type conveniently.
The runtime cost
None. Nullable types do not introduce wrapper objects. The checks happen at compile time, and the bytecode is what you would have written by hand.
Safety that is free at runtime is unusual enough to be worth stating explicitly.
Learning outcomes
- declare-nullable-types: Declare nullable types and explain what the compiler then refuses.
Concepts
- nullability: nullable and non-null are different types, checked at compile time
The four null-safety operators
Choosing between these is most of the daily work with nullable types.
?. — the safe call

s?.toUpperCase() // equivalent to: if (s != null) s.toUpperCase() else nullA null check and a call, combined. If the receiver is null the call is skipped and the whole expression is null — so the result type is String?, not String.
Chaining handles deeply nested nullable properties in one expression:
fun Person.countryName(): String? =
company?.address?.countryJava needs three nested ifs for that, or one long && chain.
?: — the Elvis operator

val length = s?.length ?: 0Reads as “the length, or zero if absent”. Safe call plus Elvis is a complete idiom, and it covers most cases in one line.
It also takes a throw on the right, because throw is an expression:
val address = person.company?.address
?: throw IllegalArgumentException("No address")
// address is non-null from here onThat works because of the Nothing type, which a later section explains.
as? — the safe cast

val person = other as? Person ?: return falseAttempts a cast, yielding null on failure instead of throwing. Its best use is exactly the one above — an equals implementation:
class Person(val firstName: String, val lastName: String) {
override fun equals(o: Any?): Boolean {
val otherPerson = o as? Person ?: return false
return otherPerson.firstName == firstName &&
otherPerson.lastName == lastName
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}One line replaces the instanceof-then-cast dance, and a smart cast makes otherPerson a Person afterwards.
!! — the not-null assertion

val sNotNull: String = s!!Tells the compiler you know better, and throws if you are wrong.
The book says the syntax was chosen to look like shouting, and that is not a joke — it is meant to be visible in review and uncomfortable to write.
Two rules:
- Use it only where you genuinely know something the compiler cannot.
- Never chain it.
a!!.b!!.cthrows on a line, and the stack trace tells you the line — not which assertion on it failed.
Learning outcomes
- use-the-null-safety-operators: Use the safe-call, Elvis, safe-cast and not-null-assertion operators.
Concepts
- safe-call-operator: call if non-null, otherwise the whole expression is null
- elvis-operator: a fallback value, or a
throw, when the left side is null - safe-cast-operator: null instead of an exception when the cast fails
- not-null-assertion: an explicit, ugly claim that a value is not null
let, lateinit and nullable receivers
let

?.let { } runs the block only when the receiver is non-null.email?.let { sendEmailTo(it) }The block runs only when the value is not null, and inside it the value is non-null and bound to it. This is the neat way to hand a nullable value to a function that requires a non-null one.
Its cost, worth stating: for anything longer than a line, if (x != null) with a smart cast reads better. let is best for a single use of the value.
lateinit
A non-null property must be initialised in the constructor — but frameworks routinely construct an object and inject its dependencies afterwards. The workaround would be a nullable type with !! at every use, which is noise around something you know is fine:
class MyService {
fun performAction(): String = "foo"
}
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: access it before initialisation and you get
kotlin.UninitializedPropertyAccessException:
lateinit property myService has not been initialized
— which names the property, and is a great deal more useful than a bare NullPointerException.
It must be a var, since a val is final and must be assigned in the constructor.
Extensions on nullable types

fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) { // no ?. needed
println("Please fill in the required fields")
}
}An extension function may declare a nullable receiver, and then it can be called on a nullable value directly, because it handles the null case itself:
fun String?.isNullOrBlank(): Boolean =
this == null || this.isBlank()This is the one place in Kotlin where this inside a function may be null — and the reason is the mechanism from unit 3.
An extension is a static function taking the receiver as its first parameter. A parameter can perfectly well be null. There is no dispatch on the receiver, so there is nothing to fail.
Worth knowing when reading library code: if a call on a nullable value compiles without ?., the function has a nullable receiver.
Learning outcomes
- use-let-and-lateinit: Use let to run code only on a non-null value, and lateinit for deferred initialization.
- write-extensions-on-nullable-types: Write an extension function whose receiver may itself be null.
Concepts
- let-function: a block that runs only on a non-null value
- lateinit-modifier: a non-null property initialised after construction, with an informative failure
Platform types
The most interesting compromise in the language, and the place where two of Kotlin’s commitments genuinely conflict.
The problem
Kotlin’s null safety depends on knowing whether a value can be null. Java does not record that. When a Java method returns a String, Kotlin cannot know.
Two clean answers, both rejected
- Treat everything from Java as nullable. Safe, and unusable — every call into a Java library would need a safe call or an assertion, including the overwhelming majority that never return null. The friction would make interoperability theoretical rather than real.
- Treat everything from Java as non-null. Convenient, and a lie — a
NullPointerExceptionwould become possible in code the compiler had certified safe, which destroys the guarantee everywhere, not just at the boundary.
Kotlin’s answer

A platform type. You cannot declare one yourself, and the IDE displays it as String!. It means “nullability unknown”, and Kotlin allows both uses — treat it as nullable or as non-null, and the compiler does not object.
Where the check happens
Not at the call site, but where a null would actually cause harm. Kotlin inserts an assertion when a platform value is assigned to a non-null type, so an error surfaces close to the mistake rather than deep inside unrelated code that received the null three calls later.
What to do about it

@Nullable and @NotNull annotations are honoured, producing proper Kotlin types.Look at the Java method and decide. Read the documentation, and check for @Nullable and @NotNull annotations — which Kotlin does honour, producing a properly nullable or non-null type rather than a platform type.
Then pick the Kotlin type deliberately when writing your own signatures over Java data.
Kotlin’s safety guarantee is complete within Kotlin. At the Java boundary it degrades to a documented, visible compromise rather than an invisible one.
That is the honest engineering answer, and it is why the language is usable in real codebases rather than only in new ones. A language that insisted on purity here would be safer on paper and adopted by nobody.
Learning outcomes
- handle-platform-types: Explain platform types and how to defend against Java’s missing nullability information.
Concepts
- platform-types: nullability unknown, so Kotlin trusts you and checks at the assignment
Primitives and conversions
One type instead of two
Java has int and Integer and makes you choose. Kotlin has Int, and the compiler emits a JVM int wherever it can — so you get primitive performance without picking a type for it.
Where a wrapper appears anyway
Three cases, sharing one cause: the JVM cannot put a primitive there.
- collections —
List<Int>holds boxed integers - generics — a type argument is always a reference type
- nullable types —
Int?must be a wrapper, because a primitive has no null
That last one is worth dwelling on. Int? is Integer, so a nullable number is a boxed number. In ordinary code that is irrelevant; in a large array of them it is not.
No implicit conversions
val i = 1
val l: Long = i // will not compile
val l: Long = i.toLong() // this is howKotlin will not convert between number types automatically, not even a widening Int to Long, which Java does silently.
Consider an Int and a Long holding the same number. With implicit conversion, whether they compare equal — and whether a collection of Long appears to contain an Int — depends on rules most people cannot recite:
val x = 1
val list = listOf(1L, 2L, 3L)
x in list // false — and in Java this would be a puzzleRequiring the conversion makes the comparison’s meaning explicit at the point of use.
It is a small daily cost for the removal of a whole category of confusing bug — the same trade as nullability, applied to numbers.
Arithmetic is the exception. The operators are overloaded for mixed types, so 1L + 1 works. The rule bites on assignment and comparison, not on arithmetic.
Literals carry their own conventions: L for Long, f for Float, 0x for hex, 0b for binary, and underscores for readability — 1_000_000.
Learning outcomes
- explain-primitive-types-and-boxing: Explain how Kotlin unifies primitive and wrapper types, and where boxing occurs.
- convert-numbers-explicitly: Convert between number types explicitly and say why implicit conversion was excluded.
Concepts
- primitive-types: one
Int, compiled to a primitive wherever the JVM allows - explicit-number-conversions: no implicit widening, to keep comparison honest
Any, Unit and Nothing
Three types that are easy to skim past, each solving a problem Java handles awkwardly.
Any
The supertype of all non-null types; Any? is the supertype of everything, null included. Under the hood it is java.lang.Object, with the same three universal methods.
The improvement is the same one as everywhere in this unit: Java’s Object includes null, so a parameter of type Object might be nothing at all. Any cannot be.
Unit
The return type of a function that returns nothing useful, and it may be omitted:
fun f(): Unit { }
fun f() { } // the same thingIt differs from void in one way that matters: Unit is a real type with a single value.
Which matters because of generics:
interface Processor<T> {
fun process(): T
}
class NoResultProcessor : Processor<Unit> {
override fun process() {
// no return statement needed — the compiler supplies Unit
}
}In Java the equivalent needs Void and an explicit return null. Small until you meet it, and then a genuine annoyance removed.
Nothing
The type of an expression that never returns at all — a function that always throws, or loops forever:
fun fail(message: String): Nothing {
throw IllegalStateException(message)
}Nothing has no values, and that is the point rather than a curiosity. Because it has no values, it is a subtype of every type, so an expression of type Nothing fits wherever any value is expected.
Which is precisely why this works:
val address = company.address ?: fail("No address")
println(address.city) // address is non-nullThe Elvis operator needs both sides to have compatible types. The right side is Nothing, which fits with anything — so the whole expression takes the type of the left side, minus its nullability.
The same reasoning is why throw on the right of an Elvis operator compiled in the earlier section. That was Nothing all along.
Learning outcomes
- use-any-unit-and-nothing: Distinguish Any, Unit and Nothing, and say what each is for.
Concepts
- any-type: the root of the non-null hierarchy
- unit-type: a real type with a value, unlike
void - nothing-type: no values, therefore a subtype of everything
Read-only and mutable collections
Nullability first
Two similar-looking declarations mean different things:

List<Int?> — a non-null list whose elements may be null.
List<Int>? — a list that may be null, whose elements are not.List<Int?> // the list exists; its elements may be null
List<Int>? // the list may be null; its elements may not
List<Int?>? // bothReading these correctly is a skill worth practising, because the difference determines where the safe calls go.
The separation

MutableCollection extends Collection, adding the modifying methods.Kotlin splits the collection interfaces in two: Collection, List, Set, Map for reading; MutableCollection, MutableList and friends for modifying, each extending its read-only counterpart.
Why it matters at signatures
fun <T> copyElements(source: Collection<T>,
target: MutableCollection<T>)That signature is a statement about behaviour, checked by the compiler: the source will not be modified, the target might be. A caller learns this from the types rather than from documentation that may be wrong.
The guidance follows: use read-only interfaces by default, and widen only where modification is genuinely needed.
The limit, stated precisely

A read-only interface does not mean the underlying object is immutable.
- the same object may be referenced elsewhere through a mutable interface
- so it may change while you hold a read-only reference to it
Read-only is a view, not a property of the object. In particular it is not a thread-safety guarantee — you cannot conclude from a List parameter that the data is stable across threads.
This is the same trap as val from unit 2, one level up: the reference is restricted, the object is not.
And Java ignores it entirely

At runtime a Kotlin read-only list is a java.util.List — the interoperability decision from unit 3. So Java code handed a read-only collection can modify it, and Kotlin cannot prevent that.
Platform types return here too. A collection coming from Java is a platform type, and you must decide three things when writing the Kotlin signature: can it be null, should it be read-only or mutable, and can its elements be null. Getting any of them wrong compiles and fails later.
This is the same shape as platform types: a real guarantee inside Kotlin, degrading at the Java boundary.
Knowing exactly where the guarantee stops is what makes it useful rather than misleading. A List parameter in a pure-Kotlin codebase means something. The same parameter in code that hands collections to Java means rather less.
Learning outcomes
- distinguish-read-only-and-mutable-collections: Distinguish read-only from mutable collection interfaces and say what the distinction guarantees.
Concepts
- read-only-vs-mutable-collections: two interfaces over one object, and what that does and does not guarantee
Arrays
The short closing section, and its main message is that you will not use this much.
val letters = Array<String>(26) { i -> ('a' + i).toString() }
val strings = arrayOf("a", "b", "c")
val nulls = arrayOfNulls<String>(26)The Array(n) { ... } form is the idiomatic one — a size and a lambda producing each element from its index, which removes the loop-and-assign pattern entirely.
The boxing problem
Array<Int> is an array of boxed Integer objects, for the reason given earlier: a type argument must be a reference type. For genuine primitive arrays there are dedicated types:
val fiveZeros = IntArray(5)
val squares = IntArray(5) { it * it }IntArray, ByteArray, CharArray and the rest compile to Java’s int[], byte[], char[]. Convert with toIntArray and toTypedArray at a Java boundary.
Collection functions work on arrays
The standard library provides the same extension functions, so filter and map are available — though they return lists, not arrays, which is usually what you wanted anyway.
When you actually need one
Two cases:
- Java interop, where an API demands an array
vararg, where the spread operator from unit 3 passes an existing array
Otherwise prefer a collection. Arrays have no read-only variant, no useful toString, and equality that compares identity rather than contents — three reasons a list is nearly always better.
Learning outcomes
- handle-collections-and-arrays-across-java: Explain how collections cross the Java boundary, and when to use an array.
Concepts
- primitive-arrays:
IntArrayand friends, to avoid the boxing thatArray<Int>forces
What the type system now guarantees
You can use nullable types and the four operators, handle the Java boundary deliberately, reason about boxing, place Any, Unit and Nothing, and choose read-only or mutable collections knowing exactly what each promises.
Learning outcomes
- declare-nullable-types: Declare nullable types and explain what the compiler then refuses.
- use-the-null-safety-operators: Use the safe-call, Elvis, safe-cast and not-null-assertion operators.
- use-let-and-lateinit: Use let to run code only on a non-null value, and lateinit for deferred initialization.
- write-extensions-on-nullable-types: Write an extension function whose receiver may itself be null.
- handle-platform-types: Explain platform types and how to defend against Java’s missing nullability information.
- explain-primitive-types-and-boxing: Explain how Kotlin unifies primitive and wrapper types, and where boxing occurs.
- convert-numbers-explicitly: Convert between number types explicitly and say why implicit conversion was excluded.
- use-any-unit-and-nothing: Distinguish Any, Unit and Nothing, and say what each is for.
- distinguish-read-only-and-mutable-collections: Distinguish read-only from mutable collection interfaces and say what the distinction guarantees.
- handle-collections-and-arrays-across-java: Explain how collections cross the Java boundary, and when to use an array.
Conclusion
Every guarantee here is bought by putting information into the type.
Nullability in the type, mutability in the interface. Nothing is checked at runtime that could be checked at compile time, and the nullability machinery costs nothing at runtime at all.
A type that admits a value none of its operations work on is dishonest.
That is the argument against Java’s reference types, and the reason
StringandString?are separate. Once they are separate, the compiler can do the checking you were doing in your head.Two commitments collide at the Java boundary, twice.
Platform types and read-only collections are both compromises where safety yields to interoperability. Both are visible rather than hidden, which is what makes them defensible.
Read-only is a view, not a promise about the object.
Another reference may be mutable, and Java can modify it regardless. It is real information in a Kotlin signature and not a thread-safety guarantee.
Explicit number conversion buys the same thing nullability does.
A category of confusing bug traded for a small daily cost. The
Int-in-a-List<Long>example is the one to remember, because implicit conversion makes it genuinely hard to reason about.Nothingis not a curiosity.It has no values, so it is a subtype of everything, so
throwandfail(...)fit on the right of an Elvis operator. A type-system trick doing real ergonomic work.
Where next
The next unit, Operator Overloading and Other Conventions, returns to something planted in unit 2. The in operator did double duty for membership and iteration, and to turned out to be a function rather than syntax.
Both are instances of one general mechanism: conventions, where a specifically named function gives your own types the behaviour of built-in syntax.