JavaRush /Courses /Kotlin SELF /Building a Mini Input API Without Duplication

Building a Mini Input API Without Duplication

Kotlin SELF
Level 14, Lesson 5
Available

1. The idea and principles of a mini input API

When you write your first console programs, it feels normal to copy 5–7 lines of input code: “well, I just asked for a number again”. But half an hour passes, and suddenly you have ten places where you forgot trim(), three places where you accidentally used toInt() instead of toIntOrNull(), and one place where you print a prompt without a trailing space, so the user types the number right up against the text. And all of that is different bugs of the same nature: the logic is repeated, which means it will drift apart.

A mini API is not a “big input framework”, but a small set of functions that you agree to use consistently. Our goal is to remove repeated pieces (printing the prompt, normalization, safe parsing, range checks) and leave in main() only what belongs to the program scenario: what data exactly we ask for and what we do next.

Naming conventions

If you name functions arbitrarily, the mini API quickly turns into a collection of random spells: “does readNumber throw an exception or return null?”, “does getInt trim or not?”. So we introduce simple conventions and stick to them. It is like agreeing within a team that “Earl Grey tea” is not “latte coffee” (otherwise there will be tears, resentment, and sudden nulls).

Let us agree on the following naming style:

Prefix / suffix Example Meaning (the function’s promise)
read…
readIntOrNull(prompt)
The function reads from the console (
readln()
inside)
parse…
parseIntOrNull(raw)
The function does not read or print; it only parses a string
validate…
validateIntInRangeOrNull(x, min, max)
The function checks rules for an already-prepared value
…OrNull
readNonEmptyLineOrNull(...)
May return
null
if “it failed to produce a valid value”
without
OrNull
readLinePrompt(...)
Returns a value guaranteed (either it is just a “raw string”, or the function “cannot fail to return”)

A separate important thought about require and check: this is not about “invalid user input”, but about incorrect usage of the function by the programmer. They throw exceptions and are used as precondition checks.

Composition without duplication

To keep the mini API from growing into an “all-in-one machine for every possible case”, it helps to keep a simple layered scheme in mind. Imagine a pipeline: the input is the user’s raw string, the output is either a valid value or null, meaning “this step failed”.

Below is the diagram we will aim for (and which is easy to extend without copying code):

flowchart TD
    A["prompt + readln()"] --> B[normalization: trim/lowercase]
    B --> C[parse...OrNull: String -> T?]
    C --> D[validate...OrNull: T -> T?]
    D --> E[scenario-level read...OrNull]

What matters here is that the “building blocks” are small and honest. parseIntOrNull() does not print “You did not enter a number”, because it has no idea how you want to communicate with the user. It simply says: “if I can parse it, here is an Int; if not, here is null”. The scenario (usually main) decides what to do with null: exit, ask again (you already know loops), show a hint, or pick a default value.

2. Input utilities in InputUtils.kt

It is tempting to immediately write one function like readIntInRangeWithRetriesAndAngryMessages(...), but that is a direct path to a 60-line monster. We will take a different route: we will assemble a file (or just a block in a single .kt), where each function performs one clear step. Then we will combine these steps into more convenient “composed” utilities.

Reading a line with a prompt

Let us start with the safe and predictable “level zero”: print a prompt and read a string. Yes, it is trivial. That is exactly why it is worth extracting: trivialities in code are better centralized.


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

Here we intentionally return String, not String?: in our console input scenarios we assume the user still enters something. (End-of-input is a separate topic; right now the overall pattern is more important.)

Input normalization

Normalization is when we turn “  Yes  ” into a normal “yes”, and “  10  ” into a normal “10”. This is not “validation”, because we are not forbidding anything; we are just removing noise.

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

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

If it feels too small — great. These little details later save you dozens of lines, because you stop scattering trim() all over your code like confetti.

Pure parsing

Now let us write parsers. They do not read from the console and do not print. They can handle an empty string neatly.

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

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

Yes, toIntOrNull() will return null on an empty string anyway, but the explicit check improves readability for a beginner: you can see that “empty” is a normal “it did not work” outcome.

Now a “yes/no” parser. We want to distinguish “no” from “I did not understand”. So we return Boolean?, not Boolean.

fun parseYesNoOrNull(raw: String): Boolean? {
    return when (normalizeCommand(raw)) {
        "yes", "y", "yeah", "yep", "sure" -> true
        "no", "n", "nope", "nay" -> false
        else -> null
    }
}

Validation

Now validators. They take an already-normal type (Int, Double, String) and enforce rules.

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    require(min <= max) { "Expected min <= max (min=$min, max=$max)" }
    if (x < min) return null
    if (x > max) return null
    return x
}

Here require(min <= max) is a precondition check: if the programmer passed incorrect bounds, you cannot keep going “as if everything is OK”. require exists for exactly this purpose, and on failure it throws IllegalArgumentException.

For strings, let us make a validator: “not empty after trim()”:

fun validateNonEmptyOrNull(raw: String): String? {
    val s = normalizeTrim(raw)
    if (s.isEmpty()) return null
    return s
}

And an example of validating a “strictly positive” number for Double (useful for prices, coefficients, etc.):

fun validatePositiveDoubleOrNull(x: Double): Double? {
    if (x <= 0.0) return null
    return x
}

Composed read…OrNull functions for the scenario

Now we can assemble functions that read from the console and use our building blocks inside. The main goal is to avoid duplication inside and to make similar things look the same.

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

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

Let us make a “non-empty line”:

fun readNonEmptyLineOrNull(prompt: String): String? {
    val raw = readLinePrompt(prompt)
    return validateNonEmptyOrNull(raw)
}

Let us make a “yes/no”:

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

And finally the “tastiest” utility: a number in a range. The important thing is that there is no custom parsing and no custom range logic here — we reuse what we already wrote.

fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
    require(min <= max) { "Expected min <= max (min=$min, max=$max)" }

    val x = readIntOrNull(prompt) ?: return null
    return validateIntInRangeOrNull(x, min, max)
}

Notice the effect: if tomorrow you decide that readIntOrNull should, for example, do trim() differently, you will change it in one place, and that automatically improves all higher-level functions.

3. A readable main() as a scenario

Now we will do the most important exercise of this lecture: write main() so it looks like a script of communicating with the user. That is: first ask for the room name, then dimensions, then price, then output. Without a mess of trim() and toDoubleOrNull() at every step. If you have ever read someone else’s code and felt your brain trying to jump out the window — that is exactly what the lack of such utilities looks like.

Below is a small example app that calculates wall area (and optionally ceiling area) and estimates the painting cost. We do not use collections, files, or “complex architecture” — only what is already available up to level 14.

fun main() {
    println("Room painting calculator v0")

    val roomName = readNonEmptyLineOrNull("Room name: ")
    if (roomName == null) {
        println("The name must not be empty.")
        return
    }

    val width = readIntInRangeOrNull("Width (1..50), m: ", 1, 50)
    if (width == null) {
        println("Width is invalid.")
        return
    }

    val length = readIntInRangeOrNull("Length (1..50), m: ", 1, 50)
    if (length == null) {
        println("Length is invalid.")
        return
    }

    val height = readIntInRangeOrNull("Height (2..10), m: ", 2, 10)
    if (height == null) {
        println("Height is invalid.")
        return
    }

    val priceRaw = readDoubleOrNull("Painting price per 1 m² (e.g. 12.5): ")
    val pricePerSqm = if (priceRaw == null) null else validatePositiveDoubleOrNull(priceRaw)
    if (pricePerSqm == null) {
        println("Price must be a number greater than 0.")
        return
    }

    val includeCeiling = readYesNoOrNull("Paint the ceiling? (yes/no): ")
    if (includeCeiling == null) {
        println("I did not understand. Enter yes/no.")
        return
    }

    val wallsArea = 2.0 * (width + length) * height
    val ceilingArea = width.toDouble() * length.toDouble()
    val totalArea = if (includeCeiling) wallsArea + ceilingArea else wallsArea

    val totalCost = totalArea * pricePerSqm

    println()
    println("Room: $roomName")
    println("Wall area: ${"%.2f".format(wallsArea)} m²")
    println("Total area: ${"%.2f".format(totalArea)} m²")
    println("Cost: ${"%.2f".format(totalCost)}")
}

Yes, there is a repeated pattern here:

val x = read...
if (x == null) { println(...); return }

But the important difference is that this repetition is already “scenario-level”. It describes a business decision: “if we failed to obtain valid data, terminate the program”. And we removed low-level repetition (read a line, trim(), parse).

If later you want behavior like “do not exit; ask again”, you will be able to change only the scenario, without touching parsers and validators. (You already have loops, but today we intentionally do not implement “retrying input”, so as not to mix topics.)

4. How to keep the mini API small

It is very easy, once you get a taste for utilities, to start writing “even more convenient” functions that eventually do everything at once: read, validate, print errors, retry input, format prompts, and also get offended at the user for a wrong comma. That is fun for five minutes, and then you realize you do not have a mini API — you have a second main, just hidden inside a function.

A good balance usually looks like this: you have a few base functions (readLinePrompt, parseIntOrNull, validateIntInRangeOrNull), and a few convenient “composed” functions that you really need often (readIntInRangeOrNull, readNonEmptyLineOrNull, readYesNoOrNull). If you see that another function ended up with six parameters and the phrase “just in case”, that is almost always a signal that you mixed responsibility levels.

Also keep in mind the difference between a “user error” and a “programmer error”. A user might type forty two instead of 42 — that is expected, and then …OrNull returns null. But if a programmer calls readIntInRangeOrNull(prompt, 10, 1), that is a precondition violation, and it is better to catch it immediately with require(min <= max). require and check are designed for exactly this fail-fast behavior and throw exceptions so the problem is visible, rather than turning into a “quiet weird bug”.

5. Common mistakes when building a mini utilities API

Mistake #1: the function name implies “not nullable”, but it returns null.
If you named a function readInt(...), but it returns Int?, you force the reader to do archaeology: “where could null come from here?”. The OrNull suffix is an honest signal: “handle null; it is part of the contract”.

Mistake #2: utilities print errors inside themselves, and then main() prints again.
At first it seems convenient: “let readIntOrNull itself write ‘You did not enter a number’”. But as soon as you have two scenarios (for example, in one place you want “not a number”, and in another you want “expected age”), the utility starts getting in the way. It is usually better for the low level to return null, and for the scenario level to decide what and how to tell the user.

Mistake #3: mixing parsing and validation in one long function.
When one function contains trim(), then toIntOrNull(), then a range check, then printing errors — it is hard to reuse and hard to “test in your head”. Splitting into parse…OrNull and validate…OrNull makes the code linear and lets you assemble different combinations without copy-paste.

Mistake #4: returning “magic values” instead of null.
Sometimes you want to return -1, 0, or Int.MIN_VALUE to “indicate an error”. This is almost always deception: -1 can be a real value (e.g., temperature), 0 can be allowed (e.g., a discount), and you start multiplying additional conditions. In Kotlin, null is much more honest for these situations: “the value was not obtained / did not pass validation”.

Mistake #5: missing precondition checks where they are actually needed.
If a function takes min and max, and you do not check that min <= max, then somewhere in the future you will get strange behavior (for example, “the range accepts nothing”) and spend time debugging something that could have been caught immediately. In such places, require(...) is appropriate, because it is a function usage error, not a user error.

Mistake #6: bloating signatures “just in case”.
A mini API stops being a mini API when you get functions like readIntInRangeOrNull(prompt, min, max, retries, defaultValue, errorMessage, allowEmpty, ...). As a rule, it means you mixed scenario logic (what to do on error) with utility logic (how to safely obtain a value). It is better to keep utilities simple and assemble complex behavior outside, in main() or in separate scenario functions.

Mistake #7: chaotic normalization in different places.
If one parser does trim(), another does not, and somewhere else you manually do lowercase() in main(), you will inevitably get “weird” bugs like “YES was not recognized, but yes was”. Normalization must be consistent: it is better to have 1–2 small functions like normalizeTrim and normalizeCommand and use them everywhere the same way.

1
Task
Kotlin SELF, level 14, lesson 5
Locked
Terminal greeting
Terminal greeting
1
Task
Kotlin SELF, level 14, lesson 5
Locked
Age in the questionnaire
Age in the questionnaire
1
Task
Kotlin SELF, level 14, lesson 5
Locked
App rating
App rating
1
Task
Kotlin SELF, level 14, lesson 5
Locked
Sound command
Sound command
1
Survey/quiz
Input and validation functions, 14 level, 5 lesson
Unavailable
Input and validation functions
Input and validation functions
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION