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 String → Int |
On a String, throws NumberFormatException if the text isn’t a valid integer |
toLong() |
Number or String → Long |
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 |
Char → Int |
The Unicode code point of the character |
Int.toChar() |
Int → Char |
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 (
InttoLong,InttoDouble) reinterprets the same value using more bits or a floating-point representation. Narrowing (InttoByte,LongtoInt) 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 why300.toByte()becomes44rather 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
.01or.99. There’s no rounding involved unless you ask for it explicitly with a function likeroundToInt(). - 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. throwNumberFormatException, while theOrNull()variants catch that failure internally and returnnullinstead — 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()‘sInt?) 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 thenullcase explicitly rather than letting aNumberFormatExceptioncrash the program. - Use
roundToInt()fromkotlin.mathwhen you actually want rounding; don’t rely ontoInt()‘s truncation and assume it’s rounding. - Validate the range before narrowing a value (
InttoByte/Short,LongtoInt) 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 anAny), and use thetoX()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 toInt, and prints their average as aDouble. It should skip the invalid entry and print an average of71.66666666666667. - Write a program that stores a
Longvalue of5_000_000_000(five billion), converts it toInt, and prints the result. Add a comment explaining why the printed value looks nothing like five billion. - Write a program that stores a
Doubleprice of19.999, and prints both the truncated integer part (viatoInt()) and the rounded integer part (viaroundToInt()), 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()ortoDouble(). - Narrowing conversions (
LongtoInt,InttoByte) 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()fromkotlin.mathfor real rounding. String-to-number conversions can fail:toInt()/toDouble()throwNumberFormatExceptionon invalid text, whiletoIntOrNull()/toDoubleOrNull()returnnullinstead.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;.codeandInt.toChar()convert between aCharand its Unicode code point.
