General thoughts
Storing and retrieving application settings should be simple: just write the setting's value to a file. From a programmer's point of view, reading from and writing to a file is not the complicated part, it is to decide on the use of a binary or human readable file format, each with it's up- and downsides.
A binary file format has the advantage that data-types do not need to be converted to or from a human readable form; the downside is that you have to read data in exactly the same order it was written, and vice versa. This also makes the process application specific.
A text file can easily be designed to be generic and while there are many such formats, the most simple is that of key-value pairs-like the INI file format.
The real challenge here lies converting to and from a string‑encoded value to the strongly typed value the program expects.In this article, I will show you a simple and extensible way to do exactly that.
You can find the complete code of my configuration arguments readerConfaron my github page.
The conversion mechanism
Since configuration files store everything as text, we need a reliable way to turn those strings into actual typed values. Confar keeps this simple by declaring a generic TypeParser interface with a single job—to convert a raw string into the correct type. If the value can’t be parsed, the function throws an IllegalArgumentException, making the failure explicit and easy to diagnose.
public interface TypeParser<T> {
public T parse(String raw) throws IllegalArgumentException;
}
Declaring and storing the setting
Each configuration file entry is represented by a generic Setting class. It stores the setting’s name, its current value, and the TypeParser used to convert the raw string from the file into the appropriate type. Settings may be required or optional: required settings have no default value, while optional ones must define one to avoid NullPointerException in the application. File values override defaults when present. The core of the class is shown below:
public static class Setting<T> {
public Setting(String name, T defaultValue, TypeParser<T> typeParser) throws IllegalArgumentException {
if (null == defaultValue) {
throw new IllegalArgumentException("Default value must not be null");
}
this.name = name;
this.value = defaultValue;
this.required = false;
this.typeParser = typeParser;
}
public Setting(String name, TypeParser<T> typeParser) throws IllegalArgumentException {
this.name = name;
this.required = true;
this.typeParser = typeParser;
}
private final String name;
public String getName() {
return name;
}
private final boolean required;
public boolean isRequired() {
return required;
}
private T value = null;
private void parse(String value) throws IllegalArgumentException {
this.value = typeParser.parse(value);
}
public T get() throws IllegalStateException {
if (null == value) {
throw new IllegalStateException("Setting '" + name + "' has no value. Use Confar.load() to initialise settings before calling get() on them");
}
return value;
}
private final TypeParser<T> typeParser;
public TypeParser<T> getTypeParser() {
return typeParser;
}
}
With the Setting class in place, declaring configuration entries becomes straightforward. A required setting might look like this:
public Setting<Boolean> verbose = new Setting("verbose", StandardTypeParsers.BOOLEAN);
and an optional one with a default value like this:
public Setting<Integer> copies = new Setting("copies", 2, StandardTypeParsers.INTEGER);
Now that the declarations are in place, the next step is retrieving their values from a configuration file.
Confar, the utility class
Because each Setting instance manages its own value, Confar doesn’t need to hold any state. It works purely as a utility class that knows how to load settings from a file and save them back. The load method receives a map of declared settings and fills them with values from the configuration file, while save writes the current values back out.
public class Confar {
private Confar() {
;
}
public static void load(File file, Map<String, Setting<?>> declaredSettings) throws IOException, IllegalArgumentException, IllegalStateException {
// ...
}
public static void save(File file, List<Setting<?>> declaredSettings) throws IOException, IllegalStateException {
// ...
}
}
With Confar reduced to a simple loader and saver, the only missing piece is the format of the configuration file. Once we know how the file is structured, the logic inside the load method follows almost directly from those rules.
General layout of the file (syntax)
The configuration file format broadly follows the Unix‑style INI convention:
- Each line contains exactly one key-value pair; no line wrapping
- Leading and trailing whitespace is allowed and ignored
- Empty or blank lines are skipped
- Single-line comments are supported
- Comments start with `#` or `;` and are ignored
- Groups improve readability but do not impose structure
- A line starting with `[` begins a new group and must end with `]`;
the content inside must not be blank
These rules translate directly into the structure of the read loop inside load:
if (null == file) {
throw new IOException("File must not be null");
}
if ((null == declaredSettings) || declaredSettings.isEmpty()) {
throw new IllegalArgumentException("Settings list must not be null or empty");
}
try (BufferedReader configFile = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) {
int lines = 0;
String currentLine;
String group = "";
List<String> parsedSettings = new ArrayList<>();
while (null != (currentLine = configFile.readLine())) {
lines++;
currentLine = currentLine.trim();
if (currentLine.startsWith("[")) {
if (!line.endsWith("]")) {
// throw exception
}
// ...
} else if (currentLine.isBlank()) {
continue;
} else if (currentLine.startsWith("#")) {
continue;
} else if (currentLine.startsWith(";")) {
continue;
} else {
// ...
}
}
// ...
}
Here, lines helps produce meaningful error messages, currentLine carries the trimmed content of each line, group reflects the currently active section, and parsedSettings ensures we don’t process the same setting twice.
Grouping setting
To keep configuration files readable, we allow settings to be grouped under headers like [network] or [ui]. When a setting appears under such a header, it should remember that group. Every setting starts out in the empty group—the implicit group at the top of the file before any header is encountered. Since [ ] isn’t a valid group, this empty group is never written explicitly. Supporting this only requires a small addition to the Setting class:
private static final String DEFAULT_GROUP = "";
private String group = DEFAULT_GROUP;
private String getGroup() {
return group;
}
private void setGroup(String name) {
group = name;
}
Processing and assigning groups
Group headers may contain leading or trailing whitespace, but the actual name must not be empty. Because groups are purely cosmetic, the parsing logic stays simple:
group = currentLine.substring(1, currentLine.length() - 1).trim();
if (group.isBlank()) {
throw new IllegalArgumentException("Invalid group name in line " + lines);
}
continue;
Here we extract the text between the brackets, trim it, validate it, and remember it as the active group for the settings that follow.
Parsing settings
A setting is defined by a key‑value pair separated by =. Whitespace around both parts is ignored, and everything after the = belongs to the value. With that in mind, the parsing logic becomes straightforward:
int index = currentLine.indexOf('=');
if (-1 == index) {
throw new IllegalArgumentException("Invalid line " + lines + ": expected key=value");
}
String key = currentLine.substring(0, index).trim();
String val = currentLine.substring(index + 1).trim();
if (parsedSettings.contains(key)) {
throw new IllegalStateException("Duplicate setting '" + key + "' at line " + lines);
}
Setting setting = declaredSettings.get(key);
if (null == setting) {
throw new IllegalArgumentException("Unknown setting '" + key + "' at line " + lines);
}
setting.parse(val);
setting.setGroup(group); // soft setting, no hard mapping
parsedSettings.add(key);
We split the line, ensure the setting hasn’t already been declared, and confirm that the key is valid. The value is then parsed using the setting’s TypeParser, which either produces a typed value or throws an exception if the input is invalid. Only after successful parsing do we assign the group and mark the setting as processed.
One last check
After parsing the file and validating each entry, there’s one last thing to verify: did the user provide all required settings?
for (String key : declaredSettings.keySet()) {
if (!parsedSettings.contains(key) && declaredSettings.get(key).isRequired()) {
throw new IllegalStateException("Missing required setting '" + key + "'");
}
}
This final pass ensures that every required setting has a value—either from the file or from its default—so accessing it through get won’t cause surprises later.
Storing settings
Settings aren’t static—applications often need to update them and persist those changes. To support this, each Setting exposes a setter that validates the new value before storing it:
public void set(T value) {
if (null == value) {
throw new IllegalStateException("Setting '" + name + "' cannot be assigned a value of 'null'");
}
this.value = value;
}
Before saving the configuration, we sort the settings into their groups so the output file stays tidy and easy to read. The grouping logic is straightforward:
if (null == file) {
throw new IOException("File must not be null");
}
if ((null == declaredSettings) || declaredSettings.isEmpty()) {
throw new IllegalArgumentException("Settings list must not be null or empty");
}
Map<String, List<Setting<?>>> groupedSettings = new LinkedHashMap<>();
for (Setting setting : declaredSettings) {
List<Setting<?>> groupedSetting = groupedSettings.get(setting.getGroup());
if (null == groupedSetting) {
groupedSetting = new ArrayList<>();
groupedSettings.put(setting.getGroup(), groupedSetting);
}
groupedSetting.add(setting);
}
By collecting settings under their group names, we preserve both readability and clean output formatting.
With the settings grouped, the final step is to write them back to the file. We begin with the empty group—settings that weren’t under any explicit header—and then move on to the named groups:
try (BufferedWriter configFile = new BufferedWriter (new OutputStreamWriter(new FileOutputStream(file)))) {
List<Setting<?>> defaultGroup = groupedSettings.get("");
if (null != defaultGroup) {
for (Setting setting : defaultGroup) {
configFile.write(setting.getName());
configFile.write('=');
configFile.write(setting.get().toString());
configFile.newLine();
}
}
for (String group : groupedSettings.keySet()) {
if (!group.isBlank()) {
configFile.write('[');
configFile.write(group);
configFile.write(']');
configFile.newLine();
List<Setting<?>> settings = groupedSettings.get(group);
for (Setting setting : settings) {
configFile.write(setting.getName());
configFile.write('=');
configFile.write(setting.get().toString());
configFile.newLine();
}
configFile.newLine();
}
}
}
The empty group is written first to preserve the natural top‑of‑file layout.
Things to watch out for (and a small convenience tip)
Although settings can be declared anywhere, they must not be accessed before being loaded—unless they have a default value. Passing an incomplete list of settings to load can also lead to confusing errors later, when required settings turn out to be missing. This makes the correct order of operations important and not always intuitive for first‑time users.
Another drawback is that comments in the configuration file are lost when saving.
So far, the actual mechanic underlying the type-save reading has only been hinted at when showcasing settings declaration earlier on.
To make Confar easier to use, it’s helpful to provide a set of ready‑made parsers for common types:
public static class StandardTypeParsers {
public static final TypeParser<Boolean> BOOLEAN = new TypeParser<>(){
@Override public Boolean parse(String raw) throws IllegalArgumentException {
return Boolean.valueOf(raw);
}
};
// ...
}
You would define similar parsers for:
Byte
Short
Integer
Long
Float
Double and
String
Note that for
String, the parser should simply return the raw value unchanged.
Conclusion
We began with the simple goal not to overthink configuration files and we end the same way — with a tiny tool that reads what you wrote, writes what you meant, and politely avoids doing anything clever behind your back.
As a bonus, the generic nature of Setting allows users to extend the allowed setting types, e.g. ip-addresses, lists of ; seperated values-whatever you want. Just stay in line ;)
This article was written with the help of an LLM for structuring and wording. All technical content reflects my own understanding and decisions.
Top comments (0)