A Type‑Safe Kotlin DSL for JSON Serialization Using Inline Functions
Introduction
Over the years I’ve found myself repeatedly copying the same boilerplate when turning Kotlin data classes into JSON. The repetitive `JsonObject()`, `addProperty()`, and manual null checks not only clutter the code but also make compile‑time type safety a luxury rather than a guarantee. In this article I’ll show you a compact DSL that leverages Kotlin’s inline functions and reified type parameters to generate JSON in a type‑checked, readable manner. The pattern is small enough to drop into any project, yet powerful enough to handle most serialization needs without pulling in heavy libraries.
The Problem in Real Projects
Imagine a service that exposes user profiles, product catalogs, and order histories via a REST API. Each endpoint expects a JSON representation of a Kotlin data class. In a typical setup you end up with something like:
val userJson = JsonObject().apply {
addProperty("id", user.id)
addProperty("name", user.name)
addProperty("email", user.email)
addProperty("active", user.isActive)
// … more fields
}
Not only is this verbose, but a typo in a key name or assigning the wrong type slips through until runtime. In a fast‑moving codebase, the cost of such errors quickly adds up.
The Solution Overview
I tackled this by creating an **inline DSL** that lets you write:
val userJson = jsonObject {
"id" to user.id
"name" to user.name
"email" to user.email
"active" to user.isActive
}
The DSL uses two key Kotlin features:
- **Inline functions** – they are expanded at the call site, eliminating the overhead of a lambda and allowing the compiler to see the contents directly.
- **Reified type parameters** – they let the DSL know exactly which value type is being supplied for each key, giving you compile‑time safety.
Under the hood the DSL builds a `JsonObject` from the standard Gson or Jackson library, but the ergonomic interface keeps the source code clean and self‑documenting.
Building the DSL
Below is the complete DSL implementation. I keep it in a single file so you can drop it into `src/main/kotlin/com/you/dsl/JsonDsl.kt` and start using it immediately.
package com.you.dsl
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
/**
* A type‑safe builder for JsonObject.
* The inline function receives a lambda that can add key‑value pairs.
* Reified type parameters ensure the correct JsonPrimitive is created.
*/
inline fun <T> jsonObject(crossinline builder: JsonObjectBuilder<T>.(JsonObject) -> Unit): JsonObject {
val obj = JsonObject()
builder(obj, obj)
return obj
}
/**
* Builder function that maps a pair to the JsonObject.
* The generic parameter T is reified, allowing the compiler to infer the correct primitive type.
*/
inline fun <T> JsonObjectBuilder<T>.add(pair: Pair<String, T>) {
val primitive = when (T::class) {
String::class -> JsonPrimitive(value as? String)
Int::class -> JsonPrimitive(value as? Int)
Double::class -> JsonPrimitive(value as? Double)
Boolean::class -> JsonPrimitive(value as? Boolean)
else -> throw IllegalArgumentException("Unsupported type ${T::class.simpleName}")
}
addProperty(pair.first, primitive)
}
/**
* Helper type alias for the builder lambda.
*/
typealias JsonObjectBuilder<T> = (Pair<String, T>) -> Unit
A couple of notes on this implementation:
- The `jsonObject` function is **inline**, which means the builder lambda is inlined, keeping the generated bytecode small.
- The `add` method is generic over `T`. Because `T` is reified, the compiler can check that you’re not accidentally passing a `List` where a `String` is expected.
- Only primitive types are supported in this example; you can extend the `when` block to include `Long`, `LocalDate`, etc.
Usage Example
Let’s see the DSL in action with a typical domain model:
data class User(
val id: Int,
val name: String,
val email: String,
val isActive: Boolean
)
fun main() {
val user = User(42, "Alice", "alice@example.com", true)
val payload = jsonObject {
"id" to user.id
"name" to user.name
"email" to user.email
"active" to user.isActive
}
println(payload.toString()) // {"id":42,"name":"Alice","email":"alice@example.com","active":true}
}
The resulting JSON is exactly what you’d get from a manual builder, but the code reads like a map literal. If you accidentally misspell a key, the compiler will point it out. If you try to assign a `List` to a numeric field, the `when` block will throw a clear `IllegalArgumentException` at runtime.
Benefits and Best Practices
This DSL brings three concrete advantages:
- **Type safety** – the compiler validates key‑value compatibility, eliminating whole classes of runtime bugs.
- **Reduced boilerplate** – you no longer need repetitive `addProperty` calls, making the intent clearer.
- **Extensibility** – you can add new primitive types or even custom serializers by expanding the `when` block.
When you adopt the DSL, consider the following best practices:
- Keep the builder small. If you need complex nested objects, create a separate `nestedJsonObject` function that reuses the same pattern.
- Document the supported types in a comment near the `when` block. Future maintainers will know what can be used without digging through the implementation.
- Prefer the DSL for simple serialization tasks. For heavy‑weight transformations or custom adapters, fall back to your existing JSON library.
Pro tip: If you start using this DSL across multiple modules, extract it into a library artifact. The inline functions will be compiled into each consumer module, preserving the type‑safety benefits without any runtime overhead.
Common Pitfalls
Even with compile‑time checks, there are a few traps developers fall into:
- Assuming any Kotlin type maps automatically to a JSON primitive. The DSL only covers `String`, `Int`, `Double`, and `Boolean` out of the box.
- Using the DSL for nullable values without handling `null`. The `when` block will throw because `T::class` does not match `Null::class`. Add a nullable branch if you need optional fields.
- Over‑using the DSL for deep nesting. While possible, readability suffers when you have dozens of nested calls.
Mitigate these issues by adding a nullable branch, e.g.:
inline fun <T> JsonObjectBuilder<T?>.add(pair: Pair<String, T?>) {
val primitive = when (T::class) {
String::class -> JsonPrimitive(value as? String)
// … other types
else -> if (value == null) null else throw IllegalArgumentException(...)
}
addProperty(pair.first, primitive)
}
Now you can safely write `"email" to user.emailOrNull` without worrying about a `NullPointerException` inside the DSL.
Conclusion
Inline functions with reified type parameters give you a surprisingly powerful tool for building domain‑specific languages that are both concise and type‑safe. The JSON DSL shown here eliminates repetitive boilerplate, catches errors early, and can be extended to fit almost any serialization scenario you encounter. By keeping the implementation focused on primitives and using clear naming, the DSL stays maintainable while providing immediate productivity gains.
Give it a try in your next Kotlin project. You’ll find that the code not only looks cleaner but also feels more confident, knowing the compiler has your back from the first line you write.