1. Introduction
When you’re just starting to program, it feels logical: “Well, I wrote val x: String, so it’s a string — that’s it.” In most cases, that’s true, and Kotlin does everything to let you live calmly. But as soon as you start writing functions that accept a more general type (for example, Any or a base class), you run into reality: this variable can receive different kinds of data. And then the question comes up: “What exactly is stored there right now, and can we call methods on this object?”
Imagine our console expense tracker (a hypothetical course app): it has an “internal” model Expense, while the user input is a string. Sometimes we want to write a helper function that can accept “something” (a string, a number, null) and try to turn it into an amount. Or we write a handler for an operation result, where a general type is returned, but inside there are different kinds of results. In such places Kotlin asks us to be honest: check the type first, then use it.
Any and Any?: the most general type
If Kotlin is a strict teacher, then Any is its phrase: “Fine, bring me anything, but later I’ll ask what you brought.” The type Any means “any non-null object.” This matters: null is not a value of type Any, because Any is non-null. If you want “anything, including null,” that’s Any?.
Any is useful when you truly don’t know the exact type ahead of time: for example, in a collection of “mixed” values, in a generic parser, in a logging function, in an adapter over someone else’s data. But Any comes with a price: while a variable has type Any, you can’t just call String methods or access .length. First you need to prove to the compiler that it’s actually a string (or some other concrete type).
A small table to reinforce it:
| Type | What it means | Can it store null |
|---|---|---|
|
any object | no |
|
any object or null | yes |
2. Type check: is and !is
The is operator is your “type detector.” It checks at runtime whether a value belongs to the specified type. If yes, the condition is true; if not, it’s false. There’s also !is if you prefer to read it as “not of this type.”
The best part: is usually triggers a smart cast. That is, inside the if branch Kotlin allows you to treat the variable as a more specific type — without an explicit as. More on that in the next section, but first — a small example.
fun printLength(x: Any) {
if (x is String) {
println(x.length) // for example: 5
} else {
println("This is not a string")
}
}
fun main() {
printLength("Hello") // 5
printLength(42) // This is not a string
}
Here x outside is Any, but inside if (x is String) it becomes “as if it were String.” That’s smart cast in action.
4. Smart cast: the compiler “figured it out” if you don’t get in its way
Smart cast isn’t magic; it’s careful control-flow analysis: the compiler looks at your checks (is, != null, some conditions) and draws conclusions: “in this branch the value is definitely not null” or “in this branch the value is definitely a String.” After that it allows you to use it as a more specific type without an explicit cast.
In Kotlin with the K2 compiler (starting from Kotlin 2.0), smart cast became possible in more situations than before: for example, when you move the type check into a separate boolean variable and use it in an if.
Let’s look at an example in the style of “our app”: suppose we have a command, and we temporarily store an “argument” as Any (because tests or different data sources might provide different types).
fun normalizeAmount(raw: Any): Int? {
val isText = raw is String
if (isText) {
return raw.toIntOrNull()
}
return null
}
In Kotlin 2.x the compiler “sees” the relationship between isText and the fact that raw can be treated as a String inside the branch.
When smart cast does not work
There are situations where Kotlin will honestly say: “I can’t guarantee that the type won’t change.” The most common case is when you check a mutable value that could potentially change between the check and the use. At a beginner level you can phrase it like this: smart cast likes val and doesn’t really like var.
Another common scenario: you checked something, but then performed an action after which the value could have changed (or the compiler isn’t sure it didn’t). Then it may “drop confidence” and require a safe call or an explicit cast again. That’s not being picky — it’s protection.
5. Unsafe cast: as
Sometimes you are sure the value has the right type and want to explicitly tell Kotlin: “Treat this as a String.” That’s what the as operator is for.
The problem is that as is not a check; it is an attempt to cast. If the type doesn’t match, the program will crash at runtime with a ClassCastException. So the compiler will accept the code, and the error will show up only when you run it.
Mini example (intentionally “dangerous”):
fun forceString(x: Any): String {
return x as String
}
fun main() {
println(forceString("OK")) // OK
println(forceString(123)) // ClassCastException at runtime
}
Why is this important right now? Because people often start using as “hoping it works”: “well, it’s probably a string...”. Don’t do that. as is good when you truly have a strict type guarantee (for example, you put a String there yourself and never put anything else there).
6. Safe cast: as? and Elvis ?:
The as? operator is a “soft version of as.” It tries to cast the type, but if it fails, it returns null instead of crashing.
This fits perfectly with our previous experience with nullable types: afterwards you either check for null, or use the Elvis operator ?:.
Example:
fun stringOrNull(x: Any): String? = x as? String
fun main() {
println(stringOrNull("Hi")?.length) // 2
println(stringOrNull(123)?.length) // null
}
Even more practical — provide a “default value” right away:
fun stringLengthOrZero(x: Any): Int {
val s = x as? String ?: return 0
return s.length
}
fun main() {
println(stringLengthOrZero("Kotlin")) // 6
println(stringLengthOrZero(10)) // 0
}
This reads straightforwardly: “try to cast to a string; if it didn’t work — 0.”
7. Useful techniques: avoid confusion and write compact code
Comparing is, as, as?
When these three show up near each other, a beginner’s brain often pretends it’s an old printer: it starts humming and prints a blank page. Let’s lock in the differences in the most down-to-earth way.
| Tool | What it does | What it returns | If the type doesn’t match |
|---|---|---|---|
|
checks the type | |
just false |
|
casts the type | |
crashes with ClassCastException |
|
casts the type safely | |
returns null |
If you need to “just understand what’s there” — start with is. If you need to “carefully try to get the right type” — use as?. Reserve as for cases where you are truly sure and ready to stand behind that certainty.
when by types and smart cast
Sometimes instead of several if (x is …) it’s more convenient to write a when and check the type in the branches. This is especially useful when you have 3–4 expected shapes of data and you want a neat “processing table.”
fun describeValue(x: Any): String =
when (x) {
is String -> "String of length ${x.length}"
is Int -> "Integer $x"
else -> "Something else"
}
fun main() {
println(describeValue("Hi")) // String of length 2
println(describeValue(7)) // Integer 7
}
Inside each branch x is smart-cast to the required type, and you can safely use .length or print the number. It’s the same principle as in if, just with a more compact form.
8. Example: parsing an expense amount from Any?
In real code the goal is rarely “just cast a type for the sake of casting.” Usually it’s part of a clearer task: parse input, extract a field from a structure, handle different data formats.
Let’s imagine that in our expense tracker there is a function that gets a “raw amount value” from some source. In a simple console version it’s a String from readln(), but sometimes we might call the function from a test and pass an Int. We want to make a universal handler that takes Any? and tries to obtain the amount in cents (an integer), otherwise returns null.
fun parseAmountCents(raw: Any?): Int? {
if (raw is Int) return raw
val text = raw as? String ?: return null
return text.trim().toIntOrNull()
}
fun main() {
println(parseAmountCents(" 120 ")) // 120
println(parseAmountCents(55)) // 55
println(parseAmountCents("oops")) // null
}
Notice how everything is “grown-up, but without drama”: first we take the fast path: if it’s already an Int, great. Then we safely try to extract a string via as?. If there’s no string — null. If there is a string — trim and parse it.
A small flowchart
flowchart TD
A["raw: Any?"] --> B{raw is Int?}
B -- yes --> C["return raw"]
B -- no --> D{raw as? String}
D -- null --> E["return null"]
D -- String --> F["trim + toIntOrNull()"]
F --> G["return Int?"]
This diagram is useful because it shows: is is a type-based branch, and as? is an “attempt to extract” the needed type without crashing.
9. Typical mistakes when working with is, smart cast, as, as?
Mistake #1: using as as a “type check.”
A very common beginner line of thinking: “if as doesn’t work — then it’s not that type.” But the problem is that “doesn’t work” here means the program crashes with a ClassCastException. If the type can vary, first do an is check or use safe as?, and keep as only for cases with a real guarantee.
Mistake #2: forgetting that as? returns a nullable (T?).
as? doesn’t give you a “clean type”; it gives you “possibly a type.” So code like val s = x as? String; println(s.length) doesn’t compile — and that’s good. The correct follow-up is: s?.length, if (s != null), or Elvis ?: for a default value.
Mistake #3: expecting smart cast where the compiler cannot provide a guarantee.
If a value can change (often related to var), the compiler may refuse to smart-cast, because “between the check and the use” the object could theoretically become different. This isn’t nitpicking; it’s a safety net. In such places you either rewrite the code so that you check and use the value “next to each other,” or store it in a local val and work with that.
Mistake #4: generalizing everything to Any too early.
Any seems convenient: “one function for everything.” But it has a price: type checks, when, as? appear almost immediately, and the code becomes harder to read. A good rule: use Any only where it’s truly justified by the contract (for example, a generic parser/adapter), and otherwise prefer concrete types in function parameters.
Mistake #5: mixing “type checking” and “business logic” into one mess.
When inside if (x is String) you start writing 40 lines of logic, the code quickly becomes unreadable. More practical: make a small block where you neatly cast the type (or return an error/null), and then work with a normal String/Int and write clear domain logic without constant checks.