JSON to Kotlin Data Classes: A Practical Guide for Android Developers
Turn any JSON payload into clean Kotlin data classes — nullable types, nested classes, and @Serializable, Gson, or Moshi annotations done right.
JSON to Kotlin Data Classes: A Practical Guide for Android Developers
Every Android developer has done it at least once: a REST endpoint returns 30 fields of snake_case JSON, and you sit there typing out val createdAt: String? line by line, hoping you spelled every key right and guessed every nullability correctly. I have lost whole afternoons to this, and the worst part is that the compiler can't help you — a typo in a @SerializedName only shows up at runtime when the field comes back null.
There's a better way. Paste the JSON, get a complete set of Kotlin data class declarations, and move on. This post walks through how that works, why the generated types look the way they do, and the handful of decisions a generator makes for you that are easy to get wrong by hand.
Why a data class instead of a Map
The lazy option is val data: Map<String, Any?> = parse(body), then reaching in with data["id"] as String. It compiles, it runs, and it throws ClassCastException in production the first time the API returns a number where you expected a string. You've also thrown away every bit of help the compiler could give you: rename a key, and nothing tells you until a user hits the broken screen.
A data class flips that around. Each field has a real type, nullability is explicit, and the IDE autocompletes .id for you. You also get equals, hashCode, toString, copy(), and destructuring generated for free — none of the Java POJO boilerplate. The concrete payoff: the converter auto-generates the entire class from one sample response, so a 25-field DTO that used to be a 25-line hand-typed model becomes a paste-and-drop operation, with the per-field types already inferred.
A worked example
Here's a real-shaped payload — a user object with a nested address and an inconsistent array:
{
"id": 4823001928374651,
"login": "li-lei",
"site_admin": false,
"created_at": "2026-06-13T09:00:00Z",
"address": { "city": "Beijing", "zip": "100000" },
"repos": [
{ "name": "toolora", "stars": 12 },
{ "name": "kt-utils", "stars": 0, "fork": true }
]
}
Run that through the JSON to Kotlin data class converter with kotlinx.serialization selected, and you get:
@Serializable
data class User(
val id: Long,
val login: String,
@SerialName("site_admin") val siteAdmin: Boolean,
@SerialName("created_at") val createdAt: String,
val address: UserAddress,
val repos: List<UserRepos>,
)
@Serializable
data class UserAddress(
val city: String,
val zip: String,
)
@Serializable
data class UserRepos(
val name: String,
val stars: Int,
val fork: Boolean? = null,
)
Look at what got decided automatically. The id is 4823001928374651, which overflows a 32-bit Int, so it became Long — exactly what you want for snowflake IDs and millisecond timestamps. site_admin and created_at were camelCased into valid Kotlin identifiers and given @SerialName so they still bind to the wire keys. The nested address object lifted into its own UserAddress class instead of being inlined. And fork — present in the second repo, missing in the first — became Boolean? because the array fold noticed the inconsistency. That last one is the inference no human reliably gets right on a list of fifty objects.
Nullable types: the rule that actually matters
Kotlin's whole pitch is that null is part of the type system, so the generator has to be careful about when a property gets a ?. There are two triggers.
First, a value seen as JSON null makes its property nullable. Second — and this is the one that saves you from production crashes — a key that is absent from some elements of an object array becomes nullable. If you paste [{"id":1},{"id":1,"name":"x"}], the name key is missing from the first element, so it types as String?. The serializer would otherwise crash trying to decode a non-null field that isn't there.
There's a subtlety worth internalizing: a single pasted object has no missing-key signal, so all its fields come back non-null unless their value is literally null. If you want to probe which fields are really optional, log a handful of real responses and paste them as a JSON array. The fold across multiple samples is where optionality reveals itself. One field that only ever appears as null types as Any?, since there's genuinely no type signal — you'll widen that to the right T? by hand.
Picking @Serializable, Gson, or Moshi
The annotation style is the one choice the generator can't make for you, because it depends on what your build already pulls in. The field inference is identical across all of them; only the annotation token and import change.
- kotlinx.serialization stamps
@Serializableon every class and@SerialName("wire_key")only where the Kotlin name diverged. It's the official multiplatform option and works on Kotlin/JS and Native too. Remember it needs both theorg.jetbrains.kotlin.plugin.serializationGradle plugin and thekotlinx-serialization-jsonruntime — the annotation alone won't compile. - Moshi uses
@Json(name = ...). Pick it if your Android codebase already standardized on Moshi with Retrofit; its codegen is reflection-free and fast. - Gson uses
@SerializedName. The right call for older Android projects already wired up with Gson. - None gives you the plain class — useful if you bind keys with a global naming strategy.
One trap to avoid: don't delete an @SerialName / @Json / @SerializedName annotation and expect binding to still work. That annotation is precisely what maps site_admin (wire) to siteAdmin (property). Remove it and the serializer hunts for a siteAdmin key that doesn't exist, leaving the field null or failing outright.
Defaults, nested classes, and money
Two more things the generator handles that are tedious by hand. Turn on default values and every nullable property gets = null while every List gets = emptyList(), so the class is constructable without supplying optionals — handy for test fixtures and partial updates. With kotlinx.serialization, defaults also let the decoder skip a missing key instead of throwing, which is the standard pattern for forward-compatible models that survive a new optional field appearing in the payload.
Nested objects always lift into named classes (User.address → UserAddress) rather than being flattened, so your types mirror the JSON structure one-to-one. And one manual fix to remember: any fractional number infers as Double, but Double is binary floating point and will misrepresent money. For currency totals, change those fields to BigDecimal after generating — JSON simply carries no signal that a value needs exact base-10 precision.
If you work across stacks, the same approach applies elsewhere — generating a TypeScript interface from JSON follows the identical paste-and-infer flow for your frontend models, so a shared API contract can be typed on both ends from one sample.
Everything runs in your browser tab — the JSON never touches a server, which matters when the payload is a real production response with customer data in it. Paste a sample, pick your serializer, and ship a typed model instead of a Map you cast your way through.
Made by Toolora · Updated 2026-06-13