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 from Double to Int with .toInt() truncates the decimal part, not rounds. If you need rounding, use kotlin.math.round(nilai).toInt() or Math.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 (Long to Int, Double to Int) can produce unexpected values. Check the range first or use coerceIn if the value is unknown.
  • .toIntOrNull() not .toInt() for untrusted input (forms, APIs, files). The *OrNull variants return null instead of throwing NumberFormatException.
  • as? not as for safe casts — as? returns null if the type doesn’t match; as throws ClassCastException. Use as only when you’re truly certain of the type.
  • Smart casts apply automatically after is or null checks on vals and parameters. They don’t apply to var properties because those could change from another thread.
  • Smart cast limitations on var properties — copy to a local val first, then check and access the local variable.
  • when + is is 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.
  • Double to Int truncates the decimal, not rounds. Use kotlin.math.round(nilai).toInt() if you need rounding.

← Previous: Higher-Order Functions   Next: Numbers →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact