Skip to content

Type-Safe Config Parser

Build a configuration parser that reads a .properties-style file and returns a type-safe configuration object — and when the input is wrong, reports every problem as a list of structured errors modeled with a sealed class hierarchy (instead of throwing on the first bad line).

This is the exercise where Kotlin’s type system starts paying off. A TS dev would reach for zod or hand-rolled validation; a Go dev would return (Config, error) and check err != nil. Kotlin lets you model “success with a value OR failure with a list of errors” directly in the type, so the compiler forces you to handle both.

  • Data classes for structured configuration (ServerConfig, DatabaseConfig, AppConfig)
  • Sealed interfaces for parse/validation results and error variants
  • Null safety?., ?:, and let to thread nullable values through
  • when expressions for exhaustive matching over the sealed types
  • Extension functions as small, reusable parsing helpers
  • Basic string manipulation
  1. Read a .properties-style input. Lines are key=value; blank lines and lines starting with # are comments to skip.

    # Application Config
    server.host=0.0.0.0
    server.port=8080
    database.url=jdbc:postgresql://localhost:5432/mydb
    database.username=admin
    database.password=secret
    database.pool.size=10
    app.name=My Service
    app.debug=true
    app.max-retries=3
  2. Parse into typed config objects. Group the flat keys into nested data classes — a ServerConfig, a DatabaseConfig, and a top-level AppConfig that holds both.

  3. Model the result as a sealed type. Parsing returns either a Success carrying the config, or a Failure carrying a List<ConfigError> — and each ConfigError is itself one of a few known shapes (missing key, invalid value, invalid format).

  4. Accumulate errors, don’t fail fast. Report every missing key and every bad value in one pass, not just the first one.

The target shapes — note how ParseResult<out T> is generic and variant, while Failure is fixed to ParseResult<Nothing> because a failure carries no value:

data class AppConfig(
val name: String,
val debug: Boolean,
val maxRetries: Int,
val server: ServerConfig,
val database: DatabaseConfig
)
sealed interface ParseResult<out T> {
data class Success<T>(val config: T) : ParseResult<T>
data class Failure(val errors: List<ConfigError>) : ParseResult<Nothing>
}
sealed interface ConfigError {
data class MissingKey(val key: String) : ConfigError
data class InvalidValue(val key: String, val value: String, val expected: String) : ConfigError
data class InvalidFormat(val line: Int, val content: String) : ConfigError
}

A minimal single-module Gradle project — one Kotlin file does the whole job, no external dependencies beyond the test runner.

  • Directoryconfig-parser/
    • build.gradle.kts deps + build config
    • settings.gradle.kts project name
    • Directorysrc/main/kotlin/com/example/configparser/
      • Main.kt data classes, sealed results, parser, and main

Pure Kotlin/JVM with the application plugin so ./gradlew run works. There is no serialization dependency this time — we parse plain text by hand.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
application
}
group = "com.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass.set("com.example.configparser.MainKt")
}
settings.gradle.kts
rootProject.name = "config-parser"

The data classes are the easy part — data class gives you equals, hashCode, copy, and a readable toString for free, which is exactly what you want for config. The interesting design choice is the sealed hierarchy: a sealed type has a closed set of subtypes known at compile time, so a when over it can be exhaustive without an else branch. ParseResult is the “success-or-failure” envelope; ConfigError enumerates the ways parsing can go wrong.

src/main/kotlin/com/example/configparser/Main.kt
package com.example.configparser
// --- Data Classes ---
data class ServerConfig(val host: String, val port: Int)
data class DatabaseConfig(
val url: String,
val username: String,
val password: String,
val poolSize: Int
)
data class AppConfig(
val name: String,
val debug: Boolean,
val maxRetries: Int,
val server: ServerConfig,
val database: DatabaseConfig
)
// --- Sealed Result Types ---
sealed interface ParseResult<out T> {
data class Success<T>(val config: T) : ParseResult<T>
data class Failure(val errors: List<ConfigError>) : ParseResult<Nothing>
}
sealed interface ConfigError {
data class MissingKey(val key: String) : ConfigError
data class InvalidValue(val key: String, val value: String, val expected: String) : ConfigError
data class InvalidFormat(val line: Int, val content: String) : ConfigError
}

Main.kt — parsing helpers as extension functions

Section titled “Main.kt — parsing helpers as extension functions”

Rather than a big nested parser, the solution adds small extension functions on String. String.toIntResult(...) and String.toBooleanResult(...) each return a Pair of (value-or-null, error-or-null) — a lightweight “either” that the caller destructures. Kotlin’s stdlib toIntOrNull() does the heavy lifting: it returns null instead of throwing on a non-numeric string, which is the null-safe idiom a Go dev will recognize as “comma-ok without the panic.”

src/main/kotlin/com/example/configparser/Main.kt
// --- Extension Functions for Parsing ---
fun String.toIntResult(key: String): Pair<Int?, ConfigError?> {
val parsed = this.toIntOrNull()
return if (parsed != null) {
parsed to null
} else {
null to ConfigError.InvalidValue(key, this, "integer")
}
}
fun String.toBooleanResult(key: String): Pair<Boolean?, ConfigError?> {
return when (this.lowercase()) {
"true" -> true to null
"false" -> false to null
else -> null to ConfigError.InvalidValue(key, this, "boolean (true/false)")
}
}

parseProperties turns raw text into a Map<String, String> with a single collection pipeline: number the lines, drop blanks and comments, then split each on the first =. Using limit = 2 on split means a value containing = (like the JDBC URL) survives intact.

parseConfig is where validation lives. It keeps a mutableListOf<ConfigError>() and uses three nested helper functions — require, requireInt, requireBoolean — that each append an error and return null on failure. Because errors are accumulated rather than thrown, one parse pass surfaces all the problems at once. The ?: Elvis operator and ?.let { … } keep the null-handling terse.

src/main/kotlin/com/example/configparser/Main.kt
// --- Parser ---
fun parseProperties(input: String): Map<String, String> {
return input.lines()
.mapIndexed { index, line -> index + 1 to line.trim() }
.filter { (_, line) -> line.isNotBlank() && !line.startsWith("#") }
.associate { (_, line) ->
val parts = line.split("=", limit = 2)
if (parts.size == 2) {
parts[0].trim() to parts[1].trim()
} else {
parts[0].trim() to ""
}
}
}
fun parseConfig(properties: Map<String, String>): ParseResult<AppConfig> {
val errors = mutableListOf<ConfigError>()
// Helper to get a required string value
fun require(key: String): String? {
return properties[key] ?: run {
errors.add(ConfigError.MissingKey(key))
null
}
}
// Helper to get a required int value
fun requireInt(key: String): Int? {
val value = require(key) ?: return null
val (parsed, error) = value.toIntResult(key)
error?.let { errors.add(it) }
return parsed
}
// Helper to get a required boolean value
fun requireBoolean(key: String): Boolean? {
val value = require(key) ?: return null
val (parsed, error) = value.toBooleanResult(key)
error?.let { errors.add(it) }
return parsed
}
// Parse all fields
val host = require("server.host")
val port = requireInt("server.port")
val dbUrl = require("database.url")
val dbUsername = require("database.username")
val dbPassword = require("database.password")
val dbPoolSize = requireInt("database.pool.size")
val appName = require("app.name")
val appDebug = requireBoolean("app.debug")
val appMaxRetries = requireInt("app.max-retries")
// If there are errors, return failure
if (errors.isNotEmpty()) {
return ParseResult.Failure(errors)
}
// All values are non-null at this point (we added errors for any nulls)
return ParseResult.Success(
AppConfig(
name = appName!!,
debug = appDebug!!,
maxRetries = appMaxRetries!!,
server = ServerConfig(host = host!!, port = port!!),
database = DatabaseConfig(
url = dbUrl!!,
username = dbUsername!!,
password = dbPassword!!,
poolSize = dbPoolSize!!
)
)
)
}

A ConfigError.display() extension turns each variant into a readable line via an exhaustive when — no else branch needed because ConfigError is sealed, so if you add a fourth error type the compiler flags this when as incomplete. main runs the parser over a valid and an invalid sample and pattern-matches the result with when (val result = parseConfig(...)).

src/main/kotlin/com/example/configparser/Main.kt
// --- Display ---
fun ConfigError.display(): String = when (this) {
is ConfigError.MissingKey -> "Missing key: $key"
is ConfigError.InvalidValue -> "Invalid value for '$key': '$value' (expected: $expected)"
is ConfigError.InvalidFormat -> "Invalid format at line $line: '$content'"
}
fun main() {
val validInput = """
# Application Config
server.host=0.0.0.0
server.port=8080
database.url=jdbc:postgresql://localhost:5432/mydb
database.username=admin
database.password=secret
database.pool.size=10
app.name=My Service
app.debug=true
app.max-retries=3
""".trimIndent()
println("=== Parsing valid config ===")
when (val result = parseConfig(parseProperties(validInput))) {
is ParseResult.Success -> {
println("SUCCESS:")
println(" ${result.config}")
}
is ParseResult.Failure -> {
println("FAILURE:")
result.errors.forEach { println(" - ${it.display()}") }
}
}
println()
// Invalid config: missing server.port, non-int pool size, bad boolean
val invalidInput = """
server.host=0.0.0.0
database.url=jdbc:postgresql://localhost:5432/mydb
database.username=admin
database.password=secret
database.pool.size=abc
app.name=My Service
app.debug=yes
app.max-retries=3
""".trimIndent()
println("=== Parsing invalid config ===")
when (val result = parseConfig(parseProperties(invalidInput))) {
is ParseResult.Success -> {
println("SUCCESS:")
println(" ${result.config}")
}
is ParseResult.Failure -> {
println("FAILURE:")
result.errors.forEach { println(" - ${it.display()}") }
}
}
}

Why this matters: the when (result) block is exhaustive. Because ParseResult is a sealed interface with exactly two subtypes, the compiler knows you’ve handled every case. You physically cannot forget the failure branch — which is the entire reason to model results as types instead of throwing exceptions.

  1. Build the project:

    Terminal window
    ./gradlew build
  2. Run the built-in valid/invalid demo:

    Terminal window
    ./gradlew run --quiet

Expected output:

=== Parsing valid config ===
SUCCESS:
AppConfig(name=My Service, debug=true, maxRetries=3, server=ServerConfig(host=0.0.0.0, port=8080), database=DatabaseConfig(url=jdbc:postgresql://localhost:5432/mydb, username=admin, password=secret, poolSize=10))
=== Parsing invalid config ===
FAILURE:
- Missing key: server.port
- Invalid value for 'database.pool.size': 'abc' (expected: integer)
- Invalid value for 'app.debug': 'yes' (expected: boolean (true/false))