Understanding JSON → Kotlin
Data classes do everything you wanted in Java.
Why a Kotlin JSON DTO fits on one line, the three libraries that can parse it, and the null-safety surprise that catches every Jackson user.
The data class.
A Kotlin data class declares fields and gets a primary constructor, properties, equals, hashCode, toString, copy and destructuring for free. data class User(val userId: Int, val name: String, val avatar: String?) is a complete, immutable, value-equal DTO. Records in Java caught up with this idea a decade later; Kotlin had it from day one.
kotlinx.serialization, Moshi, Jackson — pick one.
Three viable JSON libraries. kotlinx.serialization is JetBrains' own — compile-time-generated, multiplatform-friendly, the default for KMP and Ktor. Moshi is the Square library — Kotlin-aware, fast, the de-facto for Android. Jackson works fine with Kotlin via the jackson-module-kotlin add-on. Modern Kotlin projects should pick kotlinx.serialization unless they already have heavy Jackson investment.
Nullability is the type system's job.
A Kotlin property typed String cannot be null; one typed String? can. A JSON field that's absent or null gets deserialized into the nullable type cleanly; trying to deserialize null into a non-nullable type throws. This is exactly right — Kotlin's type system tracks what the JSON allows. The trap (especially with Jackson, less so with kotlinx.serialization) is reflection-based deserializers assigning a non-null value of a type's default to a non-nullable field, masking the bug.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null } with kotlinx.serialization: @Serializable
data class User(
@SerialName("user_id") val userId: Int,
val name: String,
val avatar: String? = null
) The @Serializable annotation marks the class for compile-time codegen. @SerialName bridges snake_case to camelCase. The default value = null makes the field optional in the JSON.
With kotlinx.serialization
@Serializable + @SerialName
Compile-time codegen; no reflection at runtime.
data class User(...)
= One file, complete DTO
Default values are how you mark optional.
Unlike Java, Kotlin doesn't need a special "optional" type — a constructor parameter with a default value (= null, = emptyList()) can be omitted from the JSON without error. The combination of nullable type and default value is the idiomatic way to express "this field is sometimes absent": the JSON's absence maps to the default, the JSON's presence maps to the value. Both cases serialise back correctly.
Sealed classes for tagged unions.
A Kotlin sealed class declares a closed set of subtypes. Combined with kotlinx.serialization's polymorphic support, you get exhaustive when-expressions over tagged unions for free: sealed class Event { @Serializable data class Click(val x: Int, val y: Int) : Event() ... }. The serializer dispatches on a "type" discriminator (configurable) and emits the right subclass. The compiler then knows that the when over all subtypes is exhaustive — no default branch needed.
The copy method.
Data classes get a copy method that creates a new instance with the given fields replaced. The Kotlin equivalent of C#'s with-expression and Rust's struct update syntax. user.copy(name = "R") returns a new User with everything from user except name. The same shape that makes immutable records ergonomic; the same single method that pays for the immutability.