1. Why pre-checks are needed
When you write file.readText() or file.writeText(...), you are kind of telling the OS: “Give me the data” or “Write this”. And the OS can answer: “OK”… or “No, because…”.
Pre-checks are not an attempt to “cancel the existence of errors”. They are an attempt to notice obvious problems earlier and tell a person (or yourself) a normal reason, rather than “something went wrong”. This is especially useful in CLI apps: users like specifics, not philosophy.
It is important to accept an unpleasant but useful truth right away: pre-checks do not guarantee that the operation will succeed. Even if you checked exists() and canRead(), the file can disappear a millisecond later. So pre-checks are the first line of defense, while the real operation must still be ready for an I/O failure (exception handling will be covered separately; today we keep the focus specifically on checks).
Let’s draw a short mental model:
flowchart TD
A[Want to read/write a file] --> B[Run pre-checks]
B -->|checks failed| C[Do not start I/O, explain the problem]
B -->|checks passed| D[Try the actual I/O]
D -->|success| E[Work with the data]
D -->|failure| F[This is already exception-handling territory]
2. File as a path and questions to the file system
Before checking reading and writing, it is important to get used to the idea: File("data/input.txt") is not a “file in your hands”, but a path object with which you ask the file system questions: does something exist at this path, is it a folder or a file, can it be read/written, and so on.
In Kotlin/JVM you typically use java.io.File. It has basic methods that we will combine:
- exists() — does something exist at the path
- isFile — is it a file?
- isDirectory — is it a directory?
- canRead(), canWrite() — do we have permissions to read/write (an important “seems like”, not “I guarantee”)
- parentFile — the parent directory (may be null)
- mkdirs() — create the directory and all parents
Let’s start with a small “diagnostic habit”: before arguing with the computer, it’s useful to ask it what it sees at this path.
import java.io.File
fun main() {
val file = File("data/input.txt")
println("path=${file.path}") // path=data/input.txt
println("exists=${file.exists()}") // e.g.: exists=false
println("isFile=${file.isFile}") // e.g.: isFile=false
println("isDirectory=${file.isDirectory}") // e.g.: isDirectory=false
}
Yes, this looks like “extra lines”. But when you eventually get a “why won’t it read?!”, these four lines save half an hour of life and one micro-flash of hatred toward humanity.
What to check before reading
When we read a file, we need to ensure three things: something exists at the path, it is actually a file (not a folder), and we have the right to read it.
The most practical minimum for reading usually looks like this:
import java.io.File
fun canReadFile(file: File): Boolean {
return file.exists() && file.isFile && file.canRead()
}
fun main() {
val ok = canReadFile(File("data/input.txt"))
println("canReadFile=$ok") // e.g.: canReadFile=false
}
Pay attention to the order: exists() → isFile → canRead(). It is not only logical, but also psychologically pleasant: we move from “does anything exist at all” to “is it the right type”, and only then to permissions.
Why exists() alone is not enough
A very common beginner mistake is to do this:
if (file.exists()) {
val text = file.readText()
}
The problem is that exists() says: “yes, something exists”. But that “something” may be a directory. And reading a directory as text is a game of “guess what error the OS will return today”.
That’s why isFile is not nitpicking; it is protection against a logical error: “I thought it was a file”.
3. canRead() and canWrite(): useful, but not a guarantee
Access permissions are an area where it’s easy to fall into the illusion of control. The methods canRead() and canWrite() do provide a useful signal: “most likely, it can be read/written”. But they are not a contract with reality.
Why? Because between “checked” and “did” everything can change: the file may have been locked, permissions may have changed, the file may have disappeared, you may be working in an environment with a network file system where checks and actual access behave more subtly.
From a development standpoint, this means a simple rule of behavior: canRead()/canWrite() help you avoid starting an obviously doomed operation, but they do not replace handling a real input/output failure. Errors of this kind usually “live” under the umbrella of IOException.
Let’s touch these flags with our hands:
import java.io.File
fun main() {
val file = File("data/input.txt")
println("exists=${file.exists()}") // e.g.: true
println("canRead=${file.canRead()}") // e.g.: true
println("canWrite=${file.canWrite()}") // e.g.: false
}
If canWrite() is suddenly false, it’s not “Kotlin broke”. It’s a hint: maybe you’re trying to write somewhere you don’t have permissions (for example, a system directory), or the file is marked read-only.
4. Pre-checks before writing: the parent directory
Writing differs from reading in that the file may not exist yet—and that’s normal. So the set of checks is different here.
Key idea: to write the file "data/out/report.txt", you first of all need the directory "data/out". And its state is what most often breaks writing.
So before writeText() we care about:
- does the parent folder exist
- is it a directory (not a file)
- can we write into it
- if the folder does not exist—can we create it
parentFile and why it can be null
If the path is relative and has no folders, for example File("report.txt"), then parentFile will be null. This is not an error; it just means “no parent specified”.
In that case, it’s convenient to treat the parent as the current directory ".".
import java.io.File
fun parentOrCurrent(target: File): File {
return target.parentFile ?: File(".")
}
fun main() {
val a = File("data/out/report.txt")
val b = File("report.txt")
println(parentOrCurrent(a).path) // data/out
println(parentOrCurrent(b).path) // .
}
mkdirs(): preparing a directory for writing
Now the most practical part: a function that guarantees (as much as possible) that the directory exists and is a directory. If it doesn’t exist, it tries to create it.
Important: mkdirs() returns Boolean. This is not “just for show”. It is the only direct signal “worked / didn’t work”.
import java.io.File
fun ensureDir(dir: File): Boolean {
if (dir.exists()) return dir.isDirectory
return dir.mkdirs()
}
fun main() {
val dir = File("data/out/reports")
val ok = ensureDir(dir)
println("dirReady=$ok") // e.g.: dirReady=true
println("exists=${dir.exists()}") // exists=true
println("isDir=${dir.isDirectory}") // isDir=true
}
Here we deliberately do not use “all-at-once” checklists; instead, we read the code as a story: “if it exists—make sure it’s a folder; if it doesn’t—create it”.
mkdir() vs mkdirs() in plain terms
mkdir() creates one directory. If you need to create "data/out/reports" but "data/out" doesn’t exist, mkdir() typically won’t handle it.
mkdirs() tries to create the whole chain.
For a beginner, the rule is simple: for nested paths, you almost always want mkdirs().
Write readiness via parentFile
Let’s assemble this into another small utility: prepare the parent directory for a file.
import java.io.File
fun ensureParentDir(target: File): Boolean {
val parent = target.parentFile ?: File(".")
return ensureDir(parent)
}
fun main() {
val out = File("data/out/report.txt")
println("parentReady=${ensureParentDir(out)}") // e.g.: parentReady=true
}
This function feels boring (and boring is often reliable). But it is exactly what most often turns the scenario “it won’t write because the folder doesn’t exist” into “the folder was created, everything is OK”.
Cheat sheet: what to check before reading and writing
Sometimes it’s useful to lock the logic in a table so you don’t have to keep it all in your head. Consider this a “cheat sheet” for emergencies.
| Action | What we want to guarantee (as much as we can) | Typical checks |
|---|---|---|
| Reading | There is a file at the path, and we can read it | |
| Writing | There is a directory where we can write the file | |
Note: for writing we more often check the directory, not “the file itself”. The file may appear only at the moment of writing—and that is a normal scenario.
5. Example: a CLI app with pre-checks
Imagine we have a simple console app that stores data in a text file "data/storage.txt" and sometimes writes a report to "data/out/report.txt". It doesn’t matter what exactly is inside—expenses, notes, or a list of “grudges against the compiler”. What matters is: how to carefully check paths before an operation.
Loading data
import java.io.File
fun loadStorageText(storageFile: File): String? {
if (!canReadFile(storageFile)) return null
return storageFile.readText()
}
fun main() {
val file = File("data/storage.txt")
val text = loadStorageText(file)
println(text?.length ?: "NO DATA") // e.g.: NO DATA
}
There is no exception handling here—and that is intentional. Today we focus on pre-checks as a filter. Handling real I/O failures will be the next lecture of the day. But even in this form you already got “smarter” behavior: if the file doesn’t exist, we don’t try to read it and don’t provoke a crash.
Saving a report
import java.io.File
fun saveReportText(reportFile: File, report: String): Boolean {
if (!ensureParentDir(reportFile)) return false
reportFile.writeText(report)
return true
}
fun main() {
val ok = saveReportText(
File("data/out/report.txt"),
"Report: all good\n"
)
println("saved=$ok") // e.g.: saved=true
}
If the folder "data/out" did not exist, it will be created. If it cannot be created, it will return false, and you can end the scenario correctly (or ask the user to choose another path).
6. Quick path diagnostics
When I/O problems start, you usually want to quickly understand: what exactly is at this path? a file? a folder? does it exist? is it readable?
Let’s make a small “diagnostic text” that you can print to the console (and that you will thank yourself for later).
import java.io.File
fun describe(file: File): String {
return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}, canRead=${file.canRead()}, canWrite=${file.canWrite()}"
}
fun main() {
val file = File("data/input.txt")
println(describe(file))
// e.g.: path=data/input.txt, exists=false, isFile=false, isDir=false, canRead=false, canWrite=false
}
This is not “logging” (we are still far from that), just human diagnostics in the style of “what does the file system see”.
7. Common mistakes in pre-checks
Error #1: checking only exists() and assuming that is enough.
exists() does not say it is a file, and it does not say it can be read. It is very easy to end up with “exists=true” but “isDirectory=true”, and then reading breaks. Get used to the formula exists() + isFile + canRead() for reading.
Error #2: when writing, checking canWrite() on a file that does not exist yet.
It sounds logical (“I’m going to write to the file”), but in practice it is often meaningless: if the file doesn’t exist, there is nothing to evaluate as “writable”. What really matters is whether you can write to the directory where the file should appear. That’s why the check starts with parentFile.
Error #3: calling mkdirs() and ignoring the result.
mkdirs() can return false, and that is not rare: no permissions, a name conflict (a file already exists at the path), a weird path, disk problems. If you ignore the result, the later “crash” will look more mysterious and farther from the real cause.
Error #4: treating canRead()/canWrite() as a guarantee of success.
These methods are useful as an early filter, but the environment can change between the check and the operation, and real errors can be more complex (for example, a temporary lock or file system issues). Keep in mind: pre-checks reduce the number of errors and improve messages, but they do not eliminate the real risk of I/O failures, which belong to the IOException family.