Type Inference
Kotlin is a statically typed language — every value has a fixed type known at compile time — but that doesn’t mean you have to spell the type out every time you write one. Type inference is the compiler’s ability to determine a variable’s, expression’s, or function’s type automatically from context, so you can write val age = 30 instead of the more verbose val age: Int = 30 while keeping full compile-time type safety. It’s one of the reasons Kotlin code reads so much cleaner than equivalent Java, without giving up any of the guarantees static typing provides.
Overview: How Type Inference Works
Type inference is a compile-time process, not a runtime one. When you write val age = 30, the Kotlin compiler looks at the initializer expression 30, determines that its type is Int, and permanently records that age has type Int — as if you had written val age: Int = 30 yourself. From that point on, age behaves exactly like an explicitly typed Int: the compiler rejects age = "thirty" with a type mismatch error, and every operation on age is checked against Int‘s members. Inference never makes Kotlin dynamically typed — it only saves you from typing something the compiler could already figure out on its own.
This matters because Kotlin is often contrasted with Java, where explicit types were mandatory everywhere until Java 10 introduced a limited local-variable var. Kotlin has had full local type inference since its first stable release, and it goes further than Java’s var in several ways: it also infers the return type of single-expression functions (fun square(x: Int) = x * x), it infers generic type arguments from the arguments you pass to a function, and it infers the types of lambda parameters when the expected function type is already known from context.
Inference is strictly local, though — it never reaches across a function’s declared signature. Function parameters always need explicit types, because the compiler has nothing else to infer them from, and a function’s return type is inferred only when the function is written with the single-expression = form; a function with a { } block body defaults to returning Unit unless you write the return type explicitly. Under the hood, once a type is inferred it is compiled into bytecode exactly the same way an explicit type would be — there is no difference in the compiled program, no boxing beyond what the inferred type itself requires, and no runtime type-checking overhead.
Inference also drives generic type argument resolution. When you call listOf(1, 2, 3), the compiler doesn’t just infer each element’s type — it infers the type argument for listOf‘s generic parameter T, arriving at List<Int> for the whole expression. When there is nothing to infer from, such as emptyList() with no elements, you must supply the type argument explicitly, like emptyList<Int>(), or the surrounding context — such as an explicitly typed val — must supply it for you.
Where Inference Applies — and Where It Doesn’t
| Context | Inferred? | Example |
|---|---|---|
Local val/var with an initializer |
Yes | val x = 5 |
| Single-expression function return type | Yes | fun square(x: Int) = x * x |
| Block-bodied function return type | No — defaults to Unit unless declared |
fun square(x: Int): Int { return x * x } |
| Function parameters | No — always explicit | fun square(x: Int) |
| Generic type argument at a call site | Yes, from the arguments passed | listOf(1, 2, 3) infers List<Int> |
| Empty collection literal | No — needs an explicit type argument | emptyList<Int>() |
| Lambda parameter type | Yes, when the expected function type is known | list.map { it * 2 } |
| Recursive single-expression function return type | No — must be explicit | fun factorial(n: Int): Int = ... |
Syntax
Type inference applies in several distinct places. The general forms look like this:
val name: Type = value // explicit type (optional — inference works without it)
val name = value // type inferred from the initializer expression
var counter = 0 // type is fixed at declaration (here, Int) even though var
// lets you reassign — you cannot later assign a String
fun square(x: Int) = x * x // return type inferred as Int
fun square(x: Int): Int { /* ... */ } // block body — return type must be explicit
val doubled = list.map { it * 2 } // lambda parameter "it" type inferred from list's element type
Type— an explicit type annotation; always legal to write even when inference would find the same type, and required when the compiler cannot determine a type on its own (an empty collection literal, a barenullinitializer you intend to widen later, or a recursive function).value— the initializer expression the compiler analyzes to determine the type; it must be present for inference to have anything to work from.val/var— inference works identically for both;varonly affects whether the reference can be reassigned later, not what type it was inferred as.fun ... = expression— the single-expression function form; omit the return type and the compiler infers it from the expression’s type.it— the implicit single-parameter name in a lambda, whose type is inferred from the function type the lambda is being passed as.
Examples
Example 1: Inferring basic types
The simplest and most common form of inference happens on every val and var declaration that has an initializer. The compiler evaluates the expression on the right of = and assigns its type to the variable.
fun main() {
val name = "Kotlin"
val version = 2.0
val isAwesome = true
val releaseYear = 2011
println("$name $version, awesome=$isAwesome, since $releaseYear")
}
Output:
Kotlin 2.0, awesome=true, since 2011
Each variable here is written without any type annotation, yet each one is fully and statically typed: name is String, version is Double (because 2.0 has a decimal point), isAwesome is Boolean, and releaseYear is Int. Try assigning name = 42 after this and the compiler rejects it immediately — inference doesn’t make the variable flexible, it just means you didn’t have to type String yourself.
Example 2: Inferring a nullable function return type
Inference also applies to function return types, but only for functions written with the single-expression = form. Here parseAge‘s return type Int? is never written — the compiler infers it from String.toIntOrNull(), which itself returns a nullable Int? because parsing can fail.
fun parseAge(input: String) = input.toIntOrNull()
fun main() {
val validAge = parseAge("42")
val invalidAge = parseAge("abc")
println(validAge)
println(invalidAge)
val displayAge = validAge?.let { "Age: $it" } ?: "Unknown age"
println(displayAge)
}
Output:
42
null
Age: 42
parseAge("42") succeeds and returns the non-null value 42, while parseAge("abc") fails to parse and toIntOrNull() returns null instead of throwing — that’s why the inferred return type has to be Int? rather than plain Int. Because validAge is inferred as nullable, the compiler forces you to handle the null case before using it as a number; the ?.let { ... } ?: "Unknown age" pattern only runs the lambda when the value is non-null, and falls back to the string on the right of ?: otherwise. This is Kotlin’s null-safety system working together with inference: the inferred type carries the nullability, and the compiler enforces it at every use site.
Example 3: Inferring generic type arguments
Inference extends to generic type parameters too. When you call a generic function, the compiler works backward from the arguments you pass to determine what the type parameter must be, without you ever writing it explicitly.
data class Point(val x: Int, val y: Int)
fun <T> firstOrDefault(list: List<T>, default: T): T {
return if (list.isNotEmpty()) list[0] else default
}
fun main() {
val origin = Point(0, 0)
val points = listOf(Point(1, 2), Point(3, 4))
val first = firstOrDefault(points, origin)
println(first)
val empty = emptyList<Point>()
val fallback = firstOrDefault(empty, origin)
println(fallback)
val distance = kotlin.math.sqrt((first.x * first.x + first.y * first.y).toDouble())
println(distance)
}
Output:
Point(x=1, y=2)
Point(x=0, y=0)
2.23606797749979
firstOrDefault is declared with a generic type parameter T, but nowhere in main do we write firstOrDefault<Point>(...) — the compiler sees that points is List<Point> and origin is Point, and infers T = Point for that call. The second call passes emptyList<Point>() — note that this one does need an explicit type argument, because an empty list literal has no elements for the compiler to infer an element type from. The final line shows inference chaining through several expressions: first.x * first.x + first.y * first.y is inferred as Int, .toDouble() converts that to Double, and kotlin.math.sqrt returns a Double that distance inherits without any annotation. Note also that the readable Point(x=1, y=2) output comes from the toString() the compiler generates automatically because Point is a data class.
How It Works Step by Step
Walking through Example 2 shows the order the compiler actually works in:
- The compiler reads the declaration
fun parseAge(input: String) = input.toIntOrNull(). Because there is no{ }block, it knows this is a single-expression function and its return type must come from the expression. - It resolves
input.toIntOrNull()against the standard library’s declared signature,fun String.toIntOrNull(): Int?, and determines the expression’s type isInt?. - It records
parseAge‘s return type asInt?, exactly as if you had written it yourself — this happens once, at compile time, before any code runs. - In
main,val validAge = parseAge("42")is evaluated: the compiler looks upparseAge‘s now-known return type,Int?, and assigns that type tovalidAge. - Every later use of
validAgeis checked againstInt?. The expressionvalidAge?.let { "Age: $it" }is only legal because the compiler knowsvalidAgemight benull— it requires the safe call?.rather than allowing a direct.letcall. - At runtime, none of this repeats — the JVM bytecode simply loads and stores
Int?-typed values (boxed asIntegerreferences under the hood, since a nullable value can’t be stored as a raw primitiveint). Inference costs nothing at runtime; all the work happens once, during compilation.
Common Mistakes
Mistake 1: Declaring var x = null and expecting it to hold any type later
When the initializer is literally null with no other context, the compiler infers the narrowest possible type: Nothing?, a type whose only possible value is null itself. Trying to assign anything else afterward fails to compile.
var value = null // inferred type is Nothing?
value = "hello" // error: type mismatch — Nothing? accepts only null
Fix it by writing the intended type explicitly, since there is no useful expression for the compiler to infer from:
fun main() {
var value: String? = null
value = "hello"
println(value)
}
Output:
hello
Mistake 2: Confusing a val‘s fixed reference with its mutable contents
A common misunderstanding is thinking val means “this collection can never change.” In fact val only fixes the reference — the inferred type MutableList<Int> still allows the contents to change; what you cannot do is point scores at a different list entirely.
val scores = mutableListOf(10, 20, 30)
scores = mutableListOf(40, 50, 60) // error: val cannot be reassigned
The contents, however, are fair game through the inferred MutableList<Int> API:
fun main() {
val scores = mutableListOf(10, 20, 30)
scores.add(40)
scores[0] = 15
println(scores)
}
Output:
[15, 20, 30, 40]
Mistake 3: Omitting the return type on a recursive function
Inference for single-expression functions has one notable blind spot: it cannot resolve a function that calls itself, because determining the return type would require already knowing the return type. The compiler reports a recursive-inference error instead of guessing.
fun factorial(n: Int) = if (n <= 1) 1 else n * factorial(n - 1)
// error: type checking has run into a recursive problem —
// the compiler cannot infer the return type of a function that calls itself
Adding the return type explicitly breaks the chicken-and-egg problem:
fun factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1)
fun main() {
println(factorial(5))
}
Output:
120
Best Practices
- Let inference handle local variables by default — write
val total = items.sumOf { it.price }rather than repeating a type the compiler can already see. - Add an explicit type when it documents intent even though inference would work anyway — for example
val id: Long = 0when you specifically need aLongand the bare literal would infer asInt. - Always write explicit return types on
publicandinternalfunctions in library or API code, even when they are single-expression. It stops an accidental change to the function body from silently widening or narrowing the type your callers depend on. - Prefer
valovervarso the inferred type — and the reference itself — stay fixed; reach forvaronly when the value genuinely needs to be reassigned. - When a literal’s inferred type is not what you need — an
Intliteral where you want aLongorDouble— use a literal suffix (100L,3.0) or an explicit annotation rather than relying on an implicit conversion; Kotlin performs no implicit numeric widening. - Supply explicit generic type arguments whenever there is nothing for the compiler to infer from, such as
emptyList<String>()ormutableMapOf<String, Int>().
Practice Exercises
- Declare three variables with
val— one from an integer literal, one from a string literal, and one from the result of comparing two numbers with>— without writing any type annotations. Print each variable’s runtime class withprintln(x.javaClass)and confirm which types the compiler chose. - Write a single-expression function
fun firstWord(sentence: String) = sentence.split(" ").firstOrNull()and call it with an empty string. What type does the compiler infer for the return value, and why can’t it be plainString? - Write a recursive single-expression function that sums the digits of a positive integer, deliberately leaving off the return type first to see the compiler’s error, then add the explicit return type to fix it.
Summary
- Type inference lets the compiler determine a variable’s, expression’s, or single-expression function’s type from context, without weakening Kotlin’s static type system.
- Inference is resolved entirely at compile time; there is no runtime cost and no difference in the compiled bytecode compared to writing the type explicitly.
- Local
val/varinitializers, single-expression function return types, and generic type arguments at a call site are all inferred; function parameters and block-bodied function return types are not. var x = nullinfers the nearly uselessNothing?type — declare the intended nullable type explicitly instead.valfixes a reference, not a collection’s contents; avalholding aMutableListcan still be mutated in place.- Recursive single-expression functions need an explicit return type because the compiler cannot infer a type that depends on itself.
