JavaRush /Courses /Kotlin SELF /Preconditions and state validation: require and check

Preconditions and state validation: require and check

Kotlin SELF
Level 31, Lesson 4
Available

1. Fail-fast and the difference between require and check

Why “fail earlier” is needed at all: the fail-fast idea

If you are just getting started, it may seem that a good program should be polite and patient: “well, the user entered nonsense — let’s quietly substitute 0, an empty string, and somehow continue.” In practice, this “kindness” often turns into error accumulation: the program keeps running, but the data becomes garbage, and eventually everything breaks… only much later and in a much less understandable way.

The fail-fast approach (literally “fail quickly”) says: if the logic makes no sense beyond this point, it’s better to stop immediately and explain what exactly was wrong. Kotlin directly supports this style with standard precondition functions: they automatically throw exceptions with a clear type.

To make the picture complete, here is a small diagram (this is about “meaning,” not syntax):

flowchart TD
    A[Got data] --> B{Is the data usable?}
    B -- No --> C[Fail-fast: require/check -> exception]
    B -- Yes --> D[Continue logic]
    D --> E{Is the state correct?}
    E -- No --> F[Fail-fast: check -> exception]
    E -- Yes --> G[Predictable result]

The biggest fail-fast bonus in a learning project: you stop guessing “why it computes weirdly” and start getting answers like “Age must be >= 0, got -5”. It’s surprising how much this reduces the urge to throw your laptop out the window (I checked; the window is not at fault).

require vs check: looks similar, but the meaning is different

From the outside, require(condition) and check(condition) look almost identical: you pass a condition and (usually) a message. But their meaning is different — and that matters, because the meaning turns into an exception type, and the exception type is a hint for you and for whoever will read the code after you.

require() is used to validate input arguments, while check() is used to validate the state of an object or variable. If require() fails, it throws IllegalArgumentException, and if check() fails, it throws IllegalStateException.

Let’s summarize in a table (it’s really worth keeping in mind):

The question you ask What to use Typical thought Exception
“I was given bad data; I can’t continue”
require(...)
“The argument is invalid”
IllegalArgumentException
“The data is fine, but the object is currently in an impossible/forbidden state”
check(...)
“The state is invalid”
IllegalStateException

A very rough but memorable analogy: require is the face control at the club entrance (“no sneakers”), and check is the security inside (“no toasters in the pool”). And if it seems to you that “both are just ‘no’,” you’re right. But the reasons for “no” are different.

2. require: protecting the input

When you create an object, you want it to appear correct immediately. This is a key OOP idea at a beginner level: if an object exists, you can trust it. In Kotlin, a typical way to do this is to place checks in init { ... } or directly in the constructor logic.

Minimal require example in init

Let’s start with something as simple as possible so your hand gets used to it:

class Age(val value: Int) {
    init {
        require(value >= 0) { "Age must be >= 0, got $value" }
    }
}

fun main() {
    println(Age(10).value) // 10
}

Here we fixed the rule: age cannot be negative. The important point is that we are not trying to “repair” it — we forbid creating an invalid object. And that’s normal: if you get -10, that is not “almost an age,” it’s an input error.

Example from the learning project: an expense tracker

Let’s continue with a practical example: a console expense tracker. Previously, we could store an expense as a set of fields, but now we want the Expense object to be unable to exist in a garbage form.

Let’s make a small class:

class Expense(
    val amountCents: Int,
    val category: String,
    val note: String
) {
    init {
        require(amountCents > 0) { "amountCents must be > 0, got $amountCents" }
        require(category.isNotBlank()) { "category must not be blank" }
        require(note.length <= 100) { "note must be <= 100 chars, got ${note.length}" }
    }
}

Pay attention to the style: the message should explain what you expected and what you got. This is not aesthetics — it’s debugging speed.

Now creation:

fun main() {
    val e = Expense(amountCents = 1999, category = "Food", note = "Pizza")
    println("${e.category}: ${e.amountCents} cents") // Food: 1999 cents
}

If someone tries to create Expense(amountCents = 0, ...), the object won’t be created — and that’s correct: a zero expense most often means we failed to parse a number somewhere or mixed up the currency.

require also works for regular methods

Preconditions are needed not only when creating an object. If you have a method that receives arguments from the outside, you can also protect the input with require: this is exactly about arguments without which the function cannot work.

Imagine we have a utility method for formatting an amount:

fun formatCents(amountCents: Int): String {
    require(amountCents >= 0) { "amountCents must be >= 0, got $amountCents" }
    val dollars = amountCents / 100
    val cents = amountCents % 100
    return "$dollars.${cents.toString().padStart(2, '0')}"
}

fun main() {
    println(formatCents(1999)) // 19.99
}

Yes, we could “silently” turn -5 into 0. But then you would lose the signal that you have an error somewhere.

3. check: protecting the object state

Now let’s move on to check. It is needed when the input data is fine, but the object itself should not perform the operation right now. This often occurs in objects with a “mode of operation”: a session may be closed, a file may already be saved and “sealed,” a report may already be finalized.

check() is about verifying the correctness of an object’s or variable’s state, and on failure it throws IllegalStateException.

Example: a “closed” expense tracker

Let’s add a class to our project that stores a list of expenses. We haven’t discussed collections of objects deeply yet, so we’ll keep it as simple as possible: a list plus a closed flag.

class ExpenseTracker {
    private val expenses: MutableList<Expense> = mutableListOf()
    private var closed: Boolean = false

    fun close() {
        check(!closed) { "Tracker already closed" }
        closed = true
    }

    fun add(expense: Expense) {
        check(!closed) { "Can't add expense: tracker is closed" }
        expenses.add(expense)
    }

    fun totalCents(): Int {
        var sum = 0
        for (e in expenses) sum += e.amountCents
        return sum
    }
}

See what we did: the input to add(expense) is valid as such (the Expense object is already guaranteed to be valid). But you can’t add it if the tracker is closed. That is a state check.

How it looks in usage

fun main() {
    val tracker = ExpenseTracker()
    tracker.add(Expense(500, "Coffee", "Latte"))
    tracker.close()

    println(tracker.totalCents()) // 500

    // tracker.add(Expense(100, "Snack", "Bar"))
    // If uncommented: IllegalStateException from check(...)
}

Why this is good: the error tells you that the problem is not in the expense and not in the “input,” but in the object’s lifecycle. Such a bug is usually fixed not by “use a different number,” but by “don’t call the method in this state.”

4. Messages and exception handling

Messages in require/check: how to make an exception useful

When beginners write require(x > 0) without a message, they assume it’s “obvious.” But two weeks later (or two hours later, if the day was rough) you’ll see something like “Failed requirement.” in the console and try to remember which requirement and where exactly it failed.

Kotlin lets you provide the message as a string or as a lambda { ... }. The lambda is convenient because it is evaluated only if the check fails (that is, in the normal case it doesn’t waste resources).

A good message is almost always in the format: expected → got.

fun parseAmountCents(text: String): Int {
    val trimmed = text.trim()
    val value = trimmed.toIntOrNull()
    require(value != null) { "Amount must be an integer, got '$text'" }
    require(value > 0) { "Amount must be > 0, got $value" }
    return value
}

fun main() {
    println(parseAmountCents(" 150 ")) // 150
}

Here there are two different errors: not a number, and a number but a bad one. And the messages are different — that saves your nerves during debugging.

And for check the message should explain “why the state is forbidden”:

class Session {
    private var started: Boolean = false

    fun start() {
        check(!started) { "Session already started" }
        started = true
    }
}

This is an almost perfect minimal example: there are no arguments, and the operation is forbidden due to state.

How to neatly combine require/check with a console main

Fail-fast is good inside your model, but a console app does not have to crash with a “nice stack trace” from the first user mistake. So a common approach is: inside classes — require/check, and at the main level — careful catching and a clear message to the person.

This is not a contradiction. It’s separation of concerns: the model says “you can’t do that,” and the CLI decides “how to explain it to the user.”

Let’s assemble a small example: the user enters an amount, a category, and a note, and we add an expense.

fun main() {
    val tracker = ExpenseTracker()

    try {
        print("Amount (cents): ")
        val amount = parseAmountCents(readln())

        print("Category: ")
        val category = readln()

        print("Note: ")
        val note = readln()

        tracker.add(Expense(amount, category, note))
        println("Added!") // Added!
        println("Total = ${tracker.totalCents()} cents") // Total = ... cents
    } catch (e: IllegalArgumentException) {
        println("Input error: ${e.message}")
    } catch (e: IllegalStateException) {
        println("App state error: ${e.message}")
    }
}

Here you can see the benefit of different exceptions: we interpret IllegalArgumentException as an input problem, and IllegalStateException as an incorrect order-of-actions problem. This is exactly the semantic separation for which require and check exist at all.

5. Common mistakes when using require/check

Mistake #1: using check for user input.
This often happens out of habit: “well, I’m checking a condition — so check.” But check has a different meaning: it’s about the situation “we ourselves brought the object to an impossible state.” If the user entered -5, that is not “the object state is bad,” it’s “the argument is bad,” which means require. Kotlin separates these cases and even throws different exceptions: require throws IllegalArgumentException, while check throws IllegalStateException.

Mistake #2: writing useless messages (“bad input”, “error”, “oops”).
When you write a message like “wrong value,” you gain exactly zero seconds. The message must be diagnostic: what was expected and what arrived. The ideal format is: “X must be …, got …”. It’s especially useful to include the actual value and the string length when validating text.

Mistake #3: placing checks too late, after changing state.
For example, a method has already added an expense to the list, then checked that the tracker is closed, and crashed. As a result, it crashed but the data has already been partially modified. That’s why require/check checks are almost always placed at the beginning of the method — before any modifications. This makes the code predictable: either the operation is fully performed, or it is not started.

Mistake #4: trying to “fix everything” by normalization where strict validation is needed.
Sometimes you want to replace require(amount > 0) with “if it’s below zero, make it zero.” But that turns errors into silent bugs. Normalization is useful for things like trim() and case conversion, but for key numbers and identifiers it is often safer to fail fast: don’t create the object if it cannot exist by meaning.

Mistake #5: storing an invalid state in an object and hoping “we’ll fix it later.”
If your Expense can exist with an empty category, sooner or later you will forget to fix it, and it will live long enough to reach reporting, saving, or sorting. The rule “created means valid” greatly simplifies how you think about code. That’s why checks in init are not pedantry, but an investment in good sleep.

1
Task
Kotlin SELF, level 31, lesson 4
Locked
Age validation
Age validation
1
Task
Kotlin SELF, level 31, lesson 4
Locked
Cents on a receipt
Cents on a receipt
1
Task
Kotlin SELF, level 31, lesson 4
Locked
Bank Account
Bank Account
1
Task
Kotlin SELF, level 31, lesson 4
Locked
Report builder
Report builder
1
Survey/quiz
Constructors and <code>init</code>, 31 level, 4 lesson
Unavailable
Constructors and init
Constructors and init
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION