JavaRush /Courses /Kotlin SELF /Validation as a separate layer: ranges, “not empty”, retu...

Validation as a separate layer: ranges, “not empty”, returning T?

Kotlin SELF
Level 14, Lesson 3
Available

1. Why separate validation into its own layer

If you’ve ever written code in the style “read a string → parse it right away → check the range right away → print an error right away → ask again right away”, then you already know how quickly everything turns into a text maze. And a maze with a minotaur: you lose the logic, repeat chunks of code, and then you’re afraid to touch the program because “what if it falls apart”.

The idea of a validation layer is simple: the parser is responsible for the format, and the validator is responsible for the rules.

For example, the string "12" can be parsed correctly into an Int, but still be wrong as “a kitten’s age in years” (if we expect 0..30) or wrong as “discount percent” (if we expect 0..100). That is, parsing succeeded, but the value is still “bad” — and that’s where validation begins.

To keep this in mind, it helps to imagine a pipeline like this:

flowchart TD
    A["Raw string from readln()"] --> B[Normalization: trim/lowercase]
    B --> C[Parsing: String -> T?]
    C --> D[Validation: T -> T?]
    D --> E[Scenario: use the value or show a message]

Note: both parsing and validation often return …?, i.e., a nullable type. This makes the pipeline steps “composable”: any step can say “didn’t work” via null, and the next layer decides what to do next.

2. Validator: what it is and why it returns T?

A validator is a small function that receives an already parsed value and checks it against a rule. If the rule holds, it returns the value (usually the same one). If not, it returns null.

Why T?? Because we want a single “did not pass” signal that’s easy to handle in Kotlin.

For example, a “number in range” validator can have this conceptual signature:

  • input: Int, bounds min, max
  • output: Int? — either the same number, or null

Another important thought: a validator should usually be silent. It doesn’t print to the console and it doesn’t decide the program’s fate. It just answers: “valid or not?”. This makes it reusable. The same validator can be applied to age, discount percent, or the number of attempts in a game — different scenarios, same range check.

And yes: inside a validator you often end up with a guard clauses style — sequential checks with early returns. It reads like plain text: “if less than the minimum — not ok; if greater than the maximum — not ok; otherwise ok”.

3. Numeric ranges: in, min..max, and boundaries

Range checking is the most common example of validation, because real data almost always has bounds. Age can’t be -500, percent can’t be 250, and the width of a rectangle in our tutorial input shouldn’t be “0” if we expect at least 1.

In Kotlin there is a very readable form: x in min..max. It uses the range min..max and the in operator.

A validator for Int in a range

Let’s start with a simple function:

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    if (x !in min..max) return null
    return x
}

Notice what matters here: we don’t print “Error!”, we don’t ask for input again, we don’t do anything dramatic. We just return null if the value is not in the range.

Let’s test it with an example:

fun main() {
    val a = validateIntInRangeOrNull(10, 1, 100)
    val b = validateIntInRangeOrNull(0, 1, 100)

    println(a) // 10
    println(b) // null
}

Boundaries are part of the rule

Sometimes beginners get “a little” confused: does the range 1..100 include 100 or not? In Kotlin, a range created with .. includes both ends. That is, 100 in 1..100 is true.

To avoid guessing, it helps to keep a small table:

Rule Example Result
x in 1..3
1 in 1..3
true
x in 1..3
3 in 1..3
true
x in 1..3
0 in 1..3
false
x in 1..3
4 in 1..3
false

If you need “strictly less” or “strictly greater”, that’s already a different kind of rule. But today we’re fixing the baseline: inclusive boundaries.

A validator for Double in a range

You can validate Double in the same way. There’s a nuance: Double is a floating-point type, but the check in min..max works in the same intuitive way (we compare numbers). In our course we already discussed that Double can be “finicky” with ==, but comparisons like <, >, and in for ranges are normal practice for user-facing constraints.

fun validateDoubleInRangeOrNull(x: Double, min: Double, max: Double): Double? {
    if (x < min) return null
    if (x > max) return null
    return x
}

Here I intentionally showed a version without in, so you can see that “under the hood” the idea is the same: two comparisons. Sometimes this variant reads even clearer when working with Double.

4. Strings: the “not empty” check after trim()

The “string must not be empty” check seems simple… until you see input like " " (four spaces). Formally it’s not an empty string, but semantically the user “entered nothing”. So in real programs the “not empty” rule almost always means: first trim(), then check for emptiness.

And yes, a simple thought often saves you here: the parser answers “string → value”, and the validator answers “does the value fit?”. A string is also a value, so it can be validated too.

A “not empty” validator for strings

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

Let’s test it:

fun main() {
    println(validateNonEmptyOrNull("Alice"))   // Alice
    println(validateNonEmptyOrNull("   "))     // null
    println(validateNonEmptyOrNull(""))        // null
}

Why the validator returns the normalized string

Notice: we return s, not raw. That means in this example the validator not only checks the rule, but also returns a “normalized” string.

This is a good practice when the rule and normalization are logically connected. If our rule is “the string must be non-empty after trimming whitespace at the ends”, then it’s natural to return exactly the variant we checked. Then later in the program you don’t carry extra spaces around, and you don’t have to remember: “oh, I should have done trim() again”.

5. Composition: readln() → parsing → validation

The most pleasant part begins when you combine everything into a chain. We’ll make a small tutorial scenario: a “mini form” that asks for a name and age, then prints a greeting. The main goal isn’t the form — it’s for you to see that validation is a separate layer, and you can plug it in like a LEGO brick.

The parser and validator should not argue about roles

First — a clean parser (like we did in the previous lecture):

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

Now the validator:

fun validateAgeOrNull(age: Int): Int? {
    return validateIntInRangeOrNull(age, 0, 150)
}

Note: validateAgeOrNull is just a “meaningful alias”. It makes the scenario code more readable: when you see validateAgeOrNull, you don’t think “what is 0..150?”, you think “okay, that’s age”.

Connect the steps: readLine → parse → validate

We won’t build a “retry input” loop yet (that’s a separate topic and a separate habit), so we’ll use a simple strategy: if something fails, print a message and end the program.

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

fun main() {
    val name = validateNonEmptyOrNull(readLine("Name: "))
        ?: return println("Name must not be empty.")

    val age = parseIntOrNull(readLine("Age: "))
        ?.let(::validateAgeOrNull)
        ?: return println("Age must be a number from 0 to 150.")

    println("Hello, $name! Your age: $age") // Hello, Alice! Your age: 20
}

There’s a small bit of “magic” here: ?.let(::validateAgeOrNull). If you don’t like let yet, you can write it more straightforwardly, without new constructs:

fun main() {
    val name = validateNonEmptyOrNull(readLine("Name: "))
        ?: return println("Name must not be empty.")

    val ageRaw = readLine("Age: ")
    val ageParsed = parseIntOrNull(ageRaw) ?: return println("Age: enter an integer.")
    val age = validateAgeOrNull(ageParsed) ?: return println("Age: valid range is 0..150.")

    println("Hello, $name! Your age: $age")
}

Yes, there are a few more lines, but everything is as straightforward as possible: parse first, then validate. For a beginner, this is often the best option.

6. How not to turn validators into a “combine harvester”

At this stage, a temptation usually appears: “Let’s make the validator print the error, and ask again, and basically do everything for me.” I get it. It’s like wanting a Swiss Army knife that also cooks borscht. But in programming, such “all-in-one” tools quickly become a problem.

The rule that saves your sanity: the validator should be small, predictable, and reusable. It takes a value, checks a rule, returns either the value or null. It shouldn’t know where the value came from (console, tests, file) or what happens next.

Naming discipline helps too. If a function can return null as “did not pass”, that should be visible from its name: the OrNull suffix is a great contract with your future self. And future you is still you — just tired, at 2:30 a.m., right before a deadline.

One more thing: try to return a “normalized” version of the value if normalization is required by the rule. With strings this is especially noticeable: if you validate the trim() version, it’s logical to return it, so you don’t carry spaces forward.

A mini library of validators for today

To cement the approach, let’s assemble a “set of building blocks” that we’ve effectively already written. This isn’t the final architecture yet (that will come in the next lectures), but it’s a good intermediate form: validators separately, parsers separately.

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    if (x !in min..max) return null
    return x
}

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

fun validateDoubleInRangeOrNull(x: Double, min: Double, max: Double): Double? {
    if (x < min) return null
    if (x > max) return null
    return x
}

Notice that the check x in min..max is a very “Kotlin-ish” way to express “the value is within the range”.

7. Common mistakes when separating validation into its own layer

Error #1: mixing parsing and validation into one “one-and-a-half page” function.
It’s very easy to start with the idea “I’ll make one function readAge() and it will handle everything”, and after a couple of edits end up with 40 lines where trim(), toIntOrNull(), ranges, error messages, and special cases are all mixed together. Such code is hard to reuse and almost impossible to extend cleanly. Splitting into a “parser” and a “validator” looks like unnecessary hassle right up until you add a second input field and realize you’re copying the same thing.

Error #2: the validator prints messages to the console.
When a validator starts doing println("Error!"), it loses universality. Today you want one wording of the error, tomorrow a different one, the day after tomorrow not even a console but tests where output isn’t needed. It’s much calmer when the validator simply returns null, and the scenario decides “what to tell the user”. Then you don’t get a “voice in the program’s head” that suddenly speaks in the wrong tone.

Error #3: “magic values” instead of null.
Sometimes instead of null people return -1 or an empty string, thinking it’s “simpler”. The problem is that -1 can be a real value (for example, in a different task), and an empty string can be valid input in some contexts (for example, an optional field). null honestly means “no value obtained / did not pass the check”, and Kotlin helps you not forget to handle it.

Error #4: the “not empty” check without trim().
If you only check raw.isEmpty(), then a string of spaces will pass as “not empty”. The user will be sure they “entered nothing”, and the program will think “everything is great, the name is set”. That’s why the “not empty” rule almost always should be understood as “not empty after trimming edge whitespace”, and you should return the trim() version.

Error #5: not agreeing on range boundaries and accidentally changing the meaning of the check.
Today you consider age 150 acceptable, tomorrow you don’t, and you start changing checks manually in different places. An extracted validator solves this problem: the rule lives in one place. And even more importantly, the code x in min..max reads unambiguously: an inclusive range, which reduces the chance of an “off-by-one” bug.

1
Task
Kotlin SELF, level 14, lesson 3
Locked
Range filter
Range filter
1
Task
Kotlin SELF, level 14, lesson 3
Locked
Name without extra spaces
Name without extra spaces
1
Task
Kotlin SELF, level 14, lesson 3
Locked
Discount by the rules
Discount by the rules
1
Task
Kotlin SELF, level 14, lesson 3
Locked
Temperature sensor
Temperature sensor
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION