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 bare null initializer 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; var only 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:

  1. 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.
  2. It resolves input.toIntOrNull() against the standard library’s declared signature, fun String.toIntOrNull(): Int?, and determines the expression’s type is Int?.
  3. It records parseAge‘s return type as Int?, exactly as if you had written it yourself — this happens once, at compile time, before any code runs.
  4. In main, val validAge = parseAge("42") is evaluated: the compiler looks up parseAge‘s now-known return type, Int?, and assigns that type to validAge.
  5. Every later use of validAge is checked against Int?. The expression validAge?.let { "Age: $it" } is only legal because the compiler knows validAge might be null — it requires the safe call ?. rather than allowing a direct .let call.
  6. At runtime, none of this repeats — the JVM bytecode simply loads and stores Int?-typed values (boxed as Integer references under the hood, since a nullable value can’t be stored as a raw primitive int). 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 = 0 when you specifically need a Long and the bare literal would infer as Int.
  • Always write explicit return types on public and internal functions 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 val over var so the inferred type — and the reference itself — stay fixed; reach for var only when the value genuinely needs to be reassigned.
  • When a literal’s inferred type is not what you need — an Int literal where you want a Long or Double — 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>() or mutableMapOf<String, Int>().

Practice Exercises

  1. 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 with println(x.javaClass) and confirm which types the compiler chose.
  2. 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 plain String?
  3. 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/var initializers, 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 = null infers the nearly useless Nothing? type — declare the intended nullable type explicitly instead.
  • val fixes a reference, not a collection’s contents; a val holding a MutableList can 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.