JavaRush /Courses /Kotlin SELF /JSON Data Validation

JSON Data Validation

Kotlin SELF
Level 49, Lesson 2
Available

1. Parsing and validation: responsibilities and the result contract

Even if JSON was parsed successfully, that still does not mean the data fits our application. In this lecture we will build a validation layer that turns a JsonElement into normal Kotlin data (or a clear error), and does it without using exceptions as the “default” mechanism for control flow.

When you start accepting data “from the outside world” (a file, a user, someone else’s service), you really want to believe the best: “well, it says amount, so it will be a number.” Reality is usually less romantic: the field may be missing, may be null, may be a string "100", may be a string "one hundred", or may even be named differently. That’s why we need discipline: first answer “is this even JSON?”, and only then — “is this the data we are prepared to process?”.

Parsing vs validation

Parsing is checking syntax. In other words: “the string follows JSON rules”. If a comma or quote is missing, the parser will say “I can’t” (and that’s fine). Validation is checking meaning and application requirements: fields are present, types are correct, values are within reasonable ranges, strings are not empty, numbers are not negative, and so on.

It helps to keep the following diagram in mind (it will also be our functional architecture):

flowchart LR
    A["String (JSON text)"] --> B["parseToJsonElement()"]
    B -->|success| C["JsonElement (tree)"]
    B -->|error| E["Error: 'invalid JSON'"]
    C --> D["validate(...)"]
    D -->|Ok| F["Normal Kotlin data"]
    D -->|Error| G["Unambiguous error for user/log"]

The main benefit of this separation is that the code becomes predictable: we have a “point” where JSON becomes a tree, and a separate “point” where the tree becomes a valid model. If something goes wrong, we know exactly at which stage and why.

Why Boolean is not enough, and why we need Ok/Error

When a beginner writes “validation”, they often end up with a function like fun isValid(x): Boolean. It’s a decent start, but it breaks against reality: if validation returns false, we need to understand why. Otherwise the application turns into a fortune-teller: “the data is invalid… which part? where? what should be fixed?”.

So we give the validator a grown-up contract: it returns either success or an error with a message. At this stage, let’s agree not to collect “a bag of 17 errors at once”, and instead return one reason — it’s simpler both for the code and for the user.

Let’s make a universal result type:

sealed class ValidationResult<out T> {
    data class Ok<T>(val value: T) : ValidationResult<T>()
    data class Error(val message: String) : ValidationResult<Nothing>()
}

Two things matter here. First, Ok stores the already-prepared value of the required type T, so the next code works with normal data instead of continuing to “poke at JSON with a stick”. Second, Error stores a message you can show to the user or log (in a console project, those are often almost the same thing).

And one more bonus: sealed class works great with when — the compiler forces us to handle all cases (meaning we won’t forget about the error case and pretend everything is fine).

2. A mini story: importing expenses into a CLI app

So the examples are not “in a vacuum”, we’ll continue developing our educational console app — a conditional Expense Tracker (expense tracking). Earlier we already could store expenses and serialize them to JSON via @Serializable, but today the scenario is different: imagine someone sent us JSON of an unknown format (or “almost ours”, but with surprises), and we want to import it.

We will validate one expense entity with fields:

  • title: a string, non-empty (for example, "Coffee")
  • amount: an integer, strictly greater than zero (for example, 250)
  • category: a string (may be empty/missing — then we will substitute "other")

Let’s make a model of “already normalized data” that we will get after validation:

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

Why Draft? Because it’s a “draft” after validating the input: we are not talking about persistence, IDs, and the like yet. We just want to guarantee the data is valid and safe for further logic.

3. Pipeline: parse JSON and validate the data

A very common mistake is to make one giant function that parses, validates, prints, and “just in case” catches all exceptions. It usually looks like a combine harvester: heavy, fragile, and scary. We’ll do it more neatly: parseJson(text) is responsible only for ensuring the text is valid JSON; validate... is responsible only for ensuring the structure and values fit.

Parse text into a JsonElement tree

parseToJsonElement can throw an exception if the text is not valid JSON, so we honestly catch the error and return Error:

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

fun parseJson(text: String): ValidationResult<JsonElement> {
    return try {
        ValidationResult.Ok(Json.parseToJsonElement(text))
    } catch (e: Exception) {
        ValidationResult.Error("Invalid JSON: ${e.message}")
    }
}

Note: we do not do any field checks for amount/title here. For now we only care about one thing: “did the tree get built or not?”

Helper functions: less copy-paste, more meaning

A validator almost always consists of repeated steps: “get the field”, “make sure it’s a primitive”, “take the string representation”, “convert it”. If you write this by hand every time, you will very quickly start hating JSON, life, and your keyboard.

So let’s reuse helper functions:

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

fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
    val el = obj[key] ?: return null
    return el as? JsonPrimitive
}
import kotlinx.serialization.json.JsonObject

fun getStringOrNull(obj: JsonObject, key: String): String? =
    getPrimitiveOrNull(obj, key)?.content
import kotlinx.serialization.json.JsonObject

fun getIntOrNull(obj: JsonObject, key: String): Int? {
    val text = getStringOrNull(obj, key) ?: return null
    return text.toIntOrNull()
}

There’s a “philosophy” here: these functions do not throw exceptions and do not substitute default values. They only say: “if I can extract it correctly — I did; if I can’t — I returned null”. And then the validator decides whether null is acceptable or is an error.

Validate one expense: JsonElement -> Ok(ExpenseDraft) / Error(message)

Now the best part: turning a piece of JSON into normal data. We expect an object (JsonObject), check fields, check constraints, and return a result. It’s important to keep the order of checks: structure first (object or not), then presence/type, then rules (ranges, empty strings).

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

fun validateExpense(el: JsonElement): ValidationResult<ExpenseDraft> {
    val obj = el as? JsonObject ?: return ValidationResult.Error("Expense must be a JSON object")

    val title = getStringOrNull(obj, "title")?.trim()
        ?: return ValidationResult.Error("Field 'title' is required")

    if (title.isBlank()) return ValidationResult.Error("Field 'title' must be non-blank")

    val amount = getIntOrNull(obj, "amount")
        ?: return ValidationResult.Error("Field 'amount' must be an integer")

    if (amount <= 0) return ValidationResult.Error("Field 'amount' must be positive")

    val categoryRaw = getStringOrNull(obj, "category")?.trim()
    val category = if (categoryRaw.isNullOrBlank()) "other" else categoryRaw

    return ValidationResult.Ok(ExpenseDraft(title = title, amount = amount, category = category))
}

Note the style: lots of early return. This is not “laziness”, but a practical guard-clause technique: as soon as we find a problem — we immediately return an error and don’t continue calculations in a “broken” state.

One more point: we normalize the data. We call trim() on strings, and convert an empty/missing category to "other". Thanks to this, later in the code we have fewer conditions and fewer chances to crash the app on an empty string.

Validate a list: add an index and stop at the first error

Imports almost always come as a list: an array of expenses. And then an additional question appears: if one element is bad, what do we do with the rest? Within this lecture we will choose the “fail fast, but nicely” strategy: as soon as we encounter an error — we stop and return Error, but include the item index in the message. This already makes life much easier when debugging input data.

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement

fun validateExpenses(el: JsonElement): ValidationResult<List<ExpenseDraft>> {
    val arr = el as? JsonArray ?: return ValidationResult.Error("Root must be a JSON array")

    val items = mutableListOf<ExpenseDraft>()
    for ((index, itemEl) in arr.withIndex()) {
        when (val r = validateExpense(itemEl)) {
            is ValidationResult.Ok -> items.add(r.value)
            is ValidationResult.Error -> return ValidationResult.Error("Item[$index]: ${r.message}")
        }
    }
    return ValidationResult.Ok(items)
}

Yes, we could “collect all errors”, but that’s a separate (and more complex) technique. For now it’s more important to learn how to keep the code simple and predictable: one error — one clear answer.

Glue the pipeline in main: readability is more important than “everything in one line”

When everything is ready, it’s tempting to do it nicely: “parse, validate, move on — all in one line”. But for beginners this often turns into magic that is hard to debug. So we’ll assemble the pipeline explicitly, using intermediate vals. This code looks a bit longer, but it’s easy to read and easy to fix.

import kotlinx.serialization.json.JsonElement

fun main() {
    val text = """
        [
          {"title":"Coffee","amount":250,"category":"food"},
          {"title":"Book","amount":900,"category":"education"}
        ]
    """.trimIndent()

    val parsed: ValidationResult<JsonElement> = parseJson(text)

    when (parsed) {
        is ValidationResult.Ok -> {
            val validated = validateExpenses(parsed.value)
            println(validated) // Ok(value=[ExpenseDraft(...), ...]) ili Error(...)
        }
        is ValidationResult.Error -> println(parsed.message) // Invalid JSON: ...
    }
}

Here you can clearly see the separation of responsibilities: parseJson is responsible for syntax, validateExpenses is responsible for application requirements. And yes, println(validated) prints a “technical” view of the result; in a real CLI you would output more user-friendly messages, but that’s a UI design question, not validation.

4. require() and check(): where they belong, and where Ok/Error belong

Sometimes a student reasonably asks: “Why all this ValidationResult if we have require(condition) and check(condition)?”. It’s a good question, because Kotlin can indeed throw exceptions automatically via preconditions: require() throws IllegalArgumentException, and check() throws IllegalStateException. That’s convenient and often makes code shorter.

But the nuance is what exactly you are checking. require() and check() are tools for situations where the program must not continue because a contract has been violated: for example, a programmer called a function incorrectly, or an object ended up in an impossible state. These checks are good in “internal” code, where errors mean a bug in the logic.

But external data (JSON “from the street”) is not a program bug. It’s a normal real-life situation: data can be corrupted, incomplete, or version-incompatible. And in such cases, exceptions as the “normal control path” often make the code less readable. It’s much nicer to return ValidationResult.Error("..."), show a clear message to the user, and finish the import correctly without crashing the whole program.

In short: require/check are about developer mistakes, Ok/Error are about input data errors.

5. Common mistakes when validating JSON data

Mistake #1: mixing parsing and validation in one “combine-harvester” function.
When a single function does parseToJsonElement, field checks, console printing, and writing to a collection at the same time, it becomes hard to understand at which step everything broke. Splitting into two stages — parsing and validation — makes the code transparent: if it doesn’t parse, the problem is in the syntax; if it parses but the validator complains, the problem is in the structure or values.

Mistake #2: using forced casts as and the !! operator when reading the tree.
Such code works exactly until the first surprise in the data, and then crashes at the worst possible moment. Validation is valuable because it should be resilient: as? plus a clear Error(...) is almost always better than “it must be an object because I said so”.

Mistake #3: early replacement of a missing field with a default value that masks the problem.
If in a helper function you do “no amount — let it be 0”, the validator may “successfully” accept a record that is actually invalid. Much safer is: the helper returns null, the validator decides whether that is an error or an acceptable absence, and only the validator has the right to substitute defaults (as we did with category = "other").

Mistake #4: checking “business rules” before checking types and presence.
If you start with if (amount <= 0) before you have even ensured that amount is a number, you’ll get either a messy set of conditions or an exception. A stable order is usually: structure → presence → type → conversion → value constraints. Then error messages will be clear and repeatable.

Mistake #5: returning Boolean instead of a result with a reason.
false does not explain what to fix. A message in Error is not a luxury, but the minimum level of politeness toward your future self who, a week later, will be debugging someone else’s JSON at 2 a.m. and whispering: “who wrote this… oh right, me”.

1
Task
Kotlin SELF, level 49, lesson 2
Locked
Schema-less parser
Schema-less parser
1
Task
Kotlin SELF, level 49, lesson 2
Locked
Profile Draft
Profile Draft
1
Task
Kotlin SELF, level 49, lesson 2
Locked
List of settings
List of settings
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION