In Kotlin, There Are No Primitives
Kotlin's basic types in full: val/var and inference, the number types and how they map to JVM primitives, boxing and the identity trap, no implicit conversions, silent overflow, unsigned types, Char that isn't a number, strings and templates, and Any/Unit/Nothing.
Coming from Java, the first surprising thing about Kotlin’s type system is what’s missing. There are no primitive types. No lowercase int, no double, no boolean. In Kotlin, 42 is an Int, and Int is a class — you can call methods on it, use it as a generic argument, make it nullable.
That should terrify a Java engineer, because you know what an object per number costs. A List<Integer> of a million elements is a million boxed objects and a million pointer chases, and you have spent a career learning where that bites. So the first thing to settle is that Kotlin’s uniform, everything-is-an-object model is not the same as its representation. The compiler maps Int to the JVM’s int wherever it can. You write one consistent thing; underneath, most of the time, you get the primitive. The word “most” is doing real work in that sentence, and half this chapter is about the cases where it doesn’t hold — because that is exactly where the surprises live.
Every code sample here was compiled and run against Kotlin 2.4.10 on the JVM. Where the output is surprising, it’s the real output.
val and var
Two ways to introduce a name. val is a read-only binding; var is reassignable:
val name = "Ada" // can't be reassigned
var age = 36
age = 37 // fine
val is not final in the deep sense — it means the reference can’t be reassigned, not that the object it points at is immutable. A val list can still have things added to it if the list itself is mutable. It’s Java’s final on a local, no more.
You rarely spell the type, because Kotlin infers it from the initializer. You add it when there’s no initializer, when you want to widen the inferred type deliberately, or when the literal’s default type isn’t what you want:
val count: Int = 5
val id: Long = 5 // without the annotation this would be Int
val ceo: Employee? = null // no initializer to infer from, so the type is required
The habit worth building on day one: reach for val by default, and switch to var only when reassignment is genuinely part of the design. It’s not stylistic tidiness — an immutable binding is one fewer thing that can change under you, and Kotlin’s smart casts (a later chapter) can only narrow the type of something that can’t be reassigned.
The number types, and what they really are
The familiar set, all as proper types: Byte, Short, Int, Long for whole numbers, Float and Double for decimals. Literals pick their type by shape and suffix:
val i = 42 // Int
val l = 42L // Long
val d = 3.14 // Double
val f = 3.14f // Float
An undecorated integer literal is an Int, and an undecorated decimal literal is a Double — the same defaults as Java. Literals can be written in hex and binary (there is no octal), and underscores are legal anywhere inside the digits for readability:
val population = 8_100_000_000L
val mask = 0xFF_00_00 // hex
val flags = 0b0000_1101 // binary
Now the representation question. When the compiler can prove a value is a non-null Int used as an Int, it emits a JVM int. When the value has to become an object — because it’s nullable, because it’s a generic type argument, because it goes into a List<Int> — the compiler boxes it into a java.lang.Integer. This is the same boxing Java does; the difference is that Kotlin’s syntax hides when it happens, so you have to learn to see it. The rule of thumb: Int is a primitive, Int? is boxed, and List<Int> is a list of boxed integers. If you want a primitive array with no boxing, Kotlin gives you dedicated types — IntArray, LongArray, DoubleArray — which compile to int[], long[], double[]. An Array<Int> is Integer[]; an IntArray is int[]. That distinction is worth internalising, because it’s the one that shows up in a profiler.
The boxing identity trap
Here is where hidden boxing draws blood, and it’s a question that has failed people in interviews. Two nullable integers with the same value — are they the same object?
val a: Int? = 127
val b: Int? = 127
val c: Int? = 128
val d: Int? = 128
println(a == b) // true — structural
println(c == d) // true — structural
Structural equality (==) is what you want, and it does the right thing. But reach for referential equality (===) and the answer depends on the value, because both Int?s are boxed and the JVM caches boxed integers in the range −128..127. Inside the cache you get the same cached object; outside it, two distinct objects. Kotlin 2.4 has an opinion about you even asking:
println(a === b) // warning: identity equality ... is prohibited → prints true
println(c === d) // warning: identity equality ... is prohibited → prints false
The compiler now flags === between two Int? values as prohibited — a warning, not an error, so it still runs — precisely because the answer is an implementation detail of the boxing cache and no correct program should depend on it. The lesson is the same one Java teaches with Integer, but Kotlin’s cleaner syntax makes it easier to forget you’re in boxed territory at all. Use == for values; reserve === for “is this literally the same object,” and never on boxed numbers.
No implicit conversions
A sharp edge, and a deliberate one. Java silently widens an int to a long. Kotlin refuses:
val i: Int = 42
// val l: Long = i // does NOT compile: type mismatch
val l: Long = i.toLong() // convert explicitly
Every numeric type carries the full set — toByte(), toShort(), toInt(), toLong(), toFloat(), toDouble() — and you spell out every narrowing and every widening. It’s more keystrokes, and the payoff is that a whole category of silent precision and sign bugs becomes impossible to write by accident. There’s no implicit int-to-long that quietly changes behaviour when someone later refactors the return type.
Two things soften the verbosity so it doesn’t become noise. First, arithmetic still promotes the way you’d expect — mixing types in an expression widens automatically, because the operators are overloaded:
val total = 1L + 1 // Long + Int → Long, no .toLong() needed
val scaled = 3 * 2.5 // Int * Double → Double
Second, the no-implicit-conversion rule is about variables and assignments, not literals. A literal that fits is simply typed to fit its context — val b: Byte = 1 is fine, because 1 is a compile-time constant the compiler can see fits in a Byte. It’s the runtime Int-in-a-variable that won’t auto-narrow.
Overflow is silent — like Java, not like Python
The explicit conversions might lull you into thinking Kotlin guards arithmetic too. It doesn’t. Fixed-width integer math wraps around on overflow, exactly as on the JVM:
val max = Int.MAX_VALUE
println(max + 1) // -2147483648
No exception, no promotion to a bignum. If you’re adding values that might exceed Int.MAX_VALUE — summing a large ledger, computing a midpoint of two large indices — either use Long or reach for the checked helpers Kotlin inherits from the platform, Math.addExact and friends, which throw on overflow instead of wrapping. Kotlin gives you strong static typing; it does not give you arbitrary-precision integers for free. That’s BigInteger, opt-in, same as Java.
Unsigned types
Kotlin has unsigned integers — UByte, UShort, UInt, ULong — stable since 1.5 and useful when you’re modelling something that genuinely has no negative values, like bytes off a wire or a bitmask. The literal suffix is u:
val u: UInt = 4_000_000_000u // wouldn't fit in a signed Int
println(u) // 4000000000
println((-1).toUInt()) // 4294967295 — reinterprets the bits
They are not a security feature and they don’t range-check arithmetic (they wrap like their signed cousins). They’re a modelling tool: a UInt in a signature says “this is never negative” in a way a comment never could. Under the hood each is a wrapper around its signed counterpart, so treat them as slightly specialist — reach for them at boundaries, not as a blanket replacement for Int.
Boolean and Char
val ready: Boolean = true
val grade: Char = 'A'
Boolean is what you’d expect. Char is the one to slow down on, because it is not a number. In Java, 'A' is usable as the int 65 in any arithmetic context; in Kotlin it’s its own type. You can add an Int to a Char and get a Char back — that’s defined, and it’s how you shift through the alphabet — but you can’t multiply two Chars, and you can’t drop one into an Int slot without asking:
val g = 'A'
println(g + 1) // B — Char + Int → Char
println(g.code) // 65 — ask explicitly for the code point
// println(g + g) // does NOT compile — Char + Char is undefined
// val n: Int = g // does NOT compile — no implicit char-to-int
g.code is the Unicode code point; Char(65) goes the other way. This closes off the accidental arithmetic-on-characters bugs that come free with C-style languages, at the cost of one honest method call when you actually want the number.
Strings and templates
Strings are immutable, and the feature you’ll use in every file is the string template. $ splices a value directly into the literal, and ${...} takes a full expression:
val name = "Ada"
val age = 36
println("Hello, $name") // Hello, Ada
println("Next year: ${age + 1}") // Next year: 37
To put a literal dollar sign next to something templatable, escape it through a template: ${'$'}9.99. It reads oddly the first time and it’s the idiomatic way.
String equality is the payoff for a Java engineer who has been burned by == on strings. In Kotlin, == is structural — it calls equals — so it compares contents, and === is the referential check you almost never want:
val s1 = "kotlin"
val s2 = buildString { append("kot"); append("lin") }
println(s1 == s2) // true — same contents
println(s1 === s2) // false — different objects
The ==-is-content rule is universal in Kotlin, not special to strings; I’ll come back to it in the equality chapter, because it interacts with equals/hashCode in ways worth their own treatment.
Triple-quoted raw strings span lines and ignore backslash escapes, which makes them ideal for paths, JSON, SQL, and regex. The catch is leading indentation, and the fix is trimIndent (or trimMargin when you want an explicit margin marker):
val json = """
{
"name": "Ada"
}
""".trimIndent() // strips the common leading whitespace
val query = """
|SELECT id, name
|FROM employees
""".trimMargin() // everything left of the | marker is removed
trimIndent removes the longest common indentation from every line, so the string is what it looks like on the page, not what the surrounding code indentation forced it to be. Without it, your JSON carries four spaces of accidental indentation on every line.
null is part of the type
This is the design choice that changes how you write everything, and it earns its own chapter. The one-paragraph version, because the types here depend on it: String and String? are different types, and a plain String can never hold null. The ? is what admits null, and once a value can be null the compiler forces you to deal with it — the safe call ?. short-circuits to null, the Elvis operator ?: supplies a fallback, and !! asserts non-null and throws if you’re wrong:
var maybe: String? = "Ada"
maybe = null
val len1: Int? = maybe?.length // null, safely
val len2: Int = maybe?.length ?: 0 // 0, with a fallback
val len3: Int = maybe!!.length // throws — the escape hatch you rarely want
That single move — null in the type system instead of in your stack traces — is why day-to-day Kotlin sees so few NullPointerExceptions. Everything else about it is the null-safety chapter’s job.
Any, Unit, and Nothing
Three special types anchor the hierarchy, and understanding them early pays off once you start passing functions around.
Any is the root — the supertype of every non-null type, Kotlin’s Object. It carries equals, hashCode, and toString, and nothing else (the rest of Object’s methods, like wait and notify, live in an extension so they don’t clutter every type). Any? sits above even Any, adding null; it is the true top of the lattice.
Unit is the type of “no meaningful value,” what a Java method returning void corresponds to. The difference that matters: Unit is a real type with exactly one value, the singleton Unit. Because it’s a real type, a function returning Unit can be passed where any function value is expected — void can’t do that, which is why Java needs Runnable, Consumer, and the rest. In Kotlin a () -> Unit is just a function type.
Nothing is the bottom type: the type of an expression that never produces a value because control never returns from it. A function that always throws returns Nothing. Because Nothing is a subtype of every type, an expression of type Nothing fits anywhere, which is what makes throw usable as an expression and makes the Elvis-plus-throw idiom type-check:
fun fail(message: String): Nothing = throw IllegalStateException(message)
fun greet(name: String?) {
val real: String = name ?: fail("name required")
// after this line the compiler knows `real` is a non-null String,
// because the only other path didn't return at all
println("Hello, $real")
}
Nothing is also why emptyList() can be assigned to a List<String>, a List<Int>, or anything else — its element type is Nothing, the subtype of all of them — and why TODO() compiles in a function with any return type. You’ll rarely write Nothing yourself, and you’ll rely on it constantly.
Final thoughts
Kotlin’s bargain in this chapter is explicitness up front for fewer surprises later. No hidden numeric widening, no char-as-int, conversions you have to spell out — a little more to type, a category of bug removed. The two places the bargain is incomplete are worth carrying forward, because they’re where a Java engineer’s instincts are correct and the syntax hides the seam: arithmetic still overflows silently, and “everything is an object” quietly becomes boxing the moment a number goes nullable or into a collection. See those two, and the rest of the type system is exactly as safe as it looks.
The headline win, though, is null. By making it part of the type rather than a property of every reference, Kotlin converts a whole class of runtime NullPointerException into a compile error you fix before it ships — which is the subject we’ll keep circling back to.
Next: functions, which in Kotlin don’t need a class to live in — and retire the static-utility class, the overload pile, and the telescoping constructor in one go.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments