JavaRush/Courses/Kotlin SELF/try/catch around file operations

try/catch around file operations

Open

1. Why try/catch is needed even after pre-checks

When you first start doing pre-checks, you get this feeling: “All right, I checked exists() and canRead() — so I can read!” And here Kotlin (and the OS) gently remind you: “ha-ha, no.” Pre-checks only say that at the moment of the check things looked fine. But the real readText() operation is an actual attempt to read data, and it still may end with an exception.

Imagine a simple situation: you checked exists() == true, and a millisecond later another process/user deletes (or renames) the file. This isn’t sci-fi — it’s normal life on a computer.

import java.io.File

fun main() {
    val file = File("data/input.txt")

    if (file.exists()) {
        // Between exists() and readText() the file may disappear.
        val text = file.readText()
        println("OK: ${text.length}") // if you're lucky
    }
}

If you’re unlucky, you’ll get an exception already during reading. And that’s normal: try/catch is not a replacement for pre-checks, but a second barrier that must stand around real I/O.

2. Narrow try: only real I/O goes inside

The most common beginner mistake looks like this: “I’ll wrap everything in one big try so it definitely won’t crash.” That’s like putting body armor on a fridge: heavy, awkward, and the fridge doesn’t care anyway. A big try makes code murky: it’s unclear what exactly could break, and inside catch you often try to “somehow continue” without understanding what state the data is in.

Anti-example: a too-wide try.

import java.io.File
import java.io.IOException

fun main() {
    val file = File("data/amount.txt")

    try {
        val text = file.readText()
        val amount = text.trim().toInt() // this is NOT I/O anymore, this is data format
        println("amount=$amount")
    } catch (e: IOException) {
        println("Failed to read the file")
    }
}

The issue here is subtle: toInt() can throw NumberFormatException, and it is not an IOException. So the program will still crash, but now “in a weird way”: you “wrapped everything,” didn’t you? But you didn’t wrap everything — only the I/O part.

It’s better to split responsibilities: reading the file is I/O and is handled as IOException, while parsing the text is a “data/format error” and should live separately (even if today we’re focusing specifically on I/O).

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? =
    try {
        file.readText()
    } catch (e: IOException) {
        null
    }

fun main() {
    val file = File("data/amount.txt")

    val text = readTextOrNull(file) ?: run {
        println("File not read: ${file.path}")
        return
    }

    val amount = text.trim().toIntOrNull()
    println("amount=$amount") // amount=123 or amount=null
}

Notice how the code feels: reading is separated, try is narrow, and after that you can calmly decide what to do with the content.

A small responsibility “map” is useful even in console projects:

flowchart TD
    A[CLI / main] --> B["loadRawText()"]
    B -->|I/O: readText| C[(File)]
    B -->|String? / error| A
    A --> D[parse / validate]
    D -->|format error| A

3. try as an expression: return a value instead of “falling apart”

Kotlin can do something that makes error handling noticeably nicer: try/catch is an expression, meaning it can return a value. This lets you write functions with a clear contract: “if it worked, I return the result; if not, I return a fallback.” In Kotlin this style is considered idiomatic: try returns one value, catch returns another.

The simplest variant for reading a file: “text or null”.

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    null
}

fun main() {
    val file = File("data/input.txt")

    val text = readTextOrNull(file)
    println(text?.take(20) ?: "NO DATA") // e.g.: NO DATA
}

Similarly, for writing you can choose the “success/failure” contract.

import java.io.File
import java.io.IOException

fun writeTextSafely(file: File, text: String): Boolean = try {
    file.writeText(text)
    true
} catch (e: IOException) {
    false
}

fun main() {
    val ok = writeTextSafely(File("data/out/report.txt"), "Hello!\n")
    println("written=$ok") // written=true or written=false
}

This idea seems simple, but it disciplines the program: instead of hoping “well it won’t crash,” you design in advance: what to return when there are problems.

4. Function contract for I/O: T?, Boolean, or rethrowing the exception

When you add try/catch, you always make a decision: what will the calling code see? For educational console apps, three contracts are usually enough: return T?, return Boolean, or don’t catch the exception here and let it propagate upward (sometimes that’s also a valid strategy).

It’s helpful to keep this comparison in mind as a table:

Function contract What it looks like When it’s convenient Cost of error
T?
String?, List<String>?
When “no result” is a normal branch of logic You must remember to handle
null
Boolean
true/false
When the data isn’t needed and only “did it work?” matters Weaker diagnostics: why
false
?
Rethrow the exception without try/catch (or with throw further) When the program makes no sense without the file The program may crash if nothing handles it above

Important: we don’t pick “the one correct option forever”. We pick what’s more convenient for your app and at this point in the code.

For example, in our educational console app let it be a simple “expense piggy bank”: we store expenses in a text file and show a short report. If the file can’t be read, we can show a clear message and start with an empty list — a great candidate for the List<String>? contract.

import java.io.File
import java.io.IOException

fun readLinesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun main() {
    val file = File("data/expenses.txt")
    val lines = readLinesOrNull(file) ?: emptyList()

    println("loaded=${lines.size}") // loaded=0 if the file doesn't exist/can't be read
}

But writing a report can be a Boolean: if we didn’t write it, we just inform the user.

5. Error messages: for the user and for you

An error message isn’t a work of art and not “something went wrong.” A good message answers at least three questions: what you tried to do, with what exactly (path), and what type of problem it is. Even without a logging system, this helps a lot.

It’s useful to create a tiny message formatter for I/O. It doesn’t need to be smart — it needs to be stable and consistent across the app.

import java.io.File
import java.io.IOException

fun ioErrorMessage(action: String, file: File, e: IOException): String {
    val type = e::class.simpleName ?: "IOException"
    val detail = e.message ?: "no details"
    return "Failed to $action: ${file.path}. Reason: $type: $detail"
}

And use it in catch:

import java.io.File
import java.io.IOException

fun readTextOrNullVerbose(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    println(ioErrorMessage("read file", file, e))
    null
}

fun main() {
    val text = readTextOrNullVerbose(File("data/input.txt"))
    println(text?.length ?: "no text") // no text
}

Yes, we print straight to the console — and that’s fine for an educational CLI. In more serious projects you’d separate “user-facing message” from “diagnostics for logs,” but today we intentionally won’t dive into logging.

Multiple catch blocks: order and the meaning of splitting

Sometimes you want to catch everything with a single catch (IOException). That’s fine, but it’s often useful to distinguish at least one special case: “file not found / can’t be opened,” i.e., FileNotFoundException. Then you can give a more human message: “check the path” instead of “oops, I/O.”

Rule in Kotlin (and on the JVM in general): catch more specific exceptions first, then more general ones, otherwise the specific one will never run.

import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

fun main() {
    val file = File("data/input.txt")

    try {
        val text = file.readText()
        println("OK: ${text.length}") // OK: ...
    } catch (e: FileNotFoundException) {
        println("File not found or cannot be opened: ${file.path}")
    } catch (e: IOException) {
        println("I/O error while reading: ${file.path}")
        println("Details: ${e.message}")
    }
}

The key is not to overcomplicate it: we split out only the most common case, and left everything else under IOException.

6. Where to put try/catch: the responsibility boundary

It’s very tempting to catch exceptions in main, because “everything is visible there.” But as the app grows, this turns into a huge combine harvester: main reads input, parses commands, works with data, and also catches every possible problem. A good style is to catch I/O where it happens, and return a clear result to the outside.

For our expense tracker this means: storage functions themselves wrap readText()/writeText() in try/catch, and main receives either data or a “didn’t work” signal.

The diagram is simple:

flowchart TD
    M[main: user commands] --> S[storage: read/write file]
    S -->|List⟨String⟩? / Boolean| M
    S -->|IOException inside| S

Example of a mini storage layer (no “magic,” everything short and explicit):

import java.io.File
import java.io.IOException

fun loadExpensesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun saveExpenses(file: File, lines: List<String>): Boolean = try {
    file.writeText(lines.joinToString(separator = "\n"))
    true
} catch (e: IOException) {
    false
}

And usage in main without feeling like you’re running a nuclear power plant:

import java.io.File

fun main() {
    val file = File("data/expenses.txt")

    val lines = loadExpensesOrNull(file)
    if (lines == null) {
        println("Failed to load expenses, starting with an empty list.")
    } else {
        println("Loaded entries: ${lines.size}") // e.g.: Loaded entries: 12
    }
}

Psychologically this is important: main doesn’t have to know how exactly the OS refused to read. main must make a decision: continue with empty data, ask the user to fix the path, exit — and so on.

7. finally and use {}: when they’re needed

The word finally often sounds like a “secret Jedi technique.” In reality, it’s needed for exactly one thing: guarantee that some piece of code runs at the end, even if an error happens inside try. In file-related tasks this is especially important for closing resources when you open streams/readers manually.

But Kotlin offers an even more convenient style: use {}, which automatically closes the resource (roughly like finally, but cleaner). This is considered the idiomatic approach for AutoCloseable.

We won’t dive deep into streams today, but a small example is useful to see the boundary: readText() doesn’t require use, while bufferedReader() does.

import java.io.File
import java.io.IOException

fun readFirstLineOrNull(file: File): String? = try {
    file.bufferedReader().use { reader ->
        reader.readLine()
    }
} catch (e: IOException) {
    null
}

fun main() {
    val first = readFirstLineOrNull(File("data/input.txt"))
    println(first ?: "<empty>") // e.g.: <empty>
}

Remember a simple rule: if you see ...Reader(), ...Writer(), InputStream, OutputStream — almost always use {} should be nearby (or at least finally). If you use “ready-made” functions like readText()/writeText(), they open and close resources internally, and you don’t need to write finally manually.

A small Kotlin 2.x nuance: the compiler got better at tracking smart-cast information through catch/finally, so in some scenarios it more carefully points out where a variable becomes nullable again. This relates to exception-handling improvements in K2.

8. Typical mistakes with try/catch around file operations

Error #1: a huge try that contains file reading, data parsing, and half the business logic.
This kind of code is unpleasant because you lose clarity about the cause of failure: is it I/O, format, or your logic? Also in catch there’s a temptation to “finish something up,” even though the data might not have loaded. The fix is simple: move I/O into small functions and keep try narrow — around readText()/writeText() and nearby operations.

Error #2: silently “swallowing” the exception.
Returning null or false is fine, but if you say absolutely nothing, then when a real problem happens you’ll debug “why is it empty” instead of “why didn’t it read.” Even in an educational CLI it’s useful to print at least once: action, path, e.message. Otherwise diagnostics turn into guesswork.

Error #3: catching Exception “just in case.”
It looks like you care about stability, but more often it’s a sweeping-under-the-rug style: you start mixing I/O errors, format errors, and your own bugs. As a result the program “kind of works,” but does strange things. For files, keep the focus on IOException (and sometimes FileNotFoundException as a special case), and don’t hide other errors without a good reason.

Error #4: returning Boolean but providing no information about “why false.”
Boolean is convenient when you only care about the fact of success. But if you sometimes need to understand the reason, you can (for today) at least print a message inside catch, or return null/String? with the error text. The key is not to create an API where false means ten different reasons, and you can’t find out which one.

Error #5: incorrect catch order.
If you write catch (e: IOException) first, and then catch (e: FileNotFoundException), the second block becomes unreachable because FileNotFoundException is a special case of IOException. The order must be “specific first, general second,” as in classic exception-handling rules.

Error #6: continuing to use data after an unsuccessful read.
This is a subtle bug: you catch an exception, print “failed,” but the variable remains, for example, an empty string, and then you parse it as if it were the file’s contents. You end up with a secondary error (for example, NumberFormatException) and get confused about what actually broke. If reading failed, return null/false and stop that execution path explicitly.

Error #7: treating canRead()/canWrite() as a guarantee.
These checks are useful, but they don’t promise success: permissions can change, the file can be locked, the filesystem can “drop,” etc. So the correct combination is: pre-checks improve messages and early failure, while try/catch protects the actual operation.

1
Task
Kotlin SELF,  45 level,  2 lecture
Locked
Note preview
Note preview
1
Task
Kotlin SELF,  45 level,  2 lecture
Locked
Reliable write
Reliable write
1
Task
Kotlin SELF,  45 level,  2 lecture
Locked
Log message
Log message
1
Task
Kotlin SELF,  45 level,  2 lecture
Locked
Amount from a file
Amount from a file
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet