JavaRush/Courses/Kotlin SELF/JsonElement as a JSON tree

JsonElement as a JSON tree

Open

1. Introduction

When you’re just starting to work with JSON, you want to believe a simple fairy tale: “JSON always matches my model”. It’s a great fairy tale while you write the format yourself or you have a strict contract, and nobody sends you an “id”: "10" instead of 10. But as soon as the format becomes “no schema”, “version drifts”, “fields are sometimes missing”, you either start breeding dozens of nullable fields, or you catch decoding errors, or you pretend it’s not your problem (spoiler: it will become your problem).

JsonElement is a different approach: we don’t try to immediately convert the input into a strict Kotlin model. Instead, we first turn JSON into a tree. And only then do we decide what we need from that tree, and how to live with it going forward.

Officially, JsonElement is a sealed class representing a single JSON element, and it can be a primitive, an array, or an object. At the same time, toString() prints the tree back as valid JSON. (kotlinlang.org)

2. JSON as a tree: the DOM idea without HTML

If you’ve ever seen the DOM in a browser, the idea will be familiar: a document (or data) is a tree of nodes. Nodes have a type, and different types have different “insides”. An object has fields by keys, an array has elements by indices, and a primitive has a value.

It’s important to get used to the thought: in JSON there is no guarantee that the root is an object. The root can be an array ([ ... ]), a number (123), a string ("ok"), a boolean (true), or null. So the basic reflex “I’ll just cast it to JsonObject right away” is like going outside without an umbrella because “the sky looks kind of fine”. Sometimes it works. Sometimes you get soaked.

A small diagram of how we’ll think about it:

flowchart TD
    A["JSON text (String)"] --> B["Json.parseToJsonElement(...)"]
    B --> C["JsonElement (root)"]
    C --> D{"What node type is it?"}
    D -->|JsonObject| E["Fields: key -> JsonElement"]
    D -->|JsonArray| F["Elements: [JsonElement]"]
    D -->|JsonPrimitive| G["Value (as a primitive)"]
    D -->|JsonNull| H["Explicit null value"]

3. Node types in the JsonElement tree

To confidently walk the tree, you need to know the “species of trees”. In kotlinx.serialization, the JSON tree has several core types.

According to the docs, JsonElement can be JsonPrimitive, JsonArray, or JsonObject.
Separately, there is JsonNull—a singleton object (object) representing JSON null.

Let’s put it into a table so your brain stops panicking:

Node type Example in JSON How to think about it What it resembles
JsonObject
{"id": 1, "name": "Ann"}
“dictionary/map of fields”
Map<String, JsonElement>
JsonArray
[1, 2, 3]
“list of elements”
List<JsonElement>
JsonPrimitive
"hi", 42, true, null
“a single value” a container for primitives
JsonNull
null
“null as a node” singleton object

Pay attention to a small psychological trap: JsonNull is not the absence of a field. It is the value null that is explicitly present in JSON. We’ll talk about the difference separately a bit later.

4. Parsing a string into a tree: Json.parseToJsonElement(...)

Now comes the moment of “it used to be complicated, and now it’s one line”. To turn JSON text into a tree, we use:

  • Json.parseToJsonElement(string: String): JsonElement

The documentation says it plainly: the function deserializes a JSON string into the corresponding JsonElement representation and throws SerializationException if the string is not valid JSON.

A minimal example—maximally short and honest:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

fun main() {
    val text = """{"id": 10, "name": "Alice"}"""
    val root: JsonElement = Json.parseToJsonElement(text)

    println(root) // {"id":10,"name":"Alice"}
}

Notice something important: we do not use @Serializable or decodeFromString. We just build a tree.

If you read JSON from “outside” (a file, user input, someone else’s API), it’s better to immediately get used to try/catch, because the input might not be JSON but “almost JSON” (a separate art genre).

import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

fun tryParseJson(text: String): JsonElement? {
    return try {
        Json.parseToJsonElement(text)
    } catch (e: SerializationException) {
        null
    }
}

5. Basic navigation through the JsonElement tree

When you walk a JsonElement, you keep doing the same thing: assume a node type, check it, move on. So it’s handy to have a small function that can at least describe what’s in front of us.

Checking the node type with when

when by node type is basically a flashlight in a dark forest:

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

fun describe(el: JsonElement): String = when (el) {
    is JsonObject -> "object(fields=${el.size})"
    is JsonArray -> "array(items=${el.size})"
    is JsonPrimitive -> "primitive"
    is JsonNull -> "null"
}

There’s a neat detail here: JsonNull is technically a JsonPrimitive (in the hierarchy it inherits from the primitive), so in real code you’ll often distinguish these cases more carefully. But as a starter map of the terrain—it works.

Navigation: object → field → array → elements

Now let’s show navigation “object → field → array → elements”. We’ll do it as straightforwardly as possible, but already with safe casts as?:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

fun main() {
    val root = Json.parseToJsonElement("""{"tags":["kotlin","json"]}""")

    val obj = root as? JsonObject ?: return
    val tags = obj["tags"] as? JsonArray ?: return

    for (tagEl in tags) {
        val tag = (tagEl as? JsonPrimitive)?.content
        println(tag) // kotlin then json
    }
}

This isn’t “pretty” code yet, but it’s very educational: each step explicitly shows where we can get the type wrong. In the next lecture, we’ll turn pieces like this into neat helper functions so we don’t write the same thing 200 times (because that’s usually how burnout starts).

Why does JsonObject["key"] return JsonElement?

When you take a field from a JsonObject, you write something like obj["name"]. And then the interesting part starts: the result is nullable. And it’s not trolling—it’s the correct model of the world.

The reason is simple: the field might be missing. And “missing field” is not the same as “field with value null”.

Let’s show it with an example. We have two JSONs:

1) The field doesn’t exist at all: {}
2) The field exists, but equals null: {"nickname": null}

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject

fun main() {
    val obj1 = Json.parseToJsonElement("""{}""") as JsonObject
    val obj2 = Json.parseToJsonElement("""{"nickname": null}""") as JsonObject

    val a = obj1["nickname"]
    val b = obj2["nickname"]

    println(a == null)        // true  (field is missing)
    println(b is JsonNull)    // true  (field exists, but value = null)
}

It’s important to keep this distinction in mind, because later (in validation and “safe extraction”) you’ll be making decisions like: “if the field is missing—that’s an error”, “if the field exists and is null—allow it”, “if the field is a string—OK, if it’s an array—complain”.

Today our goal is simpler: learn to see these cases and not confuse them.

How to read the tree “in depth” without turning your code into mush

At this stage it’s very easy to make the code unreadable. Beginners often write: “do it immediately as one chain, no stops”. As a result, you get something like obj["x"]!!.jsonObject["y"]!!.jsonArray[0]..., and it crashes on the first deviation in the structure.

Today we’re not building ideal helper functions yet (that’s the next lecture), but we can adopt a basic style that already makes the code calmer: move in small steps and give variables normal names.

Here’s an example “get the items array from an object”—no fanaticism, but readable:

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject

fun getItemsArray(rootObj: JsonObject): JsonArray? {
    val itemsEl = rootObj["items"] ?: return null
    return itemsEl as? JsonArray
}

At this point many people ask: “why not as JsonArray?” Because we’re working with a dynamic structure right now. And if we’re wrong—let it be null, not a crash. And how exactly to react to null is a higher-level decision (and it will appear in the lecture about safe extraction and validation).

6. The JSON root doesn’t have to be an object

At the habit level, it’s very important to stop thinking: “root = object”. In the world of JSON, it doesn’t have to be that way. And if you write code that crashes on "[" as the first character, then congratulations: your parser lives in a world of strict moral principles, and the data does not.

Let’s look at three root variants.

Root is an array

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray

fun main() {
    val root = Json.parseToJsonElement("""[1, 2, 3]""")
    val arr = root as? JsonArray ?: return

    println(arr.size) // 3
}

Root is a primitive

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive

fun main() {
    val root = Json.parseToJsonElement("42")
    val p = root as? JsonPrimitive ?: return

    println(p.content) // 42
}

Root is null

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull

fun main() {
    val root = Json.parseToJsonElement("null")

    println(root is JsonNull) // true
}

Yes, JSON can be just null. And sometimes it’s even a “valid contract” (even though it sounds like a meme).

7. A mini JSON inspector for a console app

To avoid leaving knowledge in a vacuum, let’s add a small debugger feature to our learning console app. Let me remind you of the overall course storyline: we already did storing data in JSON, but the format was stable and we decoded into models. Now we’ll add a mode that takes JSON text and says: “what is this structurally, anyway?”.

This is not “full processing”; it’s an inspector. It helps when your file/input doesn’t match expectations and you want to quickly understand where reality broke.

import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

fun inspectJson(text: String): String {
    val root: JsonElement = try {
        Json.parseToJsonElement(text)
    } catch (e: SerializationException) {
        return "This is not JSON"
    }

    return when (root) {
        is JsonObject -> "Root: object, fields=${root.size}"
        is JsonArray -> "Root: array, elements=${root.size}"
        is JsonPrimitive -> "Root: primitive (${root.content})"
        is JsonNull -> "Root: null"
    }
}

And a tiny main to see it live:

fun main() {
    val text = readln()
    println(inspectJson(text))
    // Example input: {"id":1,"name":"Ann"}
    // Output: Root: object, fields=2
}

Yes, it looks simple. But it’s exactly these “simple things” that later save hours of debugging: at least you understand what you’re parsing, and why your code expecting an object got an array.

8. Common mistakes when getting to know JsonElement

Mistake #1: assuming the JSON root is always an object.
This is the most common trap, because most “nice” JSON examples on the internet start with {...}. In practice, the root can be an array, a primitive, or null, and JsonElement exists precisely so you don’t make false assumptions. Check the root type explicitly, especially where JSON comes from outside.

Mistake #2: doing forced casts as JsonObject without checks.
A forced cast is a bet: “I’m 100% sure”. With unstable JSON, it’s almost always a bet against yourself. It’s much safer to start with as? and an early exit (return) while you’re building an understanding of the structure. And later, when you add a validation layer, you can make stricter decisions.

Mistake #3: confusing a missing field with a field whose value is null.
obj["key"] returns null if the field doesn’t exist, and if the field exists and equals JSON null, you’ll get JsonNull. These two cases often mean different things for business logic: “not provided” and “provided but empty” are not the same.

Mistake #4: thinking that parseToJsonElement is “safe and never crashes”.
Parsing fails on invalid JSON and throws SerializationException. That’s normal, that’s honest, and it must be handled. Especially if you let users type JSON by hand (people can type, but sometimes they add an extra comma out of pure love for art).

Mistake #5: trying to “do everything at once” in a single function.
Beginners often mix parsing, navigation, extraction, defaults, range checks, and printing errors into one huge block. You get code that’s impossible to maintain. Today we deliberately limited ourselves to the first step: we represented JSON as a tree and learned basic navigation. Next will come safe extraction and a separate validation layer—and that’s where a neat architecture will appear.

1
Task
Kotlin SELF,  49 level,  0 lecture
Locked
Root Scanner
Root Scanner
1
Task
Kotlin SELF,  49 level,  0 lecture
Locked
Key Catalog
Key Catalog
1
Task
Kotlin SELF,  49 level,  0 lecture
Locked
Array Sum
Array Sum
1
Task
Kotlin SELF,  49 level,  0 lecture
Locked
Field statuses
Field statuses
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet