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 asAny,Any?, or a supertypeType— the target type you’re checking for- Result type is
Type?— the value itself if the runtime type matches, otherwisenull(never an exception)
nullableValue?.let { name -> ... }
nullableValue?.— the safe-call operator; the block below only executes if this is non-nulllet— the scope function; receives the non-null value as its argument{ name -> ... }— the lambda; useitif 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
nullif 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.
itemholds the string"not a user", statically typedAny?. - 2.
item as? Userchecks the value’s runtime type. It is aString, not aUser, so the cast fails and the expression evaluates tonull(the statically inferred type here isUser?). - 3.
?.let { ... }sees that its receiver isnull, so the safe-call short-circuits: the lambda body never executes, and the whole(item as? User)?.let { ... }expression evaluates tonull. - 4. The elvis operator
?:sees anullon its left side, so it evaluates its right side,"Unknown item", and that becomes the return value ofgreet. - 5. For an actual
User, step 2 succeeds and returns a non-nullUser; step 3 then runs the lambda withusersmart-cast to the non-nullUsertype, letting you accessuser.nameanduser.agedirectly; 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?overaswhenever the type isn’t already guaranteed; reserve unsafeasfor 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 safetyas?exists to provide. Handle thenullcase with?.let,?:, or an explicit check instead. - Use
(value as? Type)?.let { ... } ?: fallbackas your go-to one-liner for "try this type, transform it if it matches, otherwise fall back." - Prefer a plain
if (x != null)block overletwhen you’re not transforming the value into something new —letearns its keep when you need its return value, not for pure null checks. - Name lambda parameters explicitly (
{ user -> ... }) instead of relying onitwhen nesting scope functions, so it’s always clear which value is in scope. - Remember
letnever mutates its receiver — if you need the change to stick, capture and reassign the result explicitly, and only on avar. - When checking a type with
isinstead of casting, let Kotlin’s smart-cast do the work for you inside theifbranch rather than adding a redundantas?on top.
Practice Exercises
- 1. Write a function
fun asIntOrNull(value: Any): Int?that usesas?to safely castvaluetoInt, returningnullif it isn’t one. Call it with anInt, aString, and aDouble, and print each result. - 2. Given
val data: Any? = "42", chainas? Stringwith?.letand the standard library’stoIntOrNull()to print the doubled numeric value if parsing succeeds, or"invalid"otherwise. Then try it again withdataset 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 theIntelements inside it, usingas?inside a loop (orfilterIsInstance<Int>()as a shortcut to compare against). ForlistOf(1, "two", 3, 4.0, 5), the expected result is9.
Summary
asis an unsafe cast that throwsClassCastExceptionon a type mismatch;as?is the safe version that evaluates tonullinstead.- The result of
value as? Typeis alwaysType?, forcing you to handle the failure case through Kotlin’s nullable type system before you can use it. letis a scope function that runs its lambda with the receiver as an argument (itby 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 { ... } ?: fallbackis the idiomatic one-liner for "cast, transform if successful, otherwise use a default."- Never chain
!!afteras?— it reintroduces the exact crash risk the safe cast was meant to eliminate. letnever mutates its receiver; capture and reassign its result explicitly if you need the change to persist.
