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.