Kotlin Module 3 – Null safety
Smart casts, is checks and safe casts
Check types with is, rely on the smart casts that follow in if, when and &&, convert values safely with as?, and see when the compiler refuses a smart cast.
What you will learn
- Check types with is and !is, and use the smart casts that follow in if, when and &&
- Convert values with as? and the Elvis operator, and predict when as throws instead
- Explain why a smart cast is refused on a var property or a custom getter, and fix it with a local val
- Choose between require, check and error for a wrong value, and predict the exception each one throws
Before you start
What you’ll learn · Before you start
What you will learn
- Check types with is and !is, and use the smart casts that follow in if, when and &&
- Convert values with as? and the Elvis operator, and predict when as throws instead
- Explain why a smart cast is refused on a var property or a custom getter, and fix it with a local val
- Choose between require, check and error for a wrong value, and predict the exception each one throws
Before you start
On this page
A booking request arrives as JSON and is read into a Map<String, Any?>. The seats field should be an Int and the
PNR a String, but every value has the type Any?, and the compiler will not even add 1 to an Any?. An is check
asks what a value really is while the program runs, and after it the compiler treats the value as that type, with no
cast written.
You will check types with is and !is and rely on the smart casts that follow. You will convert values with as?,
which gives null where a plain cast would throw, and see where the compiler refuses a smart cast and why it is right
to.
An is check tells the compiler the type, and the cast is done for you
value is Int is true when the value is an Int while the program runs, and value !is Int is its opposite. null
is not an instance of any class, so a check against a non-null type such as is Int is false for it, while a nullable
type such as String? includes it, as the last line of the program below shows. Beyond answering a question, is
checks change what the compiler knows. In the code that runs only when a check was true, the compiler treats the value as the checked type: a
smart cast: The compiler's own cast: after a check such as x != null or x is String, it treats x as the checked type wherever the check still holds, with no cast written.. Smart casts are why the branches below read value + 1 and value.length
without a cast:
// A booking request read from JSON: every field arrives as Any?, whatever it holds.
fun describe(value: Any?): String = when (value) {
null -> "missing"
is Int -> "an Int; plus one is ${value + 1}"
is String -> "a String of ${value.length} characters"
is List<*> -> "a list of ${value.size} items"
else -> "a ${value::class.simpleName}, not handled"
}
fun main() {
val request: Map<String, Any?> = mapOf(
"seats" to 2,
"pnr" to "8240117365",
"legs" to listOf("NDLS-AGC", "AGC-BPL"),
"fare" to 1250.5,
"coupon" to null,
)
for ((field, value) in request) {
println("$field: ${describe(value)}")
}
// null is no instance of a class, but a nullable type includes it.
val coupon = request["coupon"]
println("coupon is String: ${coupon is String}, coupon is String?: ${coupon is String?}")
} Output
seats: an Int; plus one is 3 pnr: a String of 10 characters legs: a list of 2 items fare: a Double, not handled coupon: missing coupon is String: false, coupon is String?: true
Recorded with Kotlin 2.4.20 on OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: kotlinc Classify.kt -include-runtime -d main.jar && java -jar main.jar
Each branch of the when sees value as the type it checked: an Int to add to, a String with a length, a list
with a size. List<*> checks only that the value is a list, because the types of its items are not kept while the
program runs, so a check cannot ask for a list of strings. In the else branch value is an Any, since null was
handled first.
When there is no smart cast
A smart cast needs a value that cannot change between the check and its use, so the compiler refuses one on a var
property and on a property with a custom getter. Copy such a property into a local val, then check and use the copy.
The same works after an early return and on the right of &&. Step through farePaise for three of the values the
program below passes it, and watch what the compiler knows after each check:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The String "450". farePaise is called with the String "450". Its parameter is declared Any?, so at the start the compiler knows nothing more: value could be any type, or null.
- Not null. "450" is not null, so the first if does not return. On every line after it, the compiler knows that value is not null: its type there is Any, not Any?.
- A String. value is String is true, so inside this if the compiler smart-casts value to String. value.toLong() needs no cast, and the function returns 45000.
- The Int 300. Now the Int 300. It is not null, and value is String is false, so the function goes on. At this point the compiler still knows only that value is an Any.
- No early return. value !is Int is false for 300, so this if does not return. After it, value can only be an Int, and the compiler smart-casts it for the last line.
- The last line. value * 100L multiplies an Int by a Long with no cast written, and the call returns 30000, as KnownType.kt printed.
- The Double 4.5. 4.5 is not null and not a String, and value !is Int is true for it, so the function returns -1 and never reaches the last line.
// The type the compiler knows for value changes after each check.
fun farePaise(value: Any?): Long {
if (value == null) return 0
// From here on, value is an Any: null has been ruled out.
if (value is String) return value.toLong() * 100
if (value !is Int) return -1
// From here on, value is an Int.
return value * 100L
}
// On the right of &&, the check on its left still holds.
fun isPnr(value: Any?): Boolean = value is String && value.length == 10 && value.all { it.isDigit() }
fun main() {
for (value in listOf(null, "450", 300, 4.5)) {
val type = value?.let { it::class.simpleName } ?: "null"
println("$value ($type) -> ${farePaise(value)} paise")
}
for (value in listOf<Any>("8240117365", 8240117365L, "82401")) {
println("isPnr(${value::class.simpleName} $value) = ${isPnr(value)}")
}
} Output
null (null) -> 0 paise 450 (String) -> 45000 paise 300 (Int) -> 30000 paise 4.5 (Double) -> -1 paise isPnr(String 8240117365) = true isPnr(Long 8240117365) = false isPnr(String 82401) = false
Recorded with Kotlin 2.4.20 on OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: kotlinc KnownType.kt -include-runtime -d main.jar && java -jar main.jar
After if (value !is Int) return -1, the rest of the function can only run with an Int, so value * 100L needs no
cast. In isPnr, the right side of && runs only when value is String was true, so value.length compiles there.
Key idea
After a check that was true, the compiler treats the value as the checked type wherever the check still holds: in
the branch, after an early return, and on the right of &&.
Checkpoint
Check: is checks and the smart casts after them
as? converts a value or gives null, and as throws
A cast states the type a value should have. value as Int gives the value as an Int or throws a
ClassCastException, while value as? Int is a safe cast: A cast written with as?: x as? T gives x as a T when it is one, and null otherwise, instead of throwing a ClassCastException., which gives null instead of
throwing. Safe casts pair naturally with the Elvis operator:
// as? converts a value to a type or gives null; as converts it or throws.
fun main() {
val request: Map<String, Any?> = mapOf("seats" to "2", "pnr" to "8240117365", "coupon" to null)
// "2" is a String, not an Int, so as? gives null and the Elvis operator supplies 1.
val seats = request["seats"] as? Int ?: 1
println("seats: $seats")
val pnr = request["pnr"] as? String
println("pnr: $pnr, ${pnr?.length} characters")
println("coupon: ${request["coupon"] as? String}")
try {
val strict = request["seats"] as Int
println("strict seats: $strict")
} catch (e: ClassCastException) {
println("as Int threw ${e::class.simpleName}: ${e.message}")
}
} Output
seats: 1 pnr: 8240117365, 10 characters coupon: null as Int threw ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
Recorded with Kotlin 2.4.20 on OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: kotlinc SafeCast.kt -include-runtime -d main.jar && java -jar main.jar
The seats field holds the text “2”, not the number 2, so as? Int gives null and the fallback, 1, is used: a cast
checks the type and never parses text, which is the job of toIntOrNull(). The last line shows what as does with the
same field. It throws, and its message names both classes but not the field.
Use is checks in a when when each type needs its own handling, as in describe, and safe casts with ?: when one
type is expected and anything else gets a fallback. Keep as for places where another type can only be a bug.
require and check stop a function with an exception when a condition fails, and the compiler smart-casts after
them; error throws every time it runs, for a branch that should never run.
Checkpoint
Check: as? against as
When the compiler refuses a smart cast, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
A smart cast promises that the value checked is the value used. This part reproduces each case where the compiler cannot keep that promise, shows the failure it prevents, and fixes each one.
In this part:
- Four checks the compiler cannot trust
- Why the compiler is right: a getter that changes between reads
- Three fixes that read the value once
3 runnable examples (Kotlin) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Failing fast with as, require, check and error, measured Pro: opens with a Pro, Premium or Ultimate pass
When a value has the wrong type or breaks a rule, the program should stop where the mistake is and say what it was. This part measures what each tool throws and shows the smart casts that follow them.
In this part:
- What as and as? do with a wrong type, and with null
- require, check and error report three kinds of mistake
- Smart casts after require, and contracts of your own
6 runnable examples (Kotlin) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: describe any value
This program checks a function that describes a value of any type. As given, three of its six checks fail:
// Practice: fix describe so that all six checks pass. Use a when with is checks; anything else is "unknown".
fun describe(value: Any?): String = when (value) {
is Int -> "Int $value"
is String -> "String of length ${value.length}"
else -> "unknown"
}
fun main() {
val checks = listOf(
Triple("7", 7, "Int 7"),
Triple("7L", 7L, "Long 7"),
Triple("\"abc\"", "abc", "String of length 3"),
Triple("listOf(1, 2)", listOf(1, 2), "List of size 2"),
Triple("null", null, "null"),
Triple("4.5", 4.5, "unknown"),
)
var passed = 0
for ((shown, value, want) in checks) {
val got = describe(value)
val ok = got == want
if (ok) passed++
println("${if (ok) "ok " else "FAIL"} describe($shown) = $got, want $want")
}
println("$passed of ${checks.size} checks pass")
if (passed < checks.size) kotlin.system.exitProcess(1)
} Output (exit status 1)
ok describe(7) = Int 7, want Int 7
FAIL describe(7L) = unknown, want Long 7
ok describe("abc") = String of length 3, want String of length 3
FAIL describe(listOf(1, 2)) = unknown, want List of size 2
FAIL describe(null) = unknown, want null
ok describe(4.5) = unknown, want unknown
3 of 6 checks pass
Recorded with Kotlin 2.4.20 on OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: kotlinc Describe.kt -include-runtime -d main.jar && java -jar main.jar
Copy it, add the branches that describe is missing, and run it with
kotlinc Describe.kt -include-runtime -d describe.jar && java -jar describe.jar until all six checks pass. First hint:
a Long is not an Int, a list is matched by is List<*>, and null can be a branch of its own.
The questions below cover the two ideas of the first two sections, the one you know least first:
Practice that adapts to you
Practise this lesson
5 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher · Warm-up
What is a smart cast in Kotlin, and when does the compiler refuse to make one?
A smart cast is the compiler's own cast. After a check such as
x != nullorx is String, in the code that runs only when the check was true, the compiler treatsxas the checked type, sox.lengthneeds no!!and noas. It works in the branches ofifandwhen, after an early return such asif (x !is Int) return, and on the right of&&. The compiler makes one only when the value cannot change between the check and the use, so it refuses on avarproperty, which other code could assign in between, and on a property with a custom getter, which may return something else on the next read. The usual fix is to copy the property into a localval, then check and use the copy. -
Fresher to mid
The compiler refuses a smart cast on order.note, a var property, and a colleague silences it with order.note!!.trim() right after the null check. You review the change: when can that line still crash, and what would you ask for instead?
-
Mid to senior
A partner's booking feed is parsed into a Map<String, Any?> before your code sees it. Design the function that turns one map into a Booking(pnr: String, seats: Int, coupon: String?). Where do is checks, as? and preconditions go, and what does the caller get for a missing or wrong field?
-
Mid to senior
A shared helper fun isValidPnr(s: String?): Boolean is called before every use of s, yet the compiler still wants s!! afterwards, and the team has started writing it everywhere. Why does the compiler not see the check, and what would you change?
Key takeaways
value is Tchecks the type while the program runs and!isis its opposite; fornull, a check against a non-null type is false.- After a check that was true, the compiler smart-casts the value to the checked type in the branch, after an early
return and on the right of
&&. value as? Tgivesnullfor any other type and pairs with?:, whilevalue as Tthrows aClassCastException; neither one parses text.
Terms in this lesson
- safe cast
- A cast written with as?: x as? T gives x as a T when it is one, and null otherwise, instead of throwing a ClassCastException.
- smart cast
- The compiler's own cast: after a check such as x != null or x is String, it treats x as the checked type wherever the check still holds, with no cast written.
References
- Type checks and casts (JetBrains (kotlinlang.org))
- Null safety: safe casts (JetBrains (kotlinlang.org))
- Generics: type erasure and generic type checks (JetBrains (kotlinlang.org))
- Exceptions: precondition functions (JetBrains (kotlinlang.org))
- kotlin.contracts (Kotlin standard library) (JetBrains (kotlinlang.org))
- What's new in Kotlin 2.0.0: smart cast improvements (JetBrains (kotlinlang.org))
- Conditions and loops: when with a subject (JetBrains (kotlinlang.org))
Report a problem with this lesson
Kept only in this browser. Your Learn progress