Type Checks and Smart Casts in Kotlin: Using `is`, `!is`, and `as` Operators

Type Checks with is and !is

Use the is operator to verify if an object matches a specific type at runtime. The negation !is checks if an object does not belong to a type.

val inputObj: Any = "Hello Kotlin"

if (inputObj is String) {
    println("String length: ${inputObj.length}")
}

if (inputObj !is String) { // Equivalent to !(inputObj is String)
    println("Not a String instance")
} else {
    println("String length: ${inputObj.length}")
}

Smart Casts

In most scenarios, explicit type casts are unnecessary in Kotlin. The compiler tracks is checks for immutable values and automatical inserts safe casts when needed.

fun showSmartCast(value: Any) {
    if (value is String) {
        // `value` is automatically cast to String here
        println("Processed string length: ${value.length}")
    }
}

The compiler recognizes safe casts even when a negative check triggers an early return:

fun processValue(value: Any) {
    if (value !is String) return
    // `value` is cast to String after the early return
    println("Valid string length: ${value.length}")
}

Smart casts also work in conditions using && and ||:

// `value` is cast to String on the right side of ||
if (value !is String || value.isEmpty()) return

// `value` is cast to String on the right side of &&
if (value is String && value.length > 5) {
    println("Long string detected: ${value.length} characters")
}

For when expressions and while loops, the compiler applies smart casts similarly:

fun evaluateInput(inputValue: Any) {
    when (inputValue) {
        is Int -> println("Incremented value: ${inputValue + 2}")
        is String -> println("String length + 1: ${inputValue.length + 1}")
        is DoubleArray -> println("Array sum: ${inputValue.sum()}")
    }
}

Rules for Smart Cast Eligibility

  • Local val variables: Always eligible, except for local delegated properties.
  • Class val properties: Eligible if the property is private or internal, and the check occurs within the same module. Not eligible for open properties or those with custom getters.
  • Local var variables: Eligible only if the variable is not modified between the check and usage, not captured in a modifying lambda, and not a local delegated property.
  • Class var properties: Never eligible, since they can be modified externally at any time.

Unsafe Type Casts with as

The as operator performs an unsafe cast that throws a ClassCastException if the conversion is not possible. To handle nullable sources, use a nullable type in the cast target:

val unknownVal: Any? = "Test String"
// Throws ClassCastException if unknownVal is not a String
val nonNullableStr: String = unknownVal as String

// Safe for nullable sources by casting to a nullable String type
val nullableStr: String? = unknownVal as String?

Safe Nullable Casts with as?

To avoid exceptions during failed casts, use the as? operator, wich returns null instead of throwing an error:

val randomObj: Any = 42
val castResult: String? = randomObj as? String // Returns null instead of throwing

Type Erasure and Generic Type Checks

Kotlin anforces generic type safety at compile time, but generic type parameters are erased at runtime. This means you cannot check if an instance belongs to a specific generic type like List<Int> at runtime. You can check for a star-projected generic type:

fun processGenericItem(something: Any) {
    if (something is List<*>) {
        something.forEach { println("Item: $it") } // Items are typed as Any?
    }
}

If the generic type's parameters are already validated at compile time, you can check the non-generic part of the type (omitting the angle brackets):

fun processStringList(stringList: List<String>) {
    if (stringList is ArrayList) {
        // `stringList` is smart-cast to ArrayList<String>
        println("ArrayList capacity: ${stringList.capacity()}")
    }
}

Reified Type Parameters for Inline Functions

Inline functions with reified type parameters preserve type information at runtime, allowing checks like arg is T. However, type erasure still applies to the generic parameters of the argument itself:

inline fun <reified FirstType, reified SecondType> Pair<*, *>.castToPair(): Pair<FirstType, SecondType>? {
    if (first !is FirstType || second !is SecondType) return null
    return first as FirstType to second as SecondType
}

val mixedPair: Pair<Any?, Any?> = "sample text" to setOf(4, 5, 6)
val stringToAny = mixedPair.castToPair<String, Any>()
val stringToInt = mixedPair.castToPair<String, Int>()
val stringToSet = mixedPair.castToPair<String, Set<*>>()
val stringToIntSet = mixedPair.castToPair<String, Set<Int>>() // Bypasses type safety

Unchecked Type Casts

When type erasure prevents runtime validation but you have logical guarantees of type safety, you can use an unchecked cast. The compiler will issue a warning, which can be suppressed with @Suppress("UNCHECKED_CAST"):

fun readConfigMap(file: File): Map<String, *> = file.inputStream().use {
    TODO("Implement map parsing from file input stream")
}

// Assume the file contains a Map<String, Boolean>
val configFile = File("boolean_config.map")
@Suppress("UNCHECKED_CAST")
val booleanConfig: Map<String, Boolean> = readConfigMap(configFile) as Map<String, Boolean>

For inline functions with reified types, you can create type-safe unchecked casts:

inline fun <reified T> List<*>.filterAndCastToList(): List<T>? =
    if (all { it is T }) {
        @Suppress("UNCHECKED_CAST")
        this as List<T>
    } else null

On the JVM, array types retain element type information (excluding nullability and generic parameters). Casts to array types are partially checked:

// Cast succeeds even if the array contains nullable or non-null List instances
val genericArray: Array<*> = arrayOf(listOf("a"), null)
val castArray = genericArray as Array<List<String>?>

Tags: kotlin Type Checks Smart Casts Type Conversion Generic Types

Posted on Wed, 17 Jun 2026 18:08:28 +0000 by footbagger