1. Why File and Path are needed if a path is a string
When you first see file operations, it’s tempting to think: “Well, you can store the path in a String, so then it’s just readText("data/input.txt") and off we go.” In practice, a string is too “dumb” of an entity: it’s easy to make mistakes in it, it’s inconvenient to diagnose, and it can’t answer questions like “is this a file or a folder?” That’s why in Kotlin/JVM we usually use File and Path objects, which wrap a path and provide useful operations. Kotlin works great with Java libraries (that’s part of the “interoperable with Java” idea), so java.io.File is a normal, familiar tool.
An important psychological point right away: File("data/input.txt") does not create a file. It’s just an object that stores the path and can perform checks/operations for that path.
Mini demo of a “path object”:
import java.io.File
fun main() {
val f = File("data/input.txt")
println("path = ${f.path}") // path = data/input.txt
println("absolutePath = ${f.absolutePath}") // absolutePath = .../data/input.txt
}
Notice: we’re not reading or writing anything yet. We’re just doing something that really helps during debugging: checking exactly where the program is “aiming.”
A small diagram of how a path usually looks in your head vs. in reality:
flowchart TD
A["You write: File('data/input.txt')"] --> B["OS/Java treats the path as relative"]
B --> C["A relative path is resolved from the working directory"]
C --> D["You get an absolute path"]
D --> E["Then you run checks exists/isFile/isDirectory on it"]
2. File: three questions before I/O
When you start working with files for real, the main problem is not “how to read text,” but that you read the wrong thing, from the wrong place, or try to read a folder as a file. So the starting discipline is: before any read/write, ask the File object three questions.
The first question is exists(): “is there anything at this path at all?” The second is isFile: “is it a regular file?” The third is isDirectory: “is it a directory?” These checks don’t make code “long just for length”; they make it predictable: instead of a mysterious crash “somewhere inside reading,” you get a clear diagnostic.
Here’s a small example you can run at any time to understand what actually exists at a path:
import java.io.File
fun main() {
val f = File("data")
println("path=${f.path}") // path=data
println("exists=${f.exists()}") // for example: exists=false
println("isFile=${f.isFile}") // isFile=false
println("isDirectory=${f.isDirectory}") // isDirectory=false
}
It’s useful to understand that these situations are possible:
|
|
|
What it means in practice |
|---|---|---|---|
|
|
|
There is nothing at the path (neither file nor directory) |
|
|
|
It’s a file (you can read/write it as a file) |
|
|
|
It’s a directory (you can’t read it as a file) |
The situation “exists() == true, but isFile == false, and isDirectory == false” is rare (usually it’s something filesystem-specific), but for our level it’s enough to remember: if you want to read text, expect isFile == true.
3. Preparing directories: parentFile, mkdir(), and mkdirs()
Almost every beginner steps on this rake at least once: “I want to write the file data/reports/report.txt—why is the program complaining?” The reason is simple: you want to create the file, but the folder data/reports doesn’t exist yet. Writing a file by itself is usually not required to create the whole directory chain—that’s your responsibility.
To do it neatly, we learn two ideas. First, a file has a “parent folder”—parentFile. Second, folders can be created with mkdir() or mkdirs(). The difference is simple: mkdir() tries to create exactly one directory, while mkdirs() creates a chain if needed (and this is more often what we want in applications).
Mini example: “prepare the parent folder”:
import java.io.File
fun main() {
val out = File("data/reports/report.txt")
val parent = out.parentFile
val ok = parent != null && (parent.exists() || parent.mkdirs())
println("parentPath=${parent?.path}, ready=$ok") // parentPath=data/reports, ready=true/false
}
What matters here is that parentFile can be null. This happens when you create a file “without a folder,” for example File("report.txt"). Then there’s no separate “parent” object, and that’s fine.
A slightly more “practical” version is a small utility (later we’ll gather things like this into a neat set of functions):
import java.io.File
fun ensureParentDirExists(file: File): Boolean {
val parent = file.parentFile ?: return true
return parent.exists() || parent.mkdirs()
}
fun main() {
val out = File("data/reports/report.txt")
println(ensureParentDirExists(out)) // true/false
}
A diagram of what this function does (so your brain doesn’t have to recompile reality from scratch each time):
flowchart TD
A["There is a File for a future write"] --> B["Get parentFile"]
B --> C{parentFile == null?}
C -->|Yes| D["OK: we will write next to it; no folder needed"]
C -->|No| E{"parent.exists()?"}
E -->|Yes| F["OK: the folder already exists"]
E -->|No| G["mkdirs()"]
G --> H["OK/not OK: depends on the result of mkdirs()"]
4. Building a path from parts without concatenating slashes
String concatenation of paths looks like the fastest path to happiness: "data" + "/" + "input.txt". But in reality it’s “the fastest path to tears”: somewhere you forgot a /, somewhere you added two, somewhere you’re on Windows, and you start thinking about \ (and then also about escaping "\\\\", and the evening stops being calm).
In Java/Kotlin there’s a simple, human-friendly way out: build a path from parts using ready-made APIs. It’s readable and reduces the risk of “crooked slashes.”
Building via File(parent, child)
A bad example (not forbidden, but suspicious like street shawarma at 4 a.m.):
fun main() {
val path = "data" + "/" + "reports" + "/" + "report.txt"
println(path) // data/reports/report.txt
}
A good example (easier to read, and less dependent on manual slashes):
import java.io.File
fun main() {
val base = File("data")
val reportsDir = File(base, "reports")
val reportFile = File(reportsDir, "report.txt")
println(reportFile.path) // data/reports/report.txt
}
What’s especially nice here is that the variables start to “speak”: reportsDir is clearly a directory, and reportFile is clearly a file. It’s not magic, but it really helps your brain avoid confusion.
A small table to lock in “the usual way”:
| Approach | Pros | Cons |
|---|---|---|
| String concatenation | Quick to write | Easy to make mistakes with slashes, harder to read |
|
Readable, fewer mistakes | Need to create several objects (but that’s usually OK) |
Building via Path.of(...)
File is a classic tool: it’s popular, but very old—it comes from java.io. A more modern approach in the JVM world is java.nio.file.Path. At our level, one simple idea matters: Path is also a path object that’s convenient to assemble from parts, and it works well with modern APIs.
Why can Path feel nicer? It’s designed specifically for “path as a value”: it’s convenient to compose, pass around, compare, transform. And if you still need operations “like File,” you can always convert a Path to a File.
Mini example of building a path via Path.of(...):
import java.nio.file.Path
fun main() {
val p = Path.of("data", "reports", "report.txt")
println(p.toString()) // data/reports/report.txt
}
And here’s an example of “bridging the worlds”—sometimes you want to build a path via Path, and then use File methods (or vice versa):
import java.io.File
import java.nio.file.Path
fun main() {
val p: Path = Path.of("data", "input.txt")
val f: File = p.toFile()
println(f.path) // data/input.txt
println(f.absolutePath) // .../data/input.txt
}
The reverse conversion exists too:
import java.io.File
fun main() {
val f = File("data/input.txt")
val p = f.toPath()
println(p.toString()) // data/input.txt
}
Notice that we’re not discussing “read/write via NIO” and other joys here—today our goal is simpler: learn to confidently create paths, check what exists at them, and prepare directories for future operations.
5. Mini refactor: centralize storage paths
When an application is small, you can just write File("data/input.txt") right in main() and be happy. But as soon as you have two or three files (for example, “input data,” “log,” “report”), chaos begins: paths are scattered across the code, you change the folder data to app-data and miss one place, and then spend half an hour figuring out why “sometimes it works, sometimes it doesn’t.”
That’s why a useful habit is to centralize path construction. We’re not reading or writing yet, but even now we can make a “file map” for our console app (let’s call it an expense tracker that used to live in memory, and is now getting ready to persist to files).
Let’s start with a simple data class that stores our key paths:
import java.io.File
data class StoragePaths(
val baseDir: File,
val expensesFile: File,
val reportFile: File
)
Now a function that builds these paths from parts (without string concatenation):
import java.io.File
fun buildStoragePaths(): StoragePaths {
val baseDir = File("data")
val expensesFile = File(baseDir, "expenses.txt")
val reportFile = File(File(baseDir, "reports"), "last-report.txt")
return StoragePaths(baseDir, expensesFile, reportFile)
}
And a bit of diagnostics (especially useful when students run the project “sometimes from the IDE, sometimes from the terminal”):
import java.io.File
fun main() {
val paths = buildStoragePaths()
println("workDir = ${File(".").absolutePath}") // workDir = ...
println("expenses = ${paths.expensesFile.absolutePath}") // expenses = .../data/expenses.txt
println("report = ${paths.reportFile.absolutePath}") // report = .../data/reports/last-report.txt
}
Now a very important step: since we have a report file in the subfolder data/reports, we should be able to prepare the folder ahead of time. We already wrote ensureParentDirExists, so let’s use it:
import java.io.File
fun main() {
val paths = buildStoragePaths()
val ok = ensureParentDirExists(paths.reportFile)
println("reportParentReady=$ok") // reportParentReady=true/false
}
For now we’re not doing writeText and not reading readLines—that will be in the next lectures. But this refactor is what makes future operations more reliable: first you ensure the “place for the file” is correct, and only then do you move on to reading/writing.
6. Common mistakes when working with File and Path
Moving from “just strings” to “path objects” typically solves half the problems, but beginners happily create the other half themselves— simply because the filesystem is not obligated to guess your intent. Let’s go through the most common mistakes so you can recognize them by sound, like an experienced mechanic recognizes a knock in the engine.
Error #1: thinking that File("x.txt") creates a file.
File creates a path object, not a file on disk. It’s like writing an address on an envelope: the letter hasn’t been sent, the house hasn’t been built, and the mail carrier isn’t even aware yet. Real “actions” start only when reading/writing or explicitly creating something.
Error #2: checking only exists() and not checking isFile/isDirectory.
exists() answers “is there something?”, but not “what exactly?” If the path points to a directory and you try to read it as a file, you’ll get an error at read time. It’s much calmer to agree with reality in advance: “I expect a file” → that means I want exists() && isFile.
Error #3: using mkdir() for nested folders and being surprised by false.
mkdir() creates one directory and is not obligated to create parents. If you want data/reports but the data folder doesn’t exist yet, mkdir() for reports can’t “pave the way.” For a chain, you almost always need mkdirs().
Error #4: not accounting for the fact that parentFile can be null.
If you work with File("report.txt"), then the “parent” as a separate object may be absent. This is not a bug. It just means the file is located directly in the working directory. In such cases there’s no need to “prepare a folder,” and your utility should handle this correctly.
Error #5: confusing the “project folder” with the “working directory of the run.”
It’s very tempting to think that a relative path is resolved “from the folder with sources.” But the process resolves a relative path from the working directory, which depends on how you run the program. So when things look strange, it’s useful to print File(".").absolutePath and look at the situation soberly.
Error #6: concatenating paths as strings and catching small slash bugs.
If in one place you have data/, in another reports, and in a third /report.txt, you can easily get data//reports//report.txt or datareportsreport.txt. Building via File(parent, child) or Path.of(...) removes an entire class of these errors.
Error #7: smearing paths throughout the code.
Today you use "data/expenses.txt" in one place, tomorrow you add "data/reports/last-report.txt" in another, the day after tomorrow you rename the folder—and now you have three different “truths” about where the data is stored. It’s much easier to create one place (a function or a data class) that builds all paths, and then use only that. It looks a bit “stricter,” but it saves a lot of time and stress.