Type Conversion

Type conversion is how you turn a value of one Kotlin type into a value of another type — for example, turning an Int into a Double, a String into an Int, or a Long into a Byte. Unlike Java, C, or Python, Kotlin never converts numeric types for you automatically, even when the conversion looks completely safe. That strictness is deliberate: it turns silent data loss and “why is this the wrong type” bugs into compile-time errors instead of runtime surprises. This lesson covers the full picture — every built-in conversion function, how String parsing works (and fails), what happens when a large value doesn’t fit into a smaller type, and the specific mistakes that trip up almost everyone arriving from a language with implicit conversions.

Overview: How Type Conversion Works in Kotlin

In Java, an int is automatically “widened” to a long or a double wherever one is expected — you can write long l = 5; and the compiler quietly inserts the conversion. Kotlin does not do this, for any numeric type, in either direction. Byte, Short, Int, Long, Float, and Double are all distinct, unrelated types in Kotlin’s type system — none is a subtype of another, even though every value of a smaller type fits inside a larger one. If you write val l: Long = someInt where someInt is an Int, the compiler rejects it with a type mismatch. You must convert explicitly.

Every numeric type exposes a family of conversion methods: toByte(), toShort(), toInt(), toLong(), toFloat(), and toDouble(). Calling one of these on a number produces a new value of the target type. Converting to a “larger” type (an Int to a Double, say) is always safe. Converting to a “smaller” type (a Long to an Int, a Double to an Int) can lose information — and Kotlin lets you do it anyway, because sometimes you know the value fits. When it doesn’t fit, the result is truncated, not rounded and not rejected at runtime: a fractional Double loses everything after the decimal point, and an out-of-range integer wraps around based on the bits that remain. There’s more on exactly how that truncation happens in the “How it works” section below.

String conversion works differently because parsing text can fail in a way that converting one number type to another cannot. "123".toInt() succeeds and returns 123, but "abc".toInt() throws a NumberFormatException at runtime — the compiler cannot know at compile time whether a String holds valid digits. For exactly this reason, the standard library also provides null-returning variants — toIntOrNull(), toDoubleOrNull(), and so on — which return null instead of throwing when parsing fails, letting you handle bad input with Kotlin’s ordinary null-safety tools instead of exceptions.

It’s worth distinguishing type conversion from type casting. The as and as? operators cast a reference from one type to another within a class hierarchy — for example, treating a value known only as Any back as the specific type you know it really is. They do not convert between unrelated numeric types; using as to turn an Int into a Double is a compile error, not a conversion. Finally, every type has a toString() that always succeeds, and Char converts to and from its numeric Unicode code point via the .code property and Int.toChar().

Syntax

The general shape of a conversion is always a method call on the value you already have, named after the type you want:

value.toType()
"stringValue".toType()       // may throw NumberFormatException
"stringValue".toTypeOrNull() // returns null instead of throwing

The table below lists the conversion functions you’ll use most often.

Function Converts Behavior
toByte(), toShort() Any number → Byte/Short Narrowing; truncates bits if the value doesn’t fit in the target range
toInt() Number or StringInt On a String, throws NumberFormatException if the text isn’t a valid integer
toLong() Number or StringLong Widening for numeric sources; can throw for invalid String input
toFloat(), toDouble() Number or String → floating-point Standard target for calculations that need a fractional part
toString() Any type → String Defined on every type; always succeeds
toIntOrNull(), toDoubleOrNull(), etc. String → nullable number Returns null instead of throwing on invalid text
.code CharInt The Unicode code point of the character
Int.toChar() IntChar The character for that Unicode code point

Examples

Example 1: Converting Between Numeric Types

fun main() {
    val intValue: Int = 42
    val longValue: Long = intValue.toLong()
    val doubleValue: Double = intValue.toDouble()
    val byteValue: Byte = intValue.toByte()

    println("Int: $intValue")
    println("Long: $longValue")
    println("Double: $doubleValue")
    println("Byte: $byteValue")
}

Output:

Int: 42
Long: 42
Double: 42.0
Byte: 42

Each toX() call produces a brand-new value of the target type; intValue itself is untouched and stays an Int. Notice that Double prints with a trailing .0 — converting an integer to a floating-point type always keeps that fractional part, even when it’s zero.

Example 2: Converting Between String and Number

fun main() {
    val text = "123"
    val number: Int = text.toInt()
    println("Parsed: $number")

    val invalidText = "abc"
    val safeNumber: Int? = invalidText.toIntOrNull()
    println("Safe parse result: $safeNumber")

    val backToString: String = number.toString()
    println("Back to string: $backToString")
}

Output:

Parsed: 123
Safe parse result: null
Back to string: 123

"123".toInt() succeeds because every character is a digit. "abc".toIntOrNull() can’t be parsed as a number, so it returns null instead of crashing — that’s why safeNumber has to be declared as the nullable type Int?. Converting back the other direction, number.toString(), always succeeds because every type can describe itself as text.

Example 3: A Realistic Case — Parsing a Batch of Input

fun main() {
    val rawInputs = listOf("10", "25", "notANumber", "7")
    val validNumbers = mutableListOf<Int>()

    for (input in rawInputs) {
        val parsed = input.toIntOrNull()
        if (parsed != null) {
            validNumbers.add(parsed)
        } else {
            println("Skipping invalid input: $input")
        }
    }

    val total: Int = validNumbers.sum()
    val average: Double = total.toDouble() / validNumbers.size

    println("Valid numbers: $validNumbers")
    println("Total: $total")
    println("Average: $average")
}

Output:

Skipping invalid input: notANumber
Valid numbers: [10, 25, 7]
Total: 42
Average: 14.0

This mirrors what happens with real-world input like a form field or a CSV column: not everything is guaranteed to be a valid number, so toIntOrNull() filters out the bad entry without crashing the whole program. Notice the final division: validNumbers.size is an Int, and dividing two Ints in Kotlin performs integer division, which would silently discard the remainder. Converting total to Double first — total.toDouble() / validNumbers.size — forces the division to happen in floating-point, giving the correct average of 14.0 instead of a truncated integer.

Example 4: Narrowing, Truncation, and Overflow

fun main() {
    val bigNumber: Int = 300
    val truncated: Byte = bigNumber.toByte()

    val price: Double = 9.99
    val wholePart: Int = price.toInt()

    println("Original Int: $bigNumber")
    println("Truncated to Byte: $truncated")
    println("Double $price truncated to Int: $wholePart")
}

Output:

Original Int: 300
Truncated to Byte: 44
Double 9.99 truncated to Int: 9

A Byte can only hold values from -128 to 127, but 300 doesn’t fit. Kotlin doesn’t refuse this conversion or throw an exception — it silently keeps the lowest 8 bits of the value, which come out to 44. This is the single most dangerous thing about narrowing numeric conversions: the code compiles, runs, and produces a wrong-looking value without any warning. The Double conversion shows a related but different trap: price.toInt() doesn’t round 9.99 to 10, it truncates toward zero and returns 9.

How It Works Step by Step

When the compiler sees value.toX(), two very different things can happen depending on whether value is a number or a String:

  • Numeric-to-numeric conversion happens entirely at the bit level, with no possibility of failure at runtime. Widening (Int to Long, Int to Double) reinterprets the same value using more bits or a floating-point representation. Narrowing (Int to Byte, Long to Int) keeps only the low-order bits that fit in the destination type — if the original value doesn’t fit, what comes out depends purely on which bits survive, which is why 300.toByte() becomes 44 rather than an error.
  • Double/Float-to-integer conversion truncates toward zero: everything after the decimal point is simply dropped, regardless of whether the fractional part was .01 or .99. There’s no rounding involved unless you ask for it explicitly with a function like roundToInt().
  • String-to-number conversion is a real parsing operation. The runtime scans the string’s characters, and if every character forms a valid number in the target format, it returns the parsed value. If parsing fails, toInt()/toDouble()/etc. throw NumberFormatException, while the OrNull() variants catch that failure internally and return null instead — the same outcome, just channeled through Kotlin’s null-safety system instead of an exception.
  • Compile-time checking happens before any of this runs: the compiler verifies that you never assign a value of one numeric type directly to a variable declared as another, and that you never treat a nullable result (like toIntOrNull()‘s Int?) as if it were guaranteed non-null without a check. Both of these are caught before your program ever executes, which is the whole point of Kotlin’s strict, explicit conversion model.

Common Mistakes

Mistake 1: Assuming Numbers Widen Automatically, Like in Java

Coming from Java, it’s natural to expect an Int to slot into a Long variable without any extra syntax. Kotlin refuses:

fun main() {
    val i: Int = 10
    val l: Long = i
    println(l)
}

This fails to compile with a type mismatch — Kotlin has no implicit widening at all, even from a smaller type to a larger one. The fix is to call the conversion function explicitly:

val i: Int = 10
val l: Long = i.toLong()
println(l)

Mistake 2: Assuming toInt() Rounds a Decimal

It’s easy to assume toInt() behaves like everyday rounding. It doesn’t — it always truncates toward zero:

fun main() {
    val price: Double = 9.99
    val dollars: Int = price.toInt()
    println("Rounded price: $dollars")
}

This compiles and runs fine, but the output — Rounded price: 9 — is almost certainly not what the variable name promised. If you actually want rounding, use roundToInt() from kotlin.math instead:

import kotlin.math.roundToInt

fun main() {
    val price: Double = 9.99
    val dollars: Int = price.roundToInt()
    println("Rounded price: $dollars")
}

Mistake 3: Using toInt() on Untrusted Input

Calling toInt() directly on a String you don’t control — user input, a file, a network response — is a crash waiting to happen:

fun main() {
    val userInput = "twenty"
    val age: Int = userInput.toInt()
    println("Age: $age")
}

Because "twenty" isn’t a valid integer, this throws NumberFormatException at runtime and the program terminates before the println ever runs. Prefer toIntOrNull() and handle the failure case explicitly:

fun main() {
    val userInput = "twenty"
    val age: Int? = userInput.toIntOrNull()

    if (age != null) {
        println("Age: $age")
    } else {
        println("Invalid age input: $userInput")
    }
}

Mistake 4: Reaching for as Instead of a Conversion Function

as is a type cast for class hierarchies, not a numeric conversion tool. Trying to use it between unrelated numeric types doesn’t work the way some readers expect:

fun main() {
    val number: Int = 5
    val converted: Double = number as Double
    println(converted)
}

Int and Double are unrelated types with no inheritance relationship, so this cast can never succeed and the compiler rejects it outright. Use the dedicated conversion function instead:

val number: Int = 5
val converted: Double = number.toDouble()
println(converted)

Best Practices

  • Reach for toIntOrNull(), toDoubleOrNull(), and their siblings whenever input isn’t guaranteed valid — parsing user input, files, or network data — and handle the null case explicitly rather than letting a NumberFormatException crash the program.
  • Use roundToInt() from kotlin.math when you actually want rounding; don’t rely on toInt()‘s truncation and assume it’s rounding.
  • Validate the range before narrowing a value (Int to Byte/Short, Long to Int) if correctness matters — the conversion itself will silently truncate rather than fail.
  • Keep as/as? for genuine class-hierarchy casts (like recovering a specific type from an Any), and use the toX() functions for anything numeric.
  • Minimize unnecessary back-and-forth conversions between numeric types — every narrowing conversion is a chance to lose precision, so convert once, close to where the value is produced, rather than repeatedly through a calculation chain.
  • When a function could receive either valid or invalid text, let its return type reflect that with a nullable result rather than throwing — it keeps the failure visible in the type system instead of buried in documentation.

Practice Exercises

  • Write a program that takes a List<String> of temperature readings (some valid, some not, e.g. listOf("72", "68", "N/A", "75")), converts the valid ones to Int, and prints their average as a Double. It should skip the invalid entry and print an average of 71.66666666666667.
  • Write a program that stores a Long value of 5_000_000_000 (five billion), converts it to Int, and prints the result. Add a comment explaining why the printed value looks nothing like five billion.
  • Write a program that stores a Double price of 19.999, and prints both the truncated integer part (via toInt()) and the rounded integer part (via roundToInt()), so you can compare the two directly.

Summary

  • Kotlin never converts numeric types implicitly — every conversion, widening or narrowing, must be written explicitly with a function like toInt() or toDouble().
  • Narrowing conversions (Long to Int, Int to Byte) truncate to the bits that fit, which can silently produce a very different-looking number if the original value was out of range.
  • Converting a floating-point value to an integer type truncates toward zero — it does not round. Use roundToInt() from kotlin.math for real rounding.
  • String-to-number conversions can fail: toInt()/toDouble() throw NumberFormatException on invalid text, while toIntOrNull()/toDoubleOrNull() return null instead.
  • as/as? are for casting between related reference types, not for converting between unrelated numeric types — the compiler rejects that use outright.
  • toString() converts any value to text and always succeeds; .code and Int.toChar() convert between a Char and its Unicode code point.