Safe Casts and let

When you’re working with a value whose exact type you don’t know at compile time — something coming from a JSON parser, a generic collection, or Java interop — you eventually need to ask "is this actually the type I think it is?" Kotlin gives you two tools for handling that safely: the safe cast operator as?, which turns a risky cast into a nullable result instead of a crash, and let, a small but powerful function that lets you run code against a value only when it’s non-null. Used together, as? and let form one of the most idiomatic patterns in Kotlin for turning "maybe this is a String, maybe it isn’t" into clean, crash-free code.

Overview: How Safe Casts and let Work

Kotlin inherits the JVM’s runtime type system, which means a variable declared as Any can hold literally anything — a String, an Int, a custom class, or null if the type is Any?. To narrow that back down to a concrete type, Kotlin gives you the as operator. Plain as is an unsafe cast: if the value isn’t actually the target type at runtime, it throws a ClassCastException and your program crashes right there. That’s the same risk Java developers know from (String) someObject.

as? is the safe version. Instead of throwing when the cast fails, it evaluates to null. This is only possible because of Kotlin’s nullable type system: the result of value as? String is not String, it’s String? — the compiler forces you to acknowledge that the cast might not have worked before you can use the result. There’s no way to accidentally call a method on a failed cast, because the compiler won’t let you treat a String? as a String without a null check first. This is exactly the same compile-time guarantee that protects every other nullable value in Kotlin: the type itself carries the possibility of failure, so the failure can’t be ignored.

Once you have that nullable result, you usually want to do something with it, but only if it’s not null. That’s what let is for. let is an extension function available on every type, roughly defined in the standard library as fun <T, R> T.let(block: (T) -> R): R. It takes a lambda, runs that lambda with the receiver passed in as its single argument (available as it unless you name it), and returns whatever the lambda returns. On its own, let doesn’t know anything about null — the null-safety comes from combining it with the safe-call operator ?.. When you write nullableValue?.let { ... }, the ?. means the lambda only runs at all if nullableValue is non-null; if it’s null, the whole expression short-circuits to null without ever entering the block. Inside the block, the parameter is smart-cast to the non-null type, so you can call methods on it directly.

Put the two together — (value as? Type)?.let { ... } — and you get a single expression that means: "try to treat this as Type; if that worked, run this code with the non-null, correctly-typed value; if it didn’t, produce nothing (or fall back to a default with ?:)." That one line replaces what would otherwise be a multi-branch if with an explicit null check and an unsafe cast inside it.

let vs. the other scope functions

Kotlin has five scope functions that all do some version of "run a block against an object": let, run, with, apply, and also. They differ in two ways: whether the object is available as it or as this, and whether the whole expression returns the lambda’s result or the original object.

Function Object reference Returns Typical use
let it lambda result Null-check + transform a value; scoping a temporary name
run this lambda result Compute a result using several members of the receiver
with this lambda result Group several calls on an object (not an extension function)
apply this the receiver itself Configure an object, then keep using it
also it the receiver itself Side effects (logging, validation) in the middle of a chain

let is the one you reach for specifically because it returns the lambda’s result, not the original object — which is exactly what you need after a safe cast, since you usually want to transform the casted value into something else (a length, a formatted string, a computed total).

Syntax

value as? Type
  • value — any expression, typically typed as Any, Any?, or a supertype
  • Type — the target type you’re checking for
  • Result type is Type? — the value itself if the runtime type matches, otherwise null (never an exception)
nullableValue?.let { name -> ... }
  • nullableValue?. — the safe-call operator; the block below only executes if this is non-null
  • let — the scope function; receives the non-null value as its argument
  • { name -> ... } — the lambda; use it if you don’t name the parameter, or give it an explicit name (recommended when nesting)
  • The whole expression evaluates to the lambda’s return value, or null if the safe call short-circuited

Examples

Example 1: A basic safe cast

fun main() {
    val obj: Any = "Hello, Kotlin"
    val text: String? = obj as? String
    println(text)

    val number: Int? = obj as? Int
    println(number)
}

Output:

Hello, Kotlin
null

obj is actually a String at runtime, so obj as? String succeeds and returns the string itself. obj as? Int fails — a String is not an Int — but instead of throwing, it simply evaluates to null. Notice both text and number had to be declared with the nullable types String? and Int?; the compiler wouldn’t accept a non-nullable declaration for the result of as?.

Example 2: Chaining as? with let

fun describe(value: Any) {
    val length = (value as? String)?.let { it.length }
    if (length != null) {
        println("String of length $length")
    } else {
        println("Not a string")
    }
}

fun main() {
    describe("Kotlin")
    describe(42)
}

Output:

String of length 6
Not a string

For "Kotlin", the cast succeeds, so ?.let runs its block with the non-null string and returns its length (6). For 42, the cast fails and produces null; because of the safe call, let‘s block never runs at all, and the whole expression is null. No exception, no manual if (value is String) check needed.

Example 3: A realistic filter-and-transform

data class User(val name: String, val age: Int)

fun greet(item: Any?): String {
    return (item as? User)?.let { user ->
        "Hello, ${user.name}! You are ${user.age} years old."
    } ?: "Unknown item"
}

fun main() {
    val items: List<Any?> = listOf(User("Ava", 30), "not a user", null, User("Ben", 25))
    for (item in items) {
        println(greet(item))
    }
}

Output:

Hello, Ava! You are 30 years old.
Unknown item
Unknown item
Hello, Ben! You are 25 years old.

This mixes several ideas at once. User is a data class, so it automatically has a generated toString, equals, and copy (unused here, but available). The list holds a mix of User, String, and null, all typed as Any?. For each item, item as? User only succeeds for the actual User values — note that casting null itself with as? also safely yields null rather than throwing, unlike a plain as cast of null to a non-nullable type. The ?.let builds the greeting only for the successful casts, and the trailing ?: "Unknown item" supplies a fallback for everything else in one line.

How It Works Step by Step

Walking through Example 3 for the input "not a user":

  • 1. item holds the string "not a user", statically typed Any?.
  • 2. item as? User checks the value’s runtime type. It is a String, not a User, so the cast fails and the expression evaluates to null (the statically inferred type here is User?).
  • 3. ?.let { ... } sees that its receiver is null, so the safe-call short-circuits: the lambda body never executes, and the whole (item as? User)?.let { ... } expression evaluates to null.
  • 4. The elvis operator ?: sees a null on its left side, so it evaluates its right side, "Unknown item", and that becomes the return value of greet.
  • 5. For an actual User, step 2 succeeds and returns a non-null User; step 3 then runs the lambda with user smart-cast to the non-null User type, letting you access user.name and user.age directly; step 4’s elvis operator is never reached because the left side is already non-null.

Common Mistakes

Mistake 1: Using as instead of as? for values you haven’t verified

fun printLength(value: Any) {
    val s = value as String
    println(s.length)
}

fun main() {
    printLength(42)
}

This compiles perfectly — as is syntactically valid here — but it crashes at runtime with a ClassCastException because 42 is an Int, not a String. Unsafe as should only be used when you are certain of the type (for example, right after an is check, where the compiler smart-casts for you anyway and an explicit cast isn’t even needed). Use as? whenever there’s real doubt:

fun printLength(value: Any) {
    val s = value as? String
    println(s?.length ?: -1)
}

fun main() {
    printLength(42)
    printLength("hello")
}

Output:

-1
5

Mistake 2: Using !! right after as?

fun main() {
    val value: Any = 123
    val s = (value as? String)!!
    println(s.length)
}

This is a very common anti-pattern: the whole point of as? is to avoid a crash when the cast fails, but immediately following it with !! throws that safety away and re-introduces the crash — here as a NullPointerException instead of a ClassCastException. If you find yourself writing as? followed by !!, you almost always meant to use plain as, or better, to actually handle the null case:

fun main() {
    val value: Any = 123
    val s = (value as? String) ?: "not a string"
    println(s)
}

Output:

not a string

Mistake 3: Assuming let mutates the original variable

fun main() {
    var name: String? = "  Kotlin  "
    name?.let { it.trim() }
    println("[$name]")
}

Output:

[  Kotlin  ]

This is a subtle but common mistake. let does not change name in place — it just runs the lambda and returns its result, which here is silently discarded. name is untouched, so the surrounding whitespace is still there. To actually update the variable, you have to capture and reassign the result yourself:

fun main() {
    var name: String? = "  Kotlin  "
    name = name?.let { it.trim() }
    println("[$name]")
}

Output:

[Kotlin]

Note this also required declaring name with var, not val — you can only reassign a variable that’s genuinely mutable.

Best Practices

  • Default to as? over as whenever the type isn’t already guaranteed; reserve unsafe as for cases you’ve already verified (or that the compiler has already smart-cast for you).
  • Never follow as? with !! — that combination throws away the exact safety as? exists to provide. Handle the null case with ?.let, ?:, or an explicit check instead.
  • Use (value as? Type)?.let { ... } ?: fallback as your go-to one-liner for "try this type, transform it if it matches, otherwise fall back."
  • Prefer a plain if (x != null) block over let when you’re not transforming the value into something new — let earns its keep when you need its return value, not for pure null checks.
  • Name lambda parameters explicitly ({ user -> ... }) instead of relying on it when nesting scope functions, so it’s always clear which value is in scope.
  • Remember let never mutates its receiver — if you need the change to stick, capture and reassign the result explicitly, and only on a var.
  • When checking a type with is instead of casting, let Kotlin’s smart-cast do the work for you inside the if branch rather than adding a redundant as? on top.

Practice Exercises

  • 1. Write a function fun asIntOrNull(value: Any): Int? that uses as? to safely cast value to Int, returning null if it isn’t one. Call it with an Int, a String, and a Double, and print each result.
  • 2. Given val data: Any? = "42", chain as? String with ?.let and the standard library’s toIntOrNull() to print the doubled numeric value if parsing succeeds, or "invalid" otherwise. Then try it again with data set to "oops" and confirm you get "invalid" without any crash.
  • 3. Write a function that takes a List<Any> and returns the sum of only the Int elements inside it, using as? inside a loop (or filterIsInstance<Int>() as a shortcut to compare against). For listOf(1, "two", 3, 4.0, 5), the expected result is 9.

Summary

  • as is an unsafe cast that throws ClassCastException on a type mismatch; as? is the safe version that evaluates to null instead.
  • The result of value as? Type is always Type?, forcing you to handle the failure case through Kotlin’s nullable type system before you can use it.
  • let is a scope function that runs its lambda with the receiver as an argument (it by default) and returns the lambda’s result.
  • Combined with the safe-call operator as nullableValue?.let { ... }, the block only executes when the receiver is non-null, and the parameter inside is smart-cast to the non-null type.
  • (value as? Type)?.let { ... } ?: fallback is the idiomatic one-liner for "cast, transform if successful, otherwise use a default."
  • Never chain !! after as? — it reintroduces the exact crash risk the safe cast was meant to eliminate.
  • let never mutates its receiver; capture and reassign its result explicitly if you need the change to persist.