JSON & kotlinx.serialization

Every response TrailLog's backend sends back arrives as raw JSON text over the wire — and every Trail object TrailApiService submits has to go the other way, from a Kotlin object back into JSON. The earlier Retrofit lesson glossed over exactly how that conversion happens; this lesson fills in that gap.

Three Common Choices for JSON Conversion on Android

There isn't one single official JSON library for Android — three have historically been in wide use, each with a different approach:

LibraryApproach
kotlinx.serializationJetBrains' own official Kotlin library; generates conversion code at compile time via a compiler plugin, no reflection involved
MoshiSquare's library; also Kotlin-friendly, with an optional code-generation mode similar in spirit to kotlinx.serialization
Gsonthe older, historically most widely-used choice; works by reading class structure via reflection at runtime

Why kotlinx.serialization for New Kotlin Projects

For a brand-new Kotlin project like TrailLog, kotlinx.serialization is the recommended default. Two reasons stand out. First, it's maintained by the Kotlin language team itself, so it tracks new Kotlin language features (like sealed classes and value classes) closely, rather than needing to catch up to them after the fact the way a third-party library might. Second, because it generates serialization code at compile time instead of relying on reflection, it integrates cleanly with Kotlin's own type system — null safety is respected exactly as declared, and you get compile-time errors for a type it can't serialize, instead of a runtime crash from a reflection-based library discovering the same problem only when the code actually runs.

Marking Trail as @Serializable

The @Serializable annotation is all that's needed to make a class eligible for automatic JSON conversion:

@Serializable
data class Trail(
    val id: Long,
    val name: String,
    val notes: String,
    val photoUri: String?,
    val latitude: Double,
    val longitude: Double,
    val distanceKm: Double,
    val date: Long
)

With that one annotation added, plugging Retrofit's kotlinx-serialization-converter artifact in as the converter factory (as shown in the previous two lessons) means every TrailApiService call automatically serializes and deserializes Trail objects — nothing manual to write for the conversion itself.

Custom Field Names With @SerialName

Backend JSON is often written in snake_case, while Kotlin convention is camelCase. Say TrailLog's API actually returns distance_km rather than distanceKm — @SerialName maps between the two explicitly, so the Kotlin property can keep its idiomatic name:

@Serializable
data class Trail(
    val id: Long,
    val name: String,
    val notes: String,
    val photoUri: String?,
    val latitude: Double,
    val longitude: Double,
    @SerialName("distance_km") val distanceKm: Double,
    val date: Long
)

The JSON key stays exactly as the backend sends it — "distance_km" — while every line of Kotlin code that touches this class keeps referring to trail.distanceKm, matching the rest of the app's naming style.

Handling a Nested JSON Object

Suppose TrailLog's backend groups coordinates under a nested location object instead of flat latitude/longitude fields:

{
  "id": 42,
  "name": "Bear Ridge Loop",
  "location": { "lat": 37.4275, "lng": -122.1697 },
  "distance_km": 5.2
}

A nested JSON object like this is modeled as its own small @Serializable data class, referenced as a property type on the outer class:

@Serializable
data class TrailLocation(
    @SerialName("lat") val latitude: Double,
    @SerialName("lng") val longitude: Double
)

@Serializable
data class TrailResponse(
    val id: Long,
    val name: String,
    val location: TrailLocation,
    @SerialName("distance_km") val distanceKm: Double
)

kotlinx.serialization walks into the nested object automatically because TrailLocation is itself marked @Serializable — no manual unwrapping code is needed anywhere. A mapping function elsewhere in the repository can then flatten TrailResponse into the app's own flat Trail data class, if that's the shape the rest of the app expects to work with.

Note: The data classes and JSON above are correct, real Kotlin and JSON — but this website's own project doesn't run a live Android Studio build, so no conversion actually executes on this page.
Up next: None of this covers what happens when a request fails outright — a timeout, a malformed response, no connection at all. Error Handling with Sealed Results builds a clean pattern for exactly that.