1. Navigating JsonElement without crashes
When you first see JSON, you want to think of it as a well-behaved object: “there is always an id, it’s always a number, it’s never null.” That feeling is about as realistic as believing the classroom Wi‑Fi will be stable. Theoretically possible, but it’s better not to base your thesis on it.
The problem is that JSON without a schema often comes from external sources, where the structure can change: a field got renamed, the type changed, nesting shifted, null got added somewhere, a key got removed somewhere else. And if you make one careless move in code (for example, !! or as), you’ll get a runtime crash instead of predictable behavior.
The main goal of today’s lecture: build the habit of “every step is a check” and learn to wrap these checks into small helper functions so your code doesn’t turn into a forest of if and return.
is, as?, and navigation steps
If in Kotlin you do as JsonObject, you are explicitly saying: “I swear this is an object.” If you’re wrong, you’ll get an exception. But if you do as? JsonObject, you’re saying: “I hope this is an object, but if not—return null, and I’ll handle it myself.” And that “handle it myself” is our style of the day.
Imagine JSON as a maze, and JsonElement as a room. To get to the next room, you need to: understand the room’s type, carefully open the door, and only then proceed. In code, this looks like a chain of steps: “expected type → safe cast → next step”.
A mini example of “bad” (fragile) and “good” (robust)—on the same JSON:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
fun main() {
val root = Json.parseToJsonElement("""{"user":{"name":"Ann"}}""")
// BAD: jsonObject throws an exception if root is not an object
val obj1: JsonObject = root.jsonObject
// GOOD: safe if root suddenly turns out to be an array
val obj2: JsonObject = root as? JsonObject ?: return
println("fields=${obj2.size}") // fields=1
}
The idea: while we’re working with unstable data, forced casts and “trust me” guarantees are the enemy.
JsonObject["key"]: why is the result a JsonElement?
When you do obj["age"], the result has type JsonElement?. That is, nullable. Kotlin is basically telling you in advance: “The field might be missing—and that’s fine.” And it really is fine: a missing field is a typical case.
It’s important to distinguish two scenarios here: “the field is missing” and “the field exists but equals null.” In the first case, obj["x"] == null. In the second case, obj["x"] returns not null, but the node JsonNull. If you mix up these scenarios, you can draw very strange conclusions about the data.
Let’s check it manually:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"a":null}""") as? JsonObject ?: return
val a = root["a"] // NOT null, this is JsonNull
val b = root["b"] // null, the field is missing
println(a is JsonNull) // true
println(b == null) // true
}
This is an important building block for future validation, but even now it affects how we write “safe extraction”.
3. JsonPrimitive and safe conversions
Why we often grab a string instead of “an Int right away”
For a beginner it’s natural to want: “If the field is a number, I want an Int.” But in a JSON tree, primitives are arranged more subtly: JsonPrimitive is a container for “string/number/boolean”, and you decide how to interpret the content.
Yes, JsonPrimitive can do a lot, but in a teaching style it’s useful to think like this: first take the text (string), then convert safely. This enforces discipline and helps you survive cases where a number came as a string: "21" instead of 21.
Key point: an unsafe conversion (toInt()) throws an exception, while a safe conversion (toIntOrNull()) returns null. That’s exactly what we need for robustness with external data.
Conversions String -> Int?, String -> Double?, String -> Boolean?
Once we can get a string from a primitive, we can add safe conversions. And there’s a nuance here: boolean can be “dirty” too. For example, you might get "TRUE" or "yes". We’ll discuss validation of such things later, but even at the extraction stage it’s useful to pick a strategy.
Today we’ll use a strict strategy: consider true/false valid, and everything else null. For this, it’s convenient to use toBooleanStrictOrNull() (strict conversion; it does not guess “yes”). If it’s unavailable in your Kotlin version, you can temporarily do lowercase() and compare manually, but in Kotlin/JVM things are usually fine.
import kotlinx.serialization.json.JsonObject
fun getIntOrNull(obj: JsonObject, key: String): Int? {
val text = getStringOrNull(obj, key) ?: return null
return text.toIntOrNull()
}
fun getDoubleOrNull(obj: JsonObject, key: String): Double? {
val text = getStringOrNull(obj, key) ?: return null
return text.toDoubleOrNull()
}
fun getBooleanOrNull(obj: JsonObject, key: String): Boolean? {
val text = getStringOrNull(obj, key) ?: return null
return text.toBooleanStrictOrNull()
}
The idea is: we separated “getting the data” from “interpreting it”. And we did it without exceptions.
4. Helper functions for safe reading
When you write “safe extraction” by hand, copy-paste appears quickly: the same obj[key], the same as?, the same ?: return null. At this point, it’s reasonable to make small helper functions.
Important: helper functions should be boring. No “magic”, no hidden exceptions, no attempts to “guess what the JSON author meant”. Their strength is predictability.
Primitives and strings
Let’s start with two simple ones:
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonPrimitive
}
fun getStringOrNull(obj: JsonObject, key: String): String? {
return getPrimitiveOrNull(obj, key)?.content
}
The contract here is crystal honest: if there’s no field or it’s not a primitive, return null. No “empty strings by default”, no 0. We don’t hide the problem—we make it manageable.
Nested objects: getObjectOrNull
As soon as JSON becomes real, nesting appears. For example:
{
"expense": {
"id": "10",
"title": "Coffee",
"amount": 250
}
}
If every time you write ((root as? JsonObject)?.get("expense") as? JsonObject), you’ll very quickly start hating JSON as an art genre. So let’s add a helper for objects:
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
fun getObjectOrNull(obj: JsonObject, key: String): JsonObject? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonObject
}
And use it:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"expense":{"amount":"250"}}""")
val rootObj = root as? JsonObject ?: return
val expenseObj = getObjectOrNull(rootObj, "expense") ?: return
val amount = getIntOrNull(expenseObj, "amount")
println(amount) // 250
}
Notice: amount came in as a string "250", but we still parsed it correctly because we go through .content → toIntOrNull().
Arrays: JsonArray and safe indexes
A bit later (in the next lectures) we’ll assemble reports and data processing, and arrays are a normal thing there: a list of expenses, a list of users, a list of “whatever”.
JsonArray behaves like a list (List<JsonElement>). That means we have good standard library tools—for example, getOrNull(index) so we don’t get an IndexOutOfBoundsException.
Let’s make a helper:
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
fun getArrayOrNull(obj: JsonObject, key: String): JsonArray? {
val el: JsonElement = obj[key] ?: return null
return el as? JsonArray
}
And an example of reading the first element:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
fun main() {
val root = Json.parseToJsonElement("""{"items":[{"id":1},{"id":2}]}""") as? JsonObject ?: return
val items = getArrayOrNull(root, "items") ?: return
val first = items.getOrNull(0) as? JsonObject
val id = first?.let { getIntOrNull(it, "id") }
println(id) // 1
}
Here we don’t assume the array is non-empty, and we don’t assume the first element is an object. We check everything.
5. Practical example: importing an expense
A mini algorithm for safe reading
So it doesn’t turn into mush in your head, it helps to keep a short algorithm. It almost always looks the same: “get a node → check the type → get the content → convert”.
You can imagine it as a mini flowchart:
flowchart TD
A["JsonObject['key']"] --> B{Is the element present?}
B -->|no| N["null (no field)"]
B -->|yes| C{Does the node type match?}
C -->|no| N
C -->|yes| D["JsonPrimitive.content"]
D --> E{Was the conversion successful?}
E -->|no| N
E -->|yes| OK["Returned value T"]
The main idea: we don’t “fight” errors with exceptions; we make it impossible to crash at the extraction stage.
Parsing Expense from a JsonObject
Let’s build a small piece of our training app. Suppose we have an expense entity, and we want to import it from a JSON tree (without @Serializable, because the format might be unstable).
We’ll take a simple model:
data class Expense(
val id: Int,
val title: String,
val amount: Int,
)
Now let’s write a function to “try to extract” an expense from a JsonObject. Note: this is not full validation of business rules (for example, “amount > 0”); it’s safe extraction: “if we could—return it; if not—null”.
import kotlinx.serialization.json.JsonObject
data class Expense(val id: Int, val title: String, val amount: Int)
fun parseExpenseOrNull(obj: JsonObject): Expense? {
val id = getIntOrNull(obj, "id") ?: return null
val title = getStringOrNull(obj, "title") ?: return null
val amount = getIntOrNull(obj, "amount") ?: return null
return Expense(id = id, title = title, amount = amount)
}
Now an example of usage with real JSON where the types “dance around”:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
fun main() {
val text = """
{
"expenses": [
{"id": "1", "title": "Coffee", "amount": 250},
{"id": 2, "title": "Taxi", "amount": "bad-number"}
]
}
""".trimIndent()
val root = Json.parseToJsonElement(text) as? JsonObject ?: return
val arr: JsonArray = getArrayOrNull(root, "expenses") ?: return
for (el in arr) {
val expObj = el as? JsonObject ?: continue
val expense = parseExpenseOrNull(expObj)
println(expense)
// Expense(id=1, title=Coffee, amount=250)
// null
}
}
We got important behavior: the program didn’t crash on the second element; it calmly printed null. Yes, later we’ll improve this and return Ok/Error with a message (next lecture), but at the extraction stage we already achieved the main thing: robustness.
Why at the extraction stage null is better than a “default”
When a beginner writes getIntOrNull, there’s often a temptation: “if the field is missing—return 0.” At first glance it’s convenient. But that convenience is toxic: you lose information about the problem.
If “there is no amount field” turns into 0, then later your code may decide that an expense of 0 USDT is a real expense. And then you’ll be investigating: “who is adding zero expenses?”, and it turns out it was you—just in the past.
So the basic style of the day: extraction helper functions return T?. And decisions about “what to do if null” are made above—either at the validation level or at the scenario level (for example, skip the element, show a message to the user, etc.).
6. Common mistakes
Mistake №1: chains like obj["x"]!!.jsonPrimitive.content.
This code looks “nice” only until the first unstable JSON. !! crashes if the field is missing, and jsonPrimitive crashes if it’s not a primitive. As a result, you get a runtime crash where you could have gotten null and handled the situation calmly.
Mistake №2: using toInt() instead of toIntOrNull().
toInt() is “if the string is bad, I crash.” For external data this is usually the wrong strategy. The NumberFormatException example shows the difference well: toIntOrNull() is a safe alternative that returns null instead of throwing.
Mistake №3: not distinguishing “field is missing” from “field equals null”.
If the field is absent, obj["k"] returns null. If the field exists but the value is null, you’ll get the node JsonNull. These are different cases, and sometimes they mean different things (especially during data imports and format migrations).
Mistake №4: making helper functions that are “too smart”.
If getIntOrNull starts accepting "1 000", "1,000", "about 10", "ten", you suddenly turn extraction into guesswork. Guesswork produces silent bugs. It’s better to keep helpers strict and predictable, and do “smart” conversions in separate functions and only where it’s actually needed.
Mistake №5: returning defaults (0, "", false) instead of null too early.
A default hides the problem and merges two states: “the value is really 0” and “the value is unknown/missing.” At the extraction stage it’s almost always more honest to return null, and only substitute a default at the business-decision level, where you can explain why it’s correct.