Skip to content
CalliCoder

Kotlin Variables and Data Types

Published Updated Kotlin 13 min read

val is not immutable, there are no implicit numeric conversions, Int overflows silently while toInt() truncates, and == on boxed values behaves differently from ===.

Kotlin’s type system removes two things Java programmers rely on without noticing: implicit widening between numeric types, and the ability to hold null in any reference. The first produces compile errors that look pedantic and prevent a real class of bug; the second is the whole of null safety.

Written against Kotlin 1.9.

val and var

val name = "Kotlin"     // read-only reference
var count = 0           // reassignable

name = "other"          // compile error
count += 1              // fine

val means the reference cannot be reassigned. It does not mean the object is immutable:

val items = mutableListOf("a")
items.add("b")          // perfectly legal — the list is mutable, the reference is not

That distinction is the one to internalise. For actual immutability the type has to be immutable — List rather than MutableList, a data class with val properties rather than var.

Default to val. It is not a style preference: a val local is what makes smart casts work, because the compiler can prove the value has not changed between a check and a use.

Type inference, and where to be explicit

val count = 42              // Int
val price = 19.99           // Double
val ratio = 19.99f          // Float
val big = 42L               // Long
val flag = true             // Boolean
val letter = 'K'            // Char
val text = "Kotlin"         // String

Inference is complete and local, the type is fixed at the declaration and never changes.

Two places to write the type anyway. On a public API, where the inferred type is part of the contract and should not shift when the implementation does. And when the inferred type is not the one you want:

val ids = listOf(1, 2, 3)                    // List<Int>
val ids: List<Number> = listOf(1, 2, 3)      // if the wider type is intended

A declaration with no initialiser always needs a type, since there is nothing to infer from.

Numbers, and the missing conversions

TypeBitsRange
Byte8−128 … 127
Short16−32,768 … 32,767
Int32±2.1 billion
Long64±9.2 quintillion
Float32~6–7 significant digits
Double64~15–16 significant digits
val a: Int = 42
val b: Long = a          // compile error — no implicit widening
val c: Long = a.toLong() // explicit

Java widens int to long silently. Kotlin does not, and the reason is that implicit conversion in one direction leads people to expect it in the other, where it loses data. Every conversion is toInt(), toLong(), toDouble() and so on.

The narrowing ones truncate rather than throw:

val big = 300
println(big.toByte())        // 44 — the low 8 bits
println(3.99.toInt())        // 3  — toward zero, not rounded
println((-3.99).toInt())     // -3

toInt() on a Double truncates toward zero. Math.round or .roundToInt() rounds.

Arithmetic overflows silently:

val max = Int.MAX_VALUE
println(max + 1)             // -2147483648
println(Math.addExact(max, 1))  // throws ArithmeticException

That is the same behaviour as Java and worth stating, because “Kotlin is safer” leads people to assume it is checked. It is not. Use Long, BigInteger, or the *Exact methods where the range matters.

Literals support underscores and the standard prefixes:

val million = 1_000_000
val hex = 0xFF_EC_DE
val binary = 0b0100_1101

There is no octal literal — a leading zero is not special.

Boxing, == and ===

val a: Int = 127
val b: Int = 127
println(a == b)     // true
println(a === b)    // true — primitives, no identity involved

val c: Int? = 127
val d: Int? = 127
println(c == d)     // true
println(c === d)    // true  — inside the cache range

val e: Int? = 128
val f: Int? = 128
println(e == f)     // true
println(e === f)    // false — boxed separately, outside the cache

A nullable Int? is a boxed java.lang.Integer, and the JVM caches boxes for −128…127. Outside that range, two boxes of the same value are different objects.

The lesson is not about the cache: it is that === on numbers is meaningless and == is what you want. == compares values and calls equals, handling null on both sides without throwing.

Strings and templates

val name = "Kotlin"
val greeting = "Hello, $name — ${name.length} characters"

val raw = """
    Line one
    Line two
""".trimIndent()

Templates interpolate a variable with $name and an expression with ${...}. A literal dollar sign is ${'$'} inside a template, or \$ in an ordinary string.

Raw strings preserve newlines and apply no escaping, which makes them the right choice for regular expressions, SQL and JSON. trimIndent() removes the common leading whitespace, so the literal can be indented with the code.

Strings are immutable, and + in a loop allocates each time. StringBuilder, buildString { } or joinToString are the alternatives:

val csv = items.joinToString(separator = ",", prefix = "[", postfix = "]")

Char is not a number

val c = 'K'
val n: Int = c           // compile error
val n = c.code           // 75
val back = 75.toChar()   // 'K'

Java allows char in arithmetic; Kotlin requires .code. Char.digitToInt() converts a digit character to its value, which is the operation c - '0' used to express.

Any, Unit and Nothing

Three types occupy the corners of the hierarchy and each answers a different question.

Any is the root, equivalent to java.lang.Object but without wait, notify and getClass. It declares equals, hashCode and toString and nothing else. Any? is the true top of the system, since Any itself cannot hold null.

Unit is the return type of a function that returns nothing useful — the equivalent of void, with the difference that it is a real type with a single value, so it can be a generic argument:

fun log(message: String): Unit { }   // the : Unit is conventionally omitted
val handler: (String) -> Unit = ::log

Nothing is the type with no values at all, and it is the return type of a function that never returns normally:

fun fail(message: String): Nothing = throw IllegalStateException(message)

val name = user.name ?: fail("no name")   // name is String, not String?

Because Nothing is a subtype of every type, the compiler accepts it on either side of an expression and narrows the result to the other branch. That is the mechanism behind val x = maybe ?: return and val y = maybe ?: throw ... — both work because return and throw have type Nothing.

Nothing? has exactly one value, null, which is why the inferred type of val x = null is Nothing? and why such a declaration is almost always a mistake to leave un-annotated.

Arrays

val nums = intArrayOf(1, 2, 3)          // int[] — no boxing
val objs = arrayOf(1, 2, 3)             // Integer[] — boxed
val zeros = IntArray(5)                 // five zeros
val squares = IntArray(5) { it * it }   // [0, 1, 4, 9, 16]

IntArray maps to a Java int[] and Array<Int> to Integer[]. The primitive versions exist for exactly that reason and should be preferred for numeric data.

Arrays are fixed-length and invariant — Array<String> is not an Array<Any>. In practice a List is the better default; arrays are for interop and for numeric work where boxing matters.

Related: nullable types and properties. More in the Kotlin guides.

Frequently asked questions

Does val mean immutable?

No — it means the reference cannot be reassigned. A val holding a MutableList still allows the list to change.

Should I always use val?

As a default, yes. Beyond intent, a val local is what allows the compiler to smart-cast after a type or null check.

Why can’t I assign an Int to a Long?

Kotlin has no implicit numeric conversion in either direction. Call .toLong() explicitly.

Does toInt() round?

No, it truncates toward zero. Use roundToInt() to round.

Does Kotlin check for integer overflow?

No. Int.MAX_VALUE + 1 wraps silently, exactly as in Java. Use Long, BigInteger or Math.addExact where it matters.

Why does === return false for two equal Int? values?

Above 127 they are separate boxed objects. Use ==, which compares values; === on numbers is not a useful question.

What is the difference between Int and Int?

Int compiles to a primitive; Int? is boxed, because a primitive cannot hold null. That has a memory and identity cost in hot code.

How do I write a literal dollar sign in a template?

${'$'} inside a template, or \$ in an ordinary string literal.

When should I use a raw string?

For regular expressions, SQL and anything with backslashes or newlines. Pair it with trimIndent() so it can be indented with the surrounding code.

IntArray or Array<Int>?

IntArray for numeric data — it compiles to int[] with no boxing. Array<Int> becomes Integer[]. For most code a List is the better choice than either.