Type Conversion #
Kotlin doesn’t perform implicit type conversion — this is a deliberate design decision. In Java, you can assign an int value to a long variable without doing anything. In Kotlin, this is a compile error. Every conversion must be explicit and intentional. Sounds limiting? Quite the opposite — it eliminates an entire class of bugs arising from unexpected implicit conversions, silent overflow, and ClassCastExceptions that only explode at runtime. Kotlin provides a complete conversion toolkit: numeric conversion functions, safe casting with as?, compiler-assisted smart casts, and collection conversions. This article covers all of them along with the idiomatic patterns that make type conversion safe and expressive.
Why There’s No Implicit Conversion #
Before diving into conversion mechanisms, it’s important to understand why Kotlin chose this path.
// In Java, this is valid — implicit widening:
// int i = 100;
// long l = i; // OK, no cast needed
// In Kotlin, this is an ERROR:
val i: Int = 100
val l: Long = i // ERROR: Type mismatch. Required: Long. Found: Int.
// Must be explicit:
val l: Long = i.toLong()
// Why does this matter? Look at this example:
fun prosesAngka(nilai: Long) { println(nilai) }
val angka: Int = 100
prosesAngka(angka) // ERROR — can't pass implicitly
prosesAngka(angka.toLong()) // Correct — explicit and clear conversion
flowchart LR
A["Java\nImplicit Widening"] --> B["int → long\nOK without a cast"]
A --> C["Can cause bugs\nthat are hard to trace"]
D["Kotlin\nExplicit Conversion"] --> E["Int → Long\nmust use .toLong()"]
D --> F["Conversions are always\nvisible and intentional"]Conversion Between Numeric Types #
Every Kotlin numeric type (Byte, Short, Int, Long, Float, Double) has conversion functions to other types.
Standard Conversion Functions #
val angka: Int = 42
// To larger types (widening)
val l: Long = angka.toLong() // 42L
val d: Double = angka.toDouble() // 42.0
val f: Float = angka.toFloat() // 42.0f
// To smaller types (narrowing — can lose precision!)
val b: Byte = angka.toByte() // 42
val s: Short = angka.toShort() // 42
// From Double to Int — the decimal part is discarded (not rounded)
val pi = 3.14159
val piInt = pi.toInt() // 3, not 3 or 4
val piLong = pi.toLong() // 3L
// From String to numeric (will crash if invalid!)
val teks = "123"
val n = teks.toInt() // 123
val nn = "abc".toInt() // NumberFormatException!
Overflow During Narrowing #
Converting to a smaller type can produce unexpected values due to overflow — bits that don’t fit get truncated.
// ANTI-PATTERN: narrowing without considering overflow
val besarSekali: Int = 300
val kecil: Byte = besarSekali.toByte() // overflow! the result is 44, not 300
val jutaan: Long = 1_000_000_000_000L
val overflow: Int = jutaan.toInt() // overflow! the result is -727_379_968
// CORRECT: check the range before narrowing if the value is unknown
fun safeToInt(nilai: Long): Int? {
return if (nilai in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) {
nilai.toInt()
} else null
}
// Or use coerceIn to clamp the value
val dibatasi = jutaan.coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
Converting fromDoubletoIntwith.toInt()truncates the decimal part, not rounds. If you need rounding, usekotlin.math.round(nilai).toInt()orMath.round(nilai).toInt()first.
Conversions with Different Representations #
// Char to Int (ASCII/Unicode code)
val huruf = 'A'
val kode = huruf.code // 65
val kembali = kode.toChar() // 'A'
// Int to different representations
val n = 255
val hex = n.toString(16) // "ff"
val biner = n.toString(2) // "11111111"
val oktal = n.toString(8) // "377"
// Parsing from different representations
val dariHex = "ff".toInt(16) // 255
val dariBiner = "11111111".toInt(2) // 255
// Bit operations
val a = 0b1010 // 10
val b = 0b1100 // 12
val dan = a and b // 8 (0b1000)
val atau = a or b // 14 (0b1110)
val xor = a xor b // 6 (0b0110)
val inv = a.inv() // -11
val geser = a shl 1 // 20 (left shift 1 bit)
String to Numeric Conversion — Safe vs Unsafe #
This is one of the most common bug sources: converting user input or external data to numbers without anticipating failure.
Unsafe Conversion — Don’t Use on User Input #
// ANTI-PATTERN: calling toInt() directly without error handling
fun prosesUsia(input: String): Int {
return input.toInt() // crashes if the input isn't a number!
}
prosesUsia("25") // OK: 25
prosesUsia("") // NumberFormatException!
prosesUsia("dua") // NumberFormatException!
prosesUsia("25.5") // NumberFormatException!
Safe Conversion with OrNull #
Kotlin provides an *OrNull variant for every conversion — returning null on failure instead of throwing an exception.
// CORRECT: use OrNull for untrusted input
fun prosesUsia(input: String): Int? {
return input.toIntOrNull()
}
"25".toIntOrNull() // 25
"".toIntOrNull() // null
"dua".toIntOrNull() // null
"25.5".toIntOrNull() // null (not a valid Double for Int)
"25.5".toDoubleOrNull() // 25.5 (valid as a Double)
// Every type has an OrNull variant
"3.14".toFloatOrNull() // 3.14f
"100".toLongOrNull() // 100L
"0.5".toDoubleOrNull() // 0.5
// Combining with Elvis for default values
val usia = input.toIntOrNull() ?: 0
val harga = inputHarga.toDoubleOrNull() ?: 0.0
// Combining with let for further validation
val usiaValid = input
.toIntOrNull()
?.takeIf { it in 0..150 }
?: throw IllegalArgumentException("Invalid age: $input")
// Reusable validation functions
fun String.toIntOrDefault(default: Int = 0) = toIntOrNull() ?: default
fun String.toDoubleOrDefault(default: Double = 0.0) = toDoubleOrNull() ?: default
val n = "abc".toIntOrDefault(99) // 99
Parsing Numbers from Formatted Strings #
import java.text.NumberFormat
import java.util.Locale
// Parsing numbers with thousands separators
fun parseAngkaIndonesia(teks: String): Double? {
return try {
val format = NumberFormat.getInstance(Locale("id", "ID"))
format.parse(teks.replace("Rp", "").trim())?.toDouble()
} catch (e: Exception) {
null
}
}
parseAngkaIndonesia("Rp 1.500.000") // 1500000.0
parseAngkaIndonesia("15.000,50") // 15000.5
// Parsing percentages
fun parsePersentase(teks: String): Double? {
return teks.removeSuffix("%").trim().toDoubleOrNull()?.div(100)
}
parsePersentase("85%") // 0.85
parsePersentase("12.5%") // 0.125
Type Casting — as and as? #
Casting in Kotlin is divided into two: the unsafe cast (as) which throws an exception on failure, and the safe cast (as?) which returns null.
Unsafe Cast — as #
// as: throws ClassCastException if the type doesn't match
val objek: Any = "Hello Kotlin"
val teks: String = objek as String // OK
val angka: Int = objek as Int // ClassCastException!
// When as makes sense: when you're CERTAIN of the type
fun prosesEvent(event: Any) {
// Already checked earlier that event is a ClickEvent
val click = event as ClickEvent
klik(click.koordinat)
}
Safe Cast — as? #
// ANTI-PATTERN: unsafe cast without a type guarantee
val objek: Any = ambilDariLuar()
val teks = objek as String // can crash!
// CORRECT: safe cast, the result is nullable
val teks: String? = objek as? String // null if not a String
// Combining with let or Elvis
val panjang = (objek as? String)?.length ?: 0
val nilaiInt = (objek as? Int) ?: -1
// Safe casts in a when expression
fun deskripsi(nilai: Any): String = when (nilai) {
is String -> "A String with length ${nilai.length}"
is Int -> "An Int with value $nilai"
is List<*> -> "A List with ${nilai.size} elements"
else -> "Unknown type: ${nilai::class.simpleName}"
}
// as? is very useful when parsing data from JSON or APIs
data class Respons(val data: Any?)
fun ambilNama(respons: Respons): String? {
val dataMap = respons.data as? Map<*, *> ?: return null
return dataMap["nama"] as? String
}
Smart Casts #
Smart cast is a Kotlin compiler feature that automatically converts a type after an is check — you don’t need a manual cast after a type check.
Smart Casts with is #
fun prosesNilai(nilai: Any) {
// Without smart cast — needs a manual cast (like Java)
if (nilai is String) {
val teks = nilai as String // redundant!
println(teks.uppercase())
}
// With smart cast — Kotlin knows the type after is
if (nilai is String) {
println(nilai.uppercase()) // nilai is automatically a String here
println(nilai.length) // can also access length
}
// Smart casts in when — the most elegant
when (nilai) {
is String -> println("String: ${nilai.uppercase()}") // nilai = String
is Int -> println("Int squared: ${nilai * nilai}") // nilai = Int
is List<*> -> println("List: ${nilai.size} elements") // nilai = List<*>
is Boolean -> println("Boolean: ${if (nilai) "yes" else "no"}")
else -> println("Unknown")
}
}
Smart Casts with Null Checks #
fun prosesNama(nama: String?) {
// Smart cast after a null check
if (nama != null) {
println(nama.uppercase()) // nama = String (not String?) here
println(nama.length) // no ?. needed anymore
}
// Also works with Elvis
val panjang = nama?.length ?: return // after this nama can't be null
println("Length: $panjang")
// require / check also trigger smart casts
requireNotNull(nama) { "Name must not be null" }
println(nama.uppercase()) // smart cast: nama = String
}
// Smart casts and && conditions
fun validasiInput(input: Any?) {
if (input != null && input is String && input.length > 3) {
println(input.uppercase()) // layered smart cast
}
}
Smart Cast Limitations #
Smart casts can’t always be applied — the compiler only guarantees a smart cast if the variable can’t change between the check and the use.
class Pengguna {
var nama: String? = "Andi" // var — can change at any time!
}
val pengguna = Pengguna()
// Smart cast FAILS for a var property — the compiler can't guarantee it
if (pengguna.nama != null) {
println(pengguna.nama.length) // ERROR: Smart cast to 'String' is impossible
// because pengguna.nama could change from another thread between the check and the access
}
// SOLUTION 1: copy to a local val
val nama = pengguna.nama
if (nama != null) {
println(nama.length) // OK — nama is a local val, can't change
}
// SOLUTION 2: use a safe call
println(pengguna.nama?.length)
// Smart cast WORKS for:
// - local vals without custom getters
// - val properties (not var)
// - function parameters
val nilai: Any = ambilNilai()
if (nilai is String) {
println(nilai.length) // OK — nilai is a local val
}
Conversions Between Collections #
Kotlin provides rich conversion functions between various collection types.
val list = listOf(3, 1, 4, 1, 5, 9, 2, 6, 5, 3)
// List → Set (removes duplicates)
val set: Set<Int> = list.toSet() // {3, 1, 4, 5, 9, 2, 6}
val mutableSet: MutableSet<Int> = list.toMutableSet()
// List → MutableList
val mutableList: MutableList<Int> = list.toMutableList()
// Set → List (order not guaranteed)
val listDariSet: List<Int> = set.toList()
// List → Map (with a key function)
data class Produk(val id: Int, val nama: String, val harga: Double)
val produk = listOf(
Produk(1, "Laptop", 15_000_000.0),
Produk(2, "Mouse", 250_000.0),
Produk(3, "Keyboard", 800_000.0)
)
val produkById: Map<Int, Produk> = produk.associateBy { it.id }
val namaDanHarga: Map<String, Double> = produk.associate { it.nama to it.harga }
// Map → List of Pairs
val pairs: List<Pair<Int, Produk>> = produkById.toList()
// Array ↔ List
val array = intArrayOf(1, 2, 3, 4, 5)
val dariArray: List<Int> = array.toList()
val keArray: IntArray = dariArray.toIntArray()
// Generic arrays
val arrayOf = arrayOf("apel", "jeruk", "mangga")
val listDariArray = arrayOf.toList()
val kembaliArray = listDariArray.toTypedArray()
// Sequence ↔ List
val sequence = list.asSequence()
val kembaliList = sequence.toList()
Special Type Conversions #
Any and Unit #
// Any is the supertype of all non-null types in Kotlin
val apapun: Any = "can be a string"
val apapun2: Any = 42
val apapun3: Any = listOf(1, 2, 3)
// Unit is the replacement for void — there's always one instance
fun tidakKembalikanNilai(): Unit { println("Halo") }
fun tidakKembalikanNilai2() { println("Halo") } // Unit is implicit
// Unit can be used as a type argument
val fungsiUnit: () -> Unit = { println("Halo") }
val listUnit: List<Unit> = List(3) { Unit } // [Unit, Unit, Unit]
// Nothing — a type that never has a value, for functions that don't return
fun lemparError(pesan: String): Nothing {
throw IllegalStateException(pesan)
}
// Nothing is useful for type inference
val nilai = if (kondisi) "ada" else lemparError("tidak ada nilai")
// nilai is a String, not Any
Enum Conversion #
enum class Status { AKTIF, NONAKTIF, PENDING }
// String → Enum
val status = Status.valueOf("AKTIF") // Status.AKTIF
val status2 = enumValueOf<Status>("NONAKTIF") // Status.NONAKTIF
"INVALID".let { runCatching { Status.valueOf(it) }.getOrNull() } // null
// Int → Enum via ordinal
val ordinal = 0
val statusDariOrdinal = Status.entries[ordinal] // Status.AKTIF
// Enum → String
val namaStatus = Status.AKTIF.name // "AKTIF"
val ordinalStatus = Status.AKTIF.ordinal // 0
// Safe pattern: conversion with a default
fun String.toStatusOrNull(): Status? = runCatching {
Status.valueOf(uppercase())
}.getOrNull()
fun String.toStatusOrDefault(default: Status = Status.PENDING): Status =
toStatusOrNull() ?: default
"aktif".toStatusOrDefault() // Status.AKTIF (case-insensitive)
"invalid".toStatusOrDefault() // Status.PENDING
Boolean Conversion #
// String → Boolean
"true".toBoolean() // true
"false".toBoolean() // false
"TRUE".toBoolean() // true (case-insensitive)
"yes".toBoolean() // false (only "true" is true)
// toBooleanStrictOrNull — stricter, null for anything other than "true"/"false"
"true".toBooleanStrictOrNull() // true
"false".toBooleanStrictOrNull() // false
"yes".toBooleanStrictOrNull() // null
"1".toBooleanStrictOrNull() // null
// Int → Boolean (convention)
fun Int.toBoolean() = this != 0
val flagAktif: Boolean = 1.toBoolean() // true
val flagNon: Boolean = 0.toBoolean() // false
Idiomatic Patterns for Safe Conversion #
Conversion Pipelines with Validation #
// Processing complex form input
data class FormRegistrasi(
val usiaInput: String,
val gajiInput: String,
val aktifInput: String
)
data class DataUser(val usia: Int, val gaji: Double, val aktif: Boolean)
fun parseFormRegistrasi(form: FormRegistrasi): Result<DataUser> {
val usia = form.usiaInput.toIntOrNull()
?: return Result.failure(IllegalArgumentException("Age must be a number"))
if (usia !in 18..100) {
return Result.failure(IllegalArgumentException("Age must be between 18-100"))
}
val gaji = form.gajiInput.toDoubleOrNull()
?: return Result.failure(IllegalArgumentException("Salary must be a number"))
if (gaji < 0) {
return Result.failure(IllegalArgumentException("Salary must not be negative"))
}
val aktif = form.aktifInput.toBooleanStrictOrNull()
?: return Result.failure(IllegalArgumentException("Active status must be 'true' or 'false'"))
return Result.success(DataUser(usia, gaji, aktif))
}
Safe Casts in Layered Architectures #
// A common pattern when working with dynamic API responses
fun <T> parseRespom(json: Map<String, Any?>, kunci: String, tipe: Class<T>): T? {
val nilai = json[kunci] ?: return null
return tipe.cast(nilai)
}
// Or with a reified type parameter (more idiomatic in Kotlin)
inline fun <reified T> Map<String, Any?>.getAs(kunci: String): T? {
return this[kunci] as? T
}
val respons: Map<String, Any?> = mapOf(
"nama" to "Andi",
"usia" to 25,
"aktif" to true,
"skor" to 98.5
)
val nama: String? = respons.getAs("nama") // "Andi"
val usia: Int? = respons.getAs("usia") // 25
val aktif: Boolean? = respons.getAs("aktif") // true
val invalid: Int? = respons.getAs("nama") // null (nama is a String, not an Int)
Decision Tree — Choosing the Right Conversion #
flowchart TD
A{What do you want\nto convert?} --> B["Numeric to numeric\n(Int ↔ Long ↔ Double)"]
A --> C["String to numeric\n(user input / API)"]
A --> D["Unknown type\n(Any / Object)"]
A --> E["Collection to\nanother collection"]
A --> F["String / Int to\nan Enum"]
B --> B1["Use .toInt()\n.toLong() .toDouble()\nWatch for overflow\nwhen narrowing"]
C --> C1{Trusted data\nsource?}
C1 -- Yes --> C2[".toInt()\n.toDouble()\netc."]
C1 -- No --> C3[".toIntOrNull()\n.toDoubleOrNull()\n+ Elvis for defaults"]
D --> D1{Certain of the type?}
D1 -- Yes --> D2["as Type\n(crashes if wrong)"]
D1 -- No --> D3["as? Type\n(null if wrong)\nor is + smart cast"]
E --> E1[".toList() .toSet()\n.toMutableList()\n.associateBy { }\n.toTypedArray()"]
F --> F1["Enum.valueOf()\nor .toStatusOrNull()\nfor error handling"]Summary #
- No implicit conversion in Kotlin — every numeric type conversion must be explicit with
.toInt(),.toLong(),.toDouble(), and so on. This is a deliberate design to eliminate bugs from unexpected conversions.- Narrowing can overflow — converting from a large type to a small one (
LongtoInt,DoubletoInt) can produce unexpected values. Check the range first or usecoerceInif the value is unknown..toIntOrNull()not.toInt()for untrusted input (forms, APIs, files). The*OrNullvariants returnnullinstead of throwingNumberFormatException.as?notasfor safe casts —as?returnsnullif the type doesn’t match;asthrowsClassCastException. Useasonly when you’re truly certain of the type.- Smart casts apply automatically after
isor null checks onvals and parameters. They don’t apply tovarproperties because those could change from another thread.- Smart cast limitations on
varproperties — copy to a localvalfirst, then check and access the local variable.when+isis the most idiomatic way to handle many different types — smart casts apply automatically in every branch.- Collection conversions use
.toList(),.toSet(),.toMutableList(),.associateBy { }— all produce new collections, never modifying the original.- Enum conversion is safest with
runCatching { Status.valueOf(input) }.getOrNull()or a custom extension function —valueOf()throws an exception directly for invalid input.DoubletoInttruncates the decimal, not rounds. Usekotlin.math.round(nilai).toInt()if you need rounding.