JavaRush /Courses /Kotlin SELF /Program startup: args, environment variables, and working...

Program startup: args, environment variables, and working directory

Kotlin SELF
Level 17, Lesson 3
Available

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.

1
Task
Kotlin SELF, level 17, lesson 3
Locked
Startup report
Startup report
1
Task
Kotlin SELF, level 17, lesson 3
Locked
Showcase mode
Showcase mode
1
Task
Kotlin SELF, level 17, lesson 3
Locked
Rectangle with defaults
Rectangle with defaults
1
Task
Kotlin SELF, level 17, lesson 3
Locked
Startup configuration
Startup configuration
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION