JavaRush /Courses /Kotlin SELF /Building JSON by hand — reports and non-standard structur...

Building JSON by hand — reports and non-standard structures

Kotlin SELF
Level 49, Lesson 3
Available

1. Introduction

When you first got acquainted with kotlinx.serialization, you might have had the feeling: “Great! Now I can turn any JSON into a data class, and life is good.” In practice, life—as usual—is a bit trickier: sometimes the report format doesn’t match your models, sometimes you need to return only part of the fields, sometimes you need to add aggregates that don’t exist in the model at all. And that’s when manual JSON assembly turns from “ugh, hacks” into “oh, a handy tool.”

Imagine our practical console project (a hypothetical expense tracker): inside the program we have normal Kotlin structures (Expense, lists, maps), and we want to do a “report export” to JSON for integration with anything. But the report is not a single Expense—it’s a structure like:

  • total amount,
  • totals by category,
  • a list of transactions,
  • a service section meta (format version, currency, report author).

If we try to “just serialize the list of expenses,” we’ll lose the aggregates. If we try to “make one huge data class Report,” it will start to bloat, and the report format will live its own life and break your domain model. So today we learn to build JSON like a constructor set: carefully, predictably, and without string concatenation (string concatenation is the road to the kingdom of commas you forgot to place).

2. Mental model: a JSON report as a pipeline

Before writing code, it’s useful to agree with your brain that there are two different worlds here: “computations” and “packing into JSON.” If you mix them into one giant function, you’ll get soup: it won’t taste good, and you’ll be debugging with a ladle. So we keep a simple pipeline: first compute the numbers in Kotlin, then pack them into a JSON tree.

Here’s the scheme we’ll repeat like a mantra (and yes, programmers love mantras too; they just call them “patterns”):

flowchart TD
    A[Data in Kotlin: List⟨Expense⟩] --> B[Computations: total, byCategory]
    B --> C[Build JsonElement: buildJsonObject/buildJsonArray]
    C --> D[Encode to string: Json.encodeToString]
    D --> E[Output/save]

From Kotlin’s point of view, buildJsonObject { ... } and buildJsonArray { ... } are DSL builders based on lambdas with receiver: inside the block, you’re as if “inside” the builder object and call its methods. Under the hood, the compiler works hard to infer types so you write fewer “extra words”—the same spirit as apply/with and other idiomatic constructs. And if you dig deeper, such builder DSLs in Kotlin rely on type inference mechanisms inside lambdas (builder-style inference).

3. Preparation: JsonElement, models, and converters

Basic building blocks of manual JSON assembly

To assemble JSON manually, it’s important to stop thinking “I am writing JSON” and start thinking “I am creating a JsonElement.” This greatly reduces the chance of making a syntax error, because errors become “type-level” (the compiler complains) rather than “string-level” (the user complains).

Below is the minimal set we need today:

What we want to get What we represent it with
JSON object
{...}
buildJsonObject { ... }
→
JsonObject
JSON array
[...]
buildJsonArray { ... }
→
JsonArray
String/number/boolean
JsonPrimitive(...)
null
JsonNull

Let’s start with a very short example to get a feel for the syntax and not get intimidated. There’s no domain logic here—just “built an object → printed it.”

import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

fun main() {
    val obj: JsonObject = buildJsonObject {
        put("status", JsonPrimitive("ok"))
        put("count", JsonPrimitive(3))
        put("note", JsonNull)
    }
    println(obj) // {"status":"ok","count":3,"note":null}
}

Note: put takes a JsonElement, which means we turn “raw” Int and String into JsonPrimitive. Yes, it’s a bit more verbose—but extremely transparent.

Project models: Expense and report data

So the examples aren’t “in a vacuum,” let’s continue the practical-project thread. Suppose we have an expense: an identifier, a name, a category, and an amount (in whole currency units, so we don’t argue about fractions and rounding).

If in your project the fields are named a bit differently—no problem: today the style matters, not the exact names.

data class Expense(
    val id: Int,
    val title: String,
    val category: String,
    val amount: Int
)

Now let’s sketch a small list of expenses so the report can be built right in main. In a real project, the list would come from your storage (file/memory), but for us right now it’s more important “how to assemble JSON.”

fun sampleExpenses(): List<Expense> = listOf(
    Expense(id = 1, title = "Coffee", category = "Food", amount = 220),
    Expense(id = 2, title = "Transit", category = "Transport", amount = 75),
    Expense(id = 3, title = "Pizza", category = "Food", amount = 540)
)

JSON for a single entity: expenseToJson(...)

When you build reports, there’s almost always a repeating part: “what one item looks like in JSON.” If you don’t extract it into a function, your code will grow copy-paste of put("id", JsonPrimitive(...))—and then you’ll definitely update the format in one place but forget in another. That’s the classic “I have two versions of the truth” bug—no philosophy, just bugs.

Let’s make a small converter from one expense to a JsonObject. It doesn’t serialize “magically”; it explicitly lays out the fields—and that’s a plus, because you fully control the format.

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

fun expenseToJson(e: Expense): JsonObject = buildJsonObject {
    put("id", JsonPrimitive(e.id))
    put("title", JsonPrimitive(e.title))
    put("category", JsonPrimitive(e.category))
    put("amount", JsonPrimitive(e.amount))
}

From this point on, the report will assemble like LEGO: we turn the list of expenses into an array of JSON objects via expenseToJson, instead of writing the fields again each time.

4. Assembling the report: aggregates and an array of items

Now the most interesting part: a report usually contains “everything at once.” And there’s nothing scary about that, as long as you don’t try to calculate sums right inside buildJsonObject. We’ll do it the human way: compute first, then pack.

Compute aggregates in Kotlin

First, the total amount:

fun totalAmount(items: List<Expense>): Int =
    items.sumOf { it.amount }

Then totals by category. Yes, you can do this in different ways; we’ll take a simple and readable one: group first, then sum each group.

fun totalByCategory(items: List<Expense>): Map<String, Int> =
    items.groupBy { it.category }
        .mapValues { (_, list) -> list.sumOf { it.amount } }

Pack aggregates into JSON

Now we build the resulting JsonObject. Inside it will be total, the byCategory object, and the items array.

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject

fun buildExpensesReport(items: List<Expense>): JsonObject {
    val total = totalAmount(items)
    val byCat = totalByCategory(items)

    return buildJsonObject {
        put("total", JsonPrimitive(total))
        put("byCategory", buildJsonObject {
            for ((category, amount) in byCat) {
                put(category, JsonPrimitive(amount))
            }
        })
        put("items", buildJsonArray {
            for (e in items) add(expenseToJson(e))
        })
    }
}

It’s important to internalize this: byCategory is a nested JSON object, and items is a nested JSON array. We’re not “printing JSON,” we’re building a tree of elements.

5. Non-standard report structures

Reports are rarely a “copy of the database.” Usually the format wants something odd: renamed keys, merged fields, conditional sections, or nesting that doesn’t exist in the model. Manual assembly is exactly for this: you can describe the format directly in code without trying to stretch it onto the domain model.

Renaming keys and “human-friendly” fields

Suppose in the report we want not title but name, plus a field amountText (as a string), because it’s easier to show in the UI (yes, it’s debatable, but reports are sometimes done that way).

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

fun expenseToPublicJson(e: Expense): JsonObject = buildJsonObject {
    put("id", JsonPrimitive(e.id))
    put("name", JsonPrimitive(e.title))
    put("amount", JsonPrimitive(e.amount))
    put("amountText", JsonPrimitive("${e.amount} ₣ "))
}

Notice: the domain model didn’t change, but the report format did. That’s a good sign: you’re not “breaking” business logic for the sake of export.

JsonNull vs missing field

A very common subtlety: “the field is missing” and “the field exists but is null” are not the same thing. Even if it feels the same to you, somewhere on the other end of the wire there’s a person (or a service) who thinks differently. So we must be able to do both variants.

Let’s simulate a comment field: sometimes it exists, sometimes it doesn’t.

import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

fun expenseWithCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
    put("id", JsonPrimitive(e.id))
    put("title", JsonPrimitive(e.title))

    if (comment == null) {
        put("comment", JsonNull) // the field exists, but it is null
    } else {
        put("comment", JsonPrimitive(comment))
    }
}

Now the variant: “if there’s no comment, there is no field at all.” That’s a different contract.

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

fun expenseWithOptionalCommentJson(e: Expense, comment: String?): JsonObject = buildJsonObject {
    put("id", JsonPrimitive(e.id))
    put("title", JsonPrimitive(e.title))

    if (comment != null) {
        put("comment", JsonPrimitive(comment)) // no field if comment == null
    }
}

From the outside these JSONs look almost the same, but the meaning differs. And such differences are one reason why manual assembly is sometimes more honest than “auto-serialization however it turns out.”

6. Hybrid approach: part manual, part via encodeToJsonElement(...)

Sometimes you want to assemble the “wrapper” of the report manually, but some inner section you want to serialize as-is, because it’s already nicely described with @Serializable. That’s a normal strategy: you don’t have to choose between “only manual” and “only data class.”

Let’s make a small report meta-information object (format version and currency). It’s simple and stable—an excellent candidate for @Serializable.

import kotlinx.serialization.Serializable

@Serializable
data class ReportMeta(
    val formatVersion: Int,
    val currency: String
)

Now we’ll pack meta via Json.encodeToJsonElement(...), and the rest manually.

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement

fun buildReportWithMeta(items: List<Expense>): JsonObject {
    val meta = ReportMeta(formatVersion = 1, currency = "RUB")
    val total = totalAmount(items)

    return buildJsonObject {
        put("meta", Json.encodeToJsonElement(meta))
        put("total", JsonPrimitive(total))
    }
}

Why is this convenient? Because ReportMeta is a “small data model,” and it can be reused. But the report itself is a “non-standard structure,” and it’s easier to control manually.

7. How to turn JsonElement into a string

When you’ve assembled a JsonObject or JsonArray, a simple question arises: “OK, how do I get a normal JSON text?” In Kotlin you might want to do .toString()—and yes, it will produce a JSON representation. But most of the time it will be a compact one-liner. For logs that’s fine, but for reports that a human reads, you usually want it “pretty.”

Let’s do two variants: compact and pretty.

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.serializer

fun main() {
    val report: JsonObject = buildJsonObject { put("status", JsonPrimitive("ok")) }

    println(report.toString()) // {"status":"ok"}

    val prettyJson = Json { prettyPrint = true }
    val text: String = prettyJson.encodeToString(JsonElement.serializer(), report)
    println(text) // a nice multi-line JSON
}

Important point: we encode exactly a JsonElement, because our report is already a JSON tree, not an @Serializable class. That’s logical: today we build JSON “as a structure,” not “as a model.”

8. Typical mistakes when assembling JSON by hand

Mistake #1: concatenating JSON strings.
When your hands reach for something like "{\"total\": $total}", it’s worth remembering that you already know how to build a JsonObject. String concatenation breaks on quotes, newlines, escaping, and “do I need a comma here?” With buildJsonObject you at least have the compiler as an ally, not an indifferent observer.

Mistake #2: mixing report calculation and JSON packing in one place.
If inside buildJsonObject { ... } you start doing groupBy, sumOf, sorting, and filtering, the code quickly turns into a “combine harvester.” First compute everything into Kotlin variables (val total, val byCat), and then calmly pack it. This makes it easier to debug both the calculations and the format.

Mistake #3: confusing “no field” with “field = null.”
If sometimes you omit the key and sometimes you put JsonNull, an external consumer may behave differently. This is not a “minor detail”; it’s part of the format contract. Decide the rule once and follow it everywhere.

Mistake #4: duplicating key names across the project as strings.
Today you wrote "total", tomorrow "Total", the day after tomorrow "sum"—and now your report “sometimes parses.” Even in a small learning project, it’s better to keep keys in one place (for example, object ReportKeys { const val TOTAL = "total" }) and use constants so you don’t breed random typos.

Mistake #5: making “almost JSON” by putting something other than a JsonElement into the object.
Sometimes beginners try to do put("count", 3) and are surprised that the compiler complains. In manual assembly, you work at the JsonElement level, so convert numbers and strings into JsonPrimitive. Yes, it’s a couple characters longer, but the format becomes strictly typed at the API level.

Mistake #6: trying to make the report an “ideal universal format” right away.
A report is a contract. Today you need total and items, tomorrow you’ll add byCategory, the day after tomorrow meta. Manual assembly is good because you can change the format surgically, but that doesn’t mean you should stuff everything into it “just in case.” Build only what’s actually used, and extend as needed.

1
Task
Kotlin SELF, level 49, lesson 3
Locked
Operation status
Operation status
1
Task
Kotlin SELF, level 49, lesson 3
Locked
Profile questionnaire
Profile questionnaire
1
Task
Kotlin SELF, level 49, lesson 3
Locked
Export expenses
Export expenses
1
Task
Kotlin SELF, level 49, lesson 3
Locked
Category report
Category report
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION