1. What it means to “run a program”: code + environment
When you press the Run button in an IDE or start a program from the terminal, it may seem like something simple happens: “execute main”. But in practice, startup almost always includes external parameters. A program can start with command-line arguments, with environment variables, in a specific working directory — and all of that affects the behavior of even the simplest application.
Imagine you wrote a “Guess the Number” game (we already did it earlier). It has settings: the range of numbers and the number of attempts. You can “hardcode” them in the code, and it will work. But as soon as you want to quickly change the range without rewriting the program, a question appears: “how do I pass settings at startup?”. Today we are learning to do exactly that — like adults, but without pain.
So we do not sprawl across topics, we will keep in mind a training app GuessNumberApp: a “Guess the Number” game that can now be configured at startup.
2. Command-line arguments: main(args: Array<String>)
When a program starts, the operating system (and/or the runtime launcher) can pass it a list of strings — arguments. In Kotlin, there is a form of main that takes an array of strings: fun main(args: Array<String>). This is the officially supported “second form” of the entry point.
It is important to catch the key point right away: arguments are always strings. Even if you pass 10, in the program it is "10", and if you need a number — you will have to parse it, which means errors are possible.
The very first contact: just print the arguments
First, let us do what any developer does in an unclear situation: print what actually came in.
package app
fun main(args: Array<String>) {
println("Args count = ${args.size}") // for example: Args count = 2
println("Args = ${args.contentToString()}") // for example: Args = [100, 7]
}
Here args.size is the number of arguments, and contentToString() helps print an array nicely.
If you run without arguments, size will be 0, and that is fine: the program should not be offended at the world for not being given anything.
Why you cannot immediately do args[0]
Because args can be empty. Then indexing will lead to an array out-of-bounds error. And this is the case where the program will crash before it can say: “Hey, user, you forgot the arguments.”
So the rule is simple: first check args.size, then read args[i].
package app
fun main(args: Array<String>) {
if (args.size == 0) {
println("No args. Running with defaults.") // No args. Running with defaults.
return
}
println("First arg = ${args[0]}") // for example: First arg = 100
}
Notice: we are not using anything “magic”. Just if and indexes — you already know this.
Parsing arguments: “raw strings → preparation → parsing”
Command-line arguments are a classic example of “dirty input”. It is easy to forget them, easy to mix up the order, easy to pass “one hundred” instead of 100. So we apply a familiar pattern: read a string → prepare → try to parse → handle null.
Simple scheme: max and attempts positionally
Let us agree on this contract:
args[0] — upper bound (max)
args[1] — number of attempts (attempts)
Let us do the parsing carefully:
package app
fun main(args: Array<String>) {
val maxRaw = if (args.size > 0) args[0] else null
val attemptsRaw = if (args.size > 1) args[1] else null
val max = maxRaw?.trim()?.toIntOrNull() ?: 100
val attempts = attemptsRaw?.trim()?.toIntOrNull() ?: 7
println("max=$max attempts=$attempts") // for example: max=100 attempts=7
}
Three things matter here.
First, trim() saves you from accidental spaces (yes, they can be passed “accidentally”, especially in some shells or when copy-pasting).
Second, toIntOrNull() does not throw an exception; it returns null if the string is not a number.
Third, the Elvis operator ?: provides default values. This makes the program resilient: even if the arguments are bad, it still starts.
Connecting parameters to the application
Let us assume that somewhere in the project you already have the game function (we are not rewriting the whole game — we are learning to start it with parameters):
package app
fun runGuessNumberGame(max: Int, attempts: Int) {
println("Game started: max=$max, attempts=$attempts")
// The game logic would be here, which you already wrote earlier
}
Now main becomes the “front door”: it reads the startup environment and passes valid parameters into the logic.
package app
fun main(args: Array<String>) {
val maxRaw = if (args.size > 0) args[0] else null
val attemptsRaw = if (args.size > 1) args[1] else null
val max = maxRaw?.trim()?.toIntOrNull() ?: 100
val attempts = attemptsRaw?.trim()?.toIntOrNull() ?: 7
runGuessNumberGame(max, attempts)
}
This is an important architectural feeling: main is the place where you “glue together” the outside world and your program. And the logic itself (functions) should receive already-normal values.
3. Environment variables: System.getenv
Arguments are convenient when there are few parameters and you change them “on the fly”. But there is another channel: environment variables. These are KEY=VALUE pairs that the system passes to a process at startup. At a practical level, this looks like “settings next to the launch” that you do not have to type into the command line every time.
In Kotlin/JVM, environment variables are read via System.getenv("NAME"). And there is a nuance that fits perfectly with the null-safety topic: if the variable does not exist, you get null.
Minimal example: read and diagnose
package app
fun main() {
val raw = System.getenv("GUESS_MAX")
println("GUESS_MAX = $raw") // for example: GUESS_MAX = 200 (or: GUESS_MAX = null)
}
If you see null, it is not an error. It just means: “the variable is not set”.
Safely parsing a number from an env variable
Let us do the same as with arguments: normalization → parsing → default.
package app
fun main() {
val max = System.getenv("GUESS_MAX")
?.trim()
?.toIntOrNull()
?: 100
println("max=$max") // for example: max=100
}
This is a very “Kotlin-ish” style: the ?. chain reads like “if the value exists — process it, otherwise do not touch it”.
Source priority: args over env, env over default
In real applications, people often use this logic:
1) if the user explicitly passed an argument — respect it;
2) otherwise check the environment variable;
3) otherwise take the default.
Let us implement this without introducing any new scary concepts. We will make a helper function that selects max.
package app
fun resolveMax(args: Array<String>): Int {
val fromArgs = if (args.size > 0) args[0].trim().toIntOrNull() else null
if (fromArgs != null) return fromArgs
val fromEnv = System.getenv("GUESS_MAX")?.trim()?.toIntOrNull()
if (fromEnv != null) return fromEnv
return 100
}
And use it in main:
package app
fun main(args: Array<String>) {
val max = resolveMax(args)
println("Resolved max = $max") // for example: Resolved max = 150
}
Why is this convenient? Because you have “packed” the selection policy into a single function. main does not turn into a sheet of if.
4. Working directory: System.getProperty("user.dir")
The working directory is the folder relative to which relative paths are interpreted. Even if you are not working with files yet, it is worth understanding this concept now, because it regularly shows up as “it works on my machine, but not on my friend’s”.
On the JVM, it is often convenient to read the working directory like this: System.getProperty("user.dir"). This is not “Kotlin magic”, it is just access to platform properties, and we use System.* as a ready-made tool without digging into how the JVM implements it.
Print the working directory for diagnostics
package app
fun main() {
val dir = System.getProperty("user.dir")
println("Working directory: $dir") // for example: Working directory: /Users/alice/projects/guess
}
If you run from an IDE, the working directory will often (but not always) be the project root. If you run from a terminal, it is usually equal to the “current terminal folder”.
A thought experiment: relative paths are “anchored” to "user.dir"
We are not touching files yet (that will be on other days), but we can demonstrate with strings: imagine you want to reference "data/config.txt". You can “mentally” turn a relative path into an absolute one by concatenating it with the working directory.
package app
fun main() {
val dir = System.getProperty("user.dir")
val relative = "data/config.txt"
val full = "$dir/$relative"
println(full) // for example: /Users/alice/projects/guess/data/config.txt
}
Yes, this is not a universal way to work with paths (there are dedicated APIs for that), but as an illustration of the idea it is great: the working directory defines the “reference point”.
5. Settings sources diagram and a mini refactor
Right now we have three sources of “external data at startup”: arguments, environment variables, and (as a last resort) defaults in code. Sometimes people also add interactive input via readln(), but that is already “during execution”, not “startup parameters”.
It is convenient to keep it as a small pipeline:
flowchart TD
A[Running program] --> B{Are there args?}
B -->|yes| C[Parse args: String -> Int?]
B -->|no| D{Are env?}
D -->|yes| E[Parse env: String -> Int?]
D -->|no| F[Using default]
C --> G["Running logic: runGuessNumberGame(...)"]
E --> G
F --> G
And this is a truly “adult” habit: do not mix the game/app logic with the logic of reading startup parameters.
When a program is launched not from an IDE but somewhere else, it is useful to be able to quickly understand: “what did it decide?”. That is why people often add small diagnostics: print parameters, which source they came from, what the working directory is. We will do this neatly, with short functions, without overcomplicating anything.
A function for attempts with the same priority
package app
fun resolveAttempts(args: Array<String>): Int {
val fromArgs = if (args.size > 1) args[1].trim().toIntOrNull() else null
if (fromArgs != null) return fromArgs
val fromEnv = System.getenv("GUESS_ATTEMPTS")?.trim()?.toIntOrNull()
if (fromEnv != null) return fromEnv
return 7
}
Assemble a compact main
package app
fun main(args: Array<String>) {
val max = resolveMax(args)
val attempts = resolveAttempts(args)
val dir = System.getProperty("user.dir")
println("dir=$dir") // for example: dir=/Users/alice/projects/guess
println("max=$max attempts=$attempts") // for example: max=100 attempts=7
runGuessNumberGame(max, attempts)
}
Notice: here main reads like a script. First we prepared parameters, then we showed a bit of diagnostics, then we called the main logic.
Cheat sheet: args vs env vs working directory
To avoid confusion, it is useful to distinguish these channels not by “how Linux usually does it”, but by meaning.
| Channel | What it is | When it is convenient | Main risk |
|---|---|---|---|
| args (Array<String>) | Explicit startup parameters, a list of strings | Quickly change a value on every run | Easy to forget an argument or mix up the order |
| env (System.getenv) | Settings that “live next to the launch” | Keep defaults for an environment (for example, “on this PC max=200 always”) | The variable may be missing → you will get null |
| working directory ("user.dir") | Context of “where it was launched from” | Diagnostics and understanding why relative paths behave strangely | It may differ between an IDE and a terminal |
6. Common mistakes
Mistake #1: accessing args[0] without checking the array size.
This is one of the most common reasons for crashes “out of nowhere”: you run without arguments, and the code immediately reaches for the first element. The right habit: first if (args.size > 0), then index access.
Mistake #2: parsing numbers with toInt() without a safety net.
If you use toInt() for arguments or environment variables, then for any non-numeric string the program will get an exception and exit. For input data, it is almost always better to use toIntOrNull() and then handle it normally with if (x != null) or ?:.
Mistake #3: assuming an env variable “definitely exists”.
System.getenv("NAME") returns a nullable value for a very honest reason: the variable may not exist. Even if you configured it “on your machine”, on another computer or in another run it may be absent. So ?: and a clear default are not a luxury, but a way to avoid mysterious crashes.
Mistake #4: ignoring the working directory and being surprised “why a file/folder is not found”.
Even if you are not reading files yet, you can already end up in a situation where a relative path is used indirectly (for example, in startup parameters or in output). The habit of printing System.getProperty("user.dir") during debugging often saves hours — and a few nerve cells.
Mistake #5: mixing “reading the environment” and “business logic” in one function.
If main parses args, reads env, prints diagnostics, plays “guess the number”, and also cracks jokes, it will be hard to debug. It is much calmer when main only assembles input parameters, and the main logic lives in a separate function and receives already-prepared values.