JavaRush/Courses/Kotlin SELF/Clean parsing without input: parse…OrNull

Clean parsing without input: parse…OrNull

Open

1. Why separate parsing from input

When you write a small educational main, it’s very easy to mix everything into one messy blob: “printed a prompt → read a line → parsed it right away → complained right away → decided what to do next right away”. At 10 lines this seems fine. At 30 it already looks like instant noodles: technically edible, but embarrassing to show people.

The problem is not that input is “bad”. The problem is that input is about communicating with a person, while parsing is about converting text into a value. If these two tasks live in the same place, you lose reuse: tomorrow you’ll need to parse a string not from the console but, say, from a variable (or a test), and you’ll suddenly discover that your “parser” prints prompts. Awkward.

Another reason is debugging. A “clean” parser can be tested on specific strings: "10", " 10 ", "", "abc". You don’t even need a user for that, which means fewer accidents and more control (and fewer chances to argue with the keyboard about why it typed the wrong thing again).

What a “clean parser” is and why it should be “silent”

By a “clean parser” in this lecture we mean a simple thing: it’s a function that accepts a String and returns a T?, and it doesn’t read anything and doesn’t print anything. It works like a small converter: you give it a string → you get a value, or null if the string is “in the wrong format”.

Such a parser is sometimes called “silent”: it doesn’t scold the console and it doesn’t explain who’s at fault. And that’s not because we’re insensitive. It’s because the parser must not decide how exactly to react. The reaction depends on the scenario: sometimes we want to ask the user to enter again, sometimes to terminate the program, sometimes to accept a default value. The parser doesn’t know that and must not know.

Notice the naming convention: the suffix OrNull in Kotlin often means “return null if it didn’t work”. The standard library follows this idea in various places (for example, some operations have *OrNull versions that return null instead of crashing). We do the same, but for parsing user input.

2. String normalization

Almost any user input needs a bit of “prep”. A person may add spaces, accidentally hit space at the end, type “YES” in caps, or type “Yes” with a different case. A computer is a literal creature: it doesn’t “understand what you meant” unless you explicitly program it. So we introduce an intermediate step: normalization.

Normalization is not “rule validation” (that’s the next lecture). It’s specifically turning text into a shape that’s convenient to work with: remove leading/trailing spaces, and, if needed, convert to lowercase. Our goal is for different variants that are “the same in meaning” to become identical strings.

Below are two small functions that we’ll use as building blocks:

fun normalizeTrim(raw: String): String {
    return raw.trim()
}

fun normalizeCommand(raw: String): String {
    return raw.trim().lowercase()
}

There’s an important detail here: trim() removes spaces at the edges, but it doesn’t touch spaces inside. That is, " Hello world ".trim() becomes "Hello world". And that’s fine: if the person actually entered two spaces inside, it might be part of the text.

To see how this works “live”, you can make a tiny main:

fun main() {
    val raw = "   YeS   "
    val t = normalizeTrim(raw)
    val c = normalizeCommand(raw)

    println("raw='$raw'")  // raw='   YeS   '
    println("trim='$t'")   // trim='YeS'
    println("cmd='$c'")    // cmd='yes'
}

Note: we are not arguing yet whether this input is “good”. We’re just transforming it into a convenient shape.

Mini pipeline diagram: “input → normalization → parsing”

flowchart LR
    A["raw String
as entered by the user"] --> B["normalize
trim/lowercase"] B --> C["parse…OrNull
String → T?"] C --> D["the scenario decides
what to do with null"]

3. Number parsers: Int and Double

When we previously wrote readIntOrNull(prompt), we did three things at once: printed a prompt, read a string, parsed it. Now we separate these tasks.

parseIntOrNull: parse a number without the console

We need a parser that does two things: first normalizes the string (at least trim()), then tries to parse. If the string is empty after trim(), return null (because “empty” is definitely not a number, and that’s better than hoping for a miracle).

fun parseIntOrNull(raw: String): Int? {
    val s = raw.trim()
    if (s.isEmpty()) return null
    return s.toIntOrNull()
}

Notice: there is no println, no readln(). This is a pure function: same raw → same result. That means it’s easy to use anywhere.

Let’s test it on different inputs:

fun main() {
    println(parseIntOrNull("10"))      // 10
    println(parseIntOrNull(" 10 "))    // 10
    println(parseIntOrNull("   "))     // null
    println(parseIntOrNull("10 years"))  // null
}

And here it’s very useful to mentally distinguish situations: " " is not a “program error”, it’s a normal “the data doesn’t fit” situation. We return null and let the scenario decide what to do next.

Table: “what it was → what it became”

raw (input) after trim() result of parseIntOrNull
"10"
"10"
10
" 10 "
"10"
10
""
""
null
"   "
""
null
"10.5"
"10.5"
null
"abc"
"abc"
null

parseDoubleOrNull: numbers with a dot

With Double the story is almost the same, only the method is called toDoubleOrNull(). And there’s a nuance from the human world: some people type "3.14", others, out of habit, type "3,14". Kotlin (and the JVM) expects a dot by default. We for now won’t “fix the world”, otherwise we’ll quietly drift into a more advanced normalization topic. At this stage a simple, honest contract is enough: dot means a number; comma means null.

fun parseDoubleOrNull(raw: String): Double? {
    val s = raw.trim()
    if (s.isEmpty()) return null
    return s.toDoubleOrNull()
}

Quick test:

fun main() {
    println(parseDoubleOrNull("2.5"))     // 2.5
    println(parseDoubleOrNull(" 2.5 "))   // 2.5
    println(parseDoubleOrNull("2,5"))     // null
    println(parseDoubleOrNull(""))        // null
}

If it feels like "2,5" should be accepted as "2.5", you’re thinking in the right direction. It’s just that today our task is to separate parsing from input and introduce basic normalization (trim, lowercase). More “bold” normalizations are better done deliberately; otherwise you can silently break input in places where a comma is part of the text.

4. Command parsing: parseYesNoOrNull

Numbers are nice, but console programs often ask “yes/no”, “y/n”, “ok/not ok”. And this is where lowercase() becomes our best friend: you don’t want to list in when variants like "YES", "Yes", "yEs", because that’s how you accidentally invent a new alphabet.

Let’s make a parser that accepts a string and returns a Boolean?: true for “yes”, false for “no”, null if the answer is unclear.

fun parseYesNoOrNull(raw: String): Boolean? {
    val s = raw.trim().lowercase()

    return when (s) {
        "yes", "y", "\u0434\u0430", "\u0434" -> true
        "no", "n", "\u043d\u0435\u0442", "\u043d" -> false
        else -> null
    }
}

Why Boolean? and not just Boolean? Because otherwise we’d have to “assign” some default answer. And that’s already a scenario decision. The parser should honestly say: “I didn’t understand”.

Test:

fun main() {
    println(parseYesNoOrNull(" YES "))  // true
    println(parseYesNoOrNull("\u043d"))      // false
    println(parseYesNoOrNull("\u0430\u0433\u0430"))    // null
}

Notice how neatly normalization “sits” here: we don’t list 20 case variants because we convert to lowercase once.

5. How to connect input and parsing

Now we have “clean” parsers. But a console program still has to read input. And this is where a very pleasant composition appears: the input utility stays small and dumb (in a good way), while conversion logic moves into parse…OrNull.

Let me remind you of the “zero level” from the previous lecture (one possible variant):

fun readLine(prompt: String): String {
    print(prompt)
    return readln()
}

readln() reads an entire line from standard input. After that we can make input “wrappers” that already use our parsers—and it will look very clean and uniform:

fun readIntOrNull(prompt: String): Int? {
    val raw = readLine(prompt)
    return parseIntOrNull(raw)
}

fun readDoubleOrNull(prompt: String): Double? {
    val raw = readLine(prompt)
    return parseDoubleOrNull(raw)
}

This may feel like an “extra step”, but in practice you win in several places at once: you can change parseIntOrNull without touching input, and vice versa. And you can parse strings that didn’t come from the console, using the same functions.

Small demo: “a parser without the console”

fun main() {
    val textFromSomewhere = " 42 "
    val x = parseIntOrNull(textFromSomewhere)

    println("x = $x")  // x = 42
}

The scenario doesn’t read input, doesn’t print prompts, but it reuses the same parser. That’s the core idea of the lecture: parsing becomes a separate “building block”.

6. Mini example: user questionnaire

To avoid staying at the level of abstractions, let’s take a small scenario we’ll build on throughout the day: a console “questionnaire” that asks for a name, an age, and “do you agree to continue”. Today we won’t do repeated input and we won’t introduce a separate rule-validation layer (like “age 0..150”)—that will be the next lecture. We’ll just read neatly and parse safely.

Here’s what a main might look like using our functions:

fun main() {
    val nameRaw = readLine("Name: ")
    val name = nameRaw.trim()

    val age = readIntOrNull("Age: ")
    val agree = parseYesNoOrNull(readLine("Continue? (yes/no): "))

    println("name='$name' age=$age agree=$agree")
    // Example: name='Ira' age=20 agree=true
}

Notice an interesting thing: for name we’re not doing ...OrNull yet. We just do trim(), because “non-empty name” is already a rule (validation), not parsing. Today the goal is to learn not to mix layers.

If you want to make the same thing look more “API-like”, you can move reading the “yes/no” answer into a function that uses the parser:

fun readYesNoOrNull(prompt: String): Boolean? {
    val raw = readLine(prompt)
    return parseYesNoOrNull(raw)
}

And then main becomes a bit more of a “story of actions” rather than a bundle of technical details:

fun main() {
    val name = readLine("Name: ").trim()
    val age = readIntOrNull("Age: ")
    val agree = readYesNoOrNull("Continue? (yes/no): ")

    println("name='$name' age=$age agree=$agree")
}

There’s a bit of self-irony here: yes, we wrote a few functions so that main became 3 lines shorter. But it’s like doing the dishes: at first it feels like “eh, later”, and then suddenly you run out of kitchen.

7. Common mistakes

When you first separate parsing from input, the brain often resists: “why make it so complicated, I can just do toIntOrNull() right in main”. That’s a normal stage, like denial before acceptance. Below are the most common rakes people step on in this topic.

Mistake #1: the parser starts printing to the console.
It’s very tempting to do inside parseIntOrNull something like println("Enter a number!"). But then the function stops being universal: you’ll want to use it somewhere else (for example, when processing a command string), and it will suddenly start talking to the user. Keep the parser “silent”: it returns T?, and messages are the scenario’s job.

Mistake #2: you forgot trim() and got a “magic null”.
Beginners often get surprised: “I entered 10, why is it null?”. And then it turns out it was "10 " (a trailing space) or " 10" (a leading space). Without trim(), such cases turn into micro-bugs that are more annoying than big errors because they look “random”. Do trim() inside the parser, and input will become noticeably more human-friendly.

Mistake #3: confusing parsing with validation, and the parser grows into a monster.
If you start checking ranges, “at least 1”, “at most 100”, and other business constraints right inside parseIntOrNull, the function quickly becomes too specific. Today the parser answers “is this an integer at all?”. The question “does it fit the rules?” is better moved into a separate layer (and that’s exactly where the plan is taking us next).

Mistake #4: returning false instead of null in parseYesNoOrNull.
The logic “if it’s unclear, it means no” feels convenient, but it mixes two different meanings: “the user said no” and “we didn’t understand what they said”. As a result, the scenario loses the ability to react correctly to unclear input. Boolean? is more honest here: true/false/null explicitly encode three different states.

Mistake #5: creating a “universal parser for everything” with a bunch of flags.
Sometimes there’s a desire to write one function like parseNumberOrNull(raw, allowDouble, allowComma, defaultValue, ...). It looks powerful, but it’s hard to read and almost always leads to parameter confusion. At the level of our course, it’s better to keep several small, obvious functions: parseIntOrNull, parseDoubleOrNull, parseYesNoOrNull. Small functions are easier to test, explain, and reuse.

1
Task
Kotlin SELF,  14 level,  2 lecture
Locked
Chat commands
Chat commands
1
Task
Kotlin SELF,  14 level,  2 lecture
Locked
Number input
Number input
1
Task
Kotlin SELF,  14 level,  2 lecture
Locked
Settings toggle
Settings toggle
1
Task
Kotlin SELF,  14 level,  2 lecture
Locked
Level boundaries
Level boundaries
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet