Skip to content
CalliCoder

Kotlin Type Checks and Smart Casts

Published Updated Kotlin 13 min read

The is operator makes the cast unnecessary, the four situations where the compiler refuses to smart-cast, and why as? plus Elvis beats a try around a ClassCastException.

Java’s instanceof check is followed by a cast that repeats what the check just proved. Kotlin removes the second step, after if (x is String), x is a String inside that branch. The interesting part is the handful of cases where the compiler declines, all of which have a concrete reason.

Written against Kotlin 1.9.

is and !is

fun describe(value: Any): String {
    if (value is String) {
        return "string of length ${value.length}"   // no cast needed
    }
    if (value !is Number) {
        return "not a number"
    }
    return "number ${value.toDouble()}"             // smart cast to Number
}

!is is the negated form and it smart-casts too, after an early return, the compiler knows the type for the rest of the function.

The same works in a when:

fun area(shape: Any): Double = when (shape) {
    is Circle    -> Math.PI * shape.radius * shape.radius
    is Rectangle -> shape.width * shape.height
    is String    -> shape.toDoubleOrNull() ?: 0.0
    else         -> 0.0
}

And in the right-hand side of && and ||, because the left side has already been evaluated:

if (value is String && value.length > 3) { }
if (value !is String || value.length > 3) { }

Where smart casts do not apply

Four cases, and each one is the compiler refusing to assume something it cannot prove.

A var local that a lambda could change:

var value: Any = "text"
if (value is String) {
    println(value.length)   // works — nothing between the check and the use can change it
}

That one does work, because the compiler can see the whole scope. This does not:

var value: Any = "text"
run { value = 42 }
if (value is String) {
    println(value.length)   // error: captured by a changing closure
}

A var property of a class:

class Holder {
    var value: Any = "text"
}

fun use(holder: Holder) {
    if (holder.value is String) {
        println(holder.value.length)   // error
    }
}

Another thread, or another method called in between, could reassign it. The check and the use are two separate reads, and nothing guarantees they see the same value.

A property with a custom getter, or an open one:

open class Base {
    open val value: Any get() = "text"   // may return anything, and may be overridden
}

A getter is a function call, so two reads can return two different objects.

A property in another module, because it could gain a custom getter without this module being recompiled.

The fix in every case is the same, copy it into a local val:

val value = holder.value
if (value is String) {
    println(value.length)   // fine
}

Kotlin 2.0’s data-flow analysis widened the set of cases that work, so some code that needed the local no longer does. Writing the local anyway is clearer about what is being checked, and it does not depend on which compiler version builds the file.

Explicit casts

val text = value as String     // ClassCastException if it is not
val text = value as? String    // null if it is not

as throws. as? returns null, and pairs naturally with Elvis:

val length = (value as? String)?.length ?: 0

That is the idiom to prefer. Catching ClassCastException to do the same thing is both slower and less precise: the catch also swallows a cast failure from somewhere deeper in the block.

as? on a non-nullable target still produces a nullable result, since failing must have a value to return. It also returns null for a null receiver, so it covers both the wrong-type and the no-value cases in one operator.

Generics and erasure

if (list is List<String>) { }        // error: cannot check for erased type
if (list is List<*>) { }             // fine — checks it is a List of something

Type arguments are erased at runtime, so there is nothing left to check. List<*> is the star projection: the check succeeds for any element type, and reading from it gives Any?.

A reified type parameter is the way to make the check possible, and it requires an inline function:

inline fun <reified T> Any.asOrNull(): T? = this as? T

val name = value.asOrNull<String>()

reified works because the compiler substitutes the concrete type at every call site, so the erased parameter never exists. It is only available on inline functions for exactly that reason.

Note the limit: reified recovers the outer type, not the arguments. asOrNull<List<String>>() still cannot verify the elements, and the compiler warns that the check is unchecked.

Sealed types remove the check

The strongest version of this is not to test types at all:

sealed interface Shape {
    data class Circle(val radius: Double) : Shape
    data class Rectangle(val width: Double, val height: Double) : Shape
}

fun area(shape: Shape): Double = when (shape) {
    is Shape.Circle    -> Math.PI * shape.radius * shape.radius
    is Shape.Rectangle -> shape.width * shape.height
}

No else branch, and that is the point: the compiler knows every subtype, so the when is exhaustive and adding a third shape makes this function stop compiling. A when over Any with an else silently sends the new case down the default path instead.

when used as an expression requires exhaustiveness; used as a statement it did not, until Kotlin 1.7 made a non-exhaustive when over a sealed type an error there too.

Contracts, and why your own check does not smart-cast

A helper that performs the type check does not propagate the knowledge to its caller:

fun isNotEmptyString(value: Any?): Boolean = value is String && value.isNotEmpty()

fun use(value: Any?) {
    if (isNotEmptyString(value)) {
        println(value.length)   // error — the compiler only sees a Boolean
    }
}

From the caller’s side the function returns true or false; nothing in the signature says what that implies about the argument. contract is the mechanism for telling it:

@OptIn(ExperimentalContracts::class)
fun Any?.isNotEmptyString(): Boolean {
    contract { returns(true) implies (this@isNotEmptyString is String) }
    return this is String && this.isNotEmpty()
}

Now the caller smart-casts. This is how the standard library’s own helpers work: isNullOrEmpty, isNullOrBlank and requireNotNull all carry contracts, which is why writing if (!s.isNullOrEmpty()) { s.length } compiles while a hand-rolled equivalent does not.

The contract is a promise the compiler trusts and does not verify, so a wrong one produces exactly the runtime ClassCastException the type system was meant to prevent. Worth using for a widely reused predicate; not worth it for a one-off, where an inline is is clearer anyway.

is against equals

value is String       // type check
value == other        // structural equality, calls equals
value === other       // referential identity

Three different questions, and they are independent. == on a nullable receiver is safe, it handles null on both sides and does not throw, which is a real difference from Java, where a.equals(b) needs a null check on a.

Related: nullable types and null safety and data classes, whose generated equals is what == calls. More in the Kotlin guides.

Frequently asked questions

What is a smart cast?

After is proves a type, the compiler treats the value as that type in the scope where the check holds, so no explicit cast is needed.

Why does the compiler refuse to smart-cast my property?

Because it cannot prove the value is unchanged between the check and the use: a var property, a custom getter, an open property, or one from another module. Copy it into a local val.

Does !is smart-cast too?

Yes. After an early return in the negative branch, the compiler knows the type for the rest of the function.

What is the difference between as and as?

as throws ClassCastException on failure; as? returns null. (x as? T) ?: default is the idiomatic safe cast.

Should I catch ClassCastException instead?

No. as? is cheaper and more precise: a catch also swallows unrelated cast failures from deeper in the block.

Why can’t I check is List<String>?

Type arguments are erased at runtime. Use is List<*> to check the outer type, or a reified parameter on an inline function.

What does reified do?

It makes a type parameter available at runtime by substituting the concrete type at each call site. It requires inline, and it recovers the outer type only, not its type arguments.

Why does my when need an else?

Because the subject’s type is open, so the compiler cannot know every case. A sealed hierarchy makes the when exhaustive and turns a new subtype into a compile error.

What is the difference between == and ===?

== is structural equality and calls equals, handling nulls safely. === is referential identity, the same object.

Does a smart cast cost anything at runtime?

No. It removes the redundant cast a Java version would have written; the underlying instanceof check is the same.