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.
What you’ll practice
Section titled “What you’ll practice”- Data classes for structured configuration (
ServerConfig,DatabaseConfig,AppConfig) - Sealed interfaces for parse/validation results and error variants
- Null safety —
?.,?:, andletto thread nullable values through whenexpressions for exhaustive matching over the sealed types- Extension functions as small, reusable parsing helpers
- Basic string manipulation
Requirements
Section titled “Requirements”-
Read a
.properties-style input. Lines arekey=value; blank lines and lines starting with#are comments to skip.# Application Configserver.host=0.0.0.0server.port=8080database.url=jdbc:postgresql://localhost:5432/mydbdatabase.username=admindatabase.password=secretdatabase.pool.size=10app.name=My Serviceapp.debug=trueapp.max-retries=3 -
Parse into typed config objects. Group the flat keys into nested data classes — a
ServerConfig, aDatabaseConfig, and a top-levelAppConfigthat holds both. -
Model the result as a sealed type. Parsing returns either a
Successcarrying the config, or aFailurecarrying aList<ConfigError>— and eachConfigErroris itself one of a few known shapes (missing key, invalid value, invalid format). -
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}The worked solution
Section titled “The worked solution”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
build.gradle.kts
Section titled “build.gradle.kts”Pure Kotlin/JVM with the application plugin so ./gradlew run works. There is no
serialization dependency this time — we parse plain text by hand.
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")}rootProject.name = "config-parser"Main.kt — the domain model
Section titled “Main.kt — the domain model”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.
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.”
// --- 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)") }}Main.kt — the parser
Section titled “Main.kt — the parser”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.
// --- 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!! ) ) )}Main.kt — display and main
Section titled “Main.kt — display and main”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(...)).
// --- 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.
Run it
Section titled “Run it”-
Build the project:
Terminal window ./gradlew build -
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))