A kotlinx.serialization é a biblioteca oficial do Kotlin para serialização e desserialização de dados, e vai muito além do básico @Serializable em data classes. Quando você trabalha com APIs que retornam tipos variados, precisa lidar com formatos legados ou quer otimizar payloads, os recursos avançados da biblioteca fazem toda a diferença.

Neste artigo, vamos explorar polimorfismo com sealed classes, serializers customizados, serialização contextual e padrões avançados que resolvem problemas reais em projetos Kotlin. Se você já domina o básico, este guia vai elevar seu nível.

Polimorfismo com sealed classes

O cenário mais comum de serialização polimórfica acontece quando uma API retorna diferentes tipos de objetos em uma mesma lista ou campo. Considere um sistema de notificações:

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
sealed class Notificacao {
    abstract val id: String
    abstract val timestamp: Long

    @Serializable
    @SerialName("email")
    data class Email(
        override val id: String,
        override val timestamp: Long,
        val destinatario: String,
        val assunto: String,
        val corpo: String
    ) : Notificacao()

    @Serializable
    @SerialName("push")
    data class Push(
        override val id: String,
        override val timestamp: Long,
        val titulo: String,
        val mensagem: String,
        val icone: String? = null
    ) : Notificacao()

    @Serializable
    @SerialName("sms")
    data class Sms(
        override val id: String,
        override val timestamp: Long,
        val telefone: String,
        val texto: String
    ) : Notificacao()
}

Com sealed classes, o compilador conhece todos os subtipos em tempo de compilação, e a serialização funciona automaticamente:

val json = Json { prettyPrint = true }

val notificacoes: List<Notificacao> = listOf(
    Notificacao.Email(
        id = "1",
        timestamp = System.currentTimeMillis(),
        destinatario = "dev@kotlin.dev.br",
        assunto = "Deploy concluído",
        corpo = "O deploy da v2.5 foi finalizado com sucesso."
    ),
    Notificacao.Push(
        id = "2",
        timestamp = System.currentTimeMillis(),
        titulo = "Nova versão",
        mensagem = "Kotlin 2.4.0 disponível!"
    )
)

val texto = json.encodeToString(notificacoes)
println(texto)

O JSON gerado inclui automaticamente o campo discriminador type:

[
  {
    "type": "email",
    "id": "1",
    "timestamp": 1745856000000,
    "destinatario": "dev@kotlin.dev.br",
    "assunto": "Deploy concluído",
    "corpo": "O deploy da v2.5 foi finalizado com sucesso."
  },
  {
    "type": "push",
    "id": "2",
    "timestamp": 1745856000000,
    "titulo": "Nova versão",
    "mensagem": "Kotlin 2.4.0 disponível!"
  }
]

O @SerialName define o valor do discriminador — sem ele, o nome completo da classe é usado, o que raramente é desejável em APIs públicas.

Serializers customizados com KSerializer

Quando o formato do JSON não corresponde à estrutura da sua classe Kotlin, você precisa de um serializer customizado. Um caso clássico: APIs que representam datas como strings em formato brasileiro:

import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter

object LocalDateBrSerializer : KSerializer<LocalDate> {
    private val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")

    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("LocalDateBr", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: LocalDate) {
        encoder.encodeString(value.format(formatter))
    }

    override fun deserialize(decoder: Decoder): LocalDate {
        return LocalDate.parse(decoder.decodeString(), formatter)
    }
}

@Serializable
data class Evento(
    val nome: String,
    @Serializable(with = LocalDateBrSerializer::class)
    val data: LocalDate,
    val local: String
)

Agora, ao serializar um Evento, a data aparece no formato 28/04/2026 em vez do ISO padrão. Esse padrão é especialmente útil para integrar com APIs legadas ou serviços que usam formatos não padronizados.

JsonContentPolymorphicSerializer para APIs sem discriminador

Nem toda API inclui um campo type para indicar o tipo do objeto. Muitas APIs retornam objetos onde o tipo é inferido pela presença ou ausência de campos específicos. O JsonContentPolymorphicSerializer resolve isso:

@Serializable(with = PagamentoSerializer::class)
sealed class Pagamento {
    abstract val valor: Double

    @Serializable
    data class CartaoCredito(
        override val valor: Double,
        val bandeira: String,
        val ultimos4Digitos: String,
        val parcelas: Int = 1
    ) : Pagamento()

    @Serializable
    data class Pix(
        override val valor: Double,
        val chave: String,
        val txId: String
    ) : Pagamento()

    @Serializable
    data class Boleto(
        override val valor: Double,
        val codigoBarras: String,
        val vencimento: String
    ) : Pagamento()
}

object PagamentoSerializer : JsonContentPolymorphicSerializer<Pagamento>(
    Pagamento::class
) {
    override fun selectDeserializer(
        element: JsonElement
    ): DeserializationStrategy<Pagamento> {
        val jsonObject = element.jsonObject
        return when {
            "bandeira" in jsonObject -> Pagamento.CartaoCredito.serializer()
            "chave" in jsonObject -> Pagamento.Pix.serializer()
            "codigoBarras" in jsonObject -> Pagamento.Boleto.serializer()
            else -> throw SerializationException(
                "Tipo de pagamento desconhecido: ${jsonObject.keys}"
            )
        }
    }
}

O serializer examina o conteúdo JSON para decidir qual tipo instanciar. Essa abordagem funciona perfeitamente com APIs de terceiros onde você não tem controle sobre o formato de resposta.

Serialização contextual

A serialização contextual permite registrar serializers em runtime, separando a lógica de serialização da definição da classe. Isso é útil quando uma mesma classe precisa ser serializada de formas diferentes dependendo do contexto:

import kotlinx.serialization.modules.*

@Serializable
data class Relatorio(
    val titulo: String,
    @Contextual
    val geradoEm: LocalDate,
    val dados: Map<String, Int>
)

// Módulo para formato brasileiro
val moduloBrasileiro = SerializersModule {
    contextual(LocalDateBrSerializer)
}

// Módulo para formato ISO
val moduloISO = SerializersModule {
    contextual(LocalDateISOSerializer)
}

// JSON configurado para o contexto brasileiro
val jsonBr = Json {
    serializersModule = moduloBrasileiro
    prettyPrint = true
}

// JSON configurado para APIs internacionais
val jsonISO = Json {
    serializersModule = moduloISO
}

fun main() {
    val relatorio = Relatorio(
        titulo = "Vendas Q1",
        geradoEm = LocalDate.of(2026, 4, 28),
        dados = mapOf("janeiro" to 1500, "fevereiro" to 2300, "marco" to 1800)
    )

    // Mesmo objeto, formatos diferentes
    println(jsonBr.encodeToString(relatorio))
    // geradoEm: "28/04/2026"

    println(jsonISO.encodeToString(relatorio))
    // geradoEm: "2026-04-28"
}

Esse padrão é poderoso em aplicações que servem múltiplos clientes — um frontend brasileiro pode receber datas em formato local, enquanto uma API parceira recebe no formato ISO.

Polimorfismo aberto com SerializersModule

Quando você não pode usar sealed classes (por exemplo, quando subtipos são definidos em módulos diferentes), o polimorfismo aberto com registro explícito é a solução:

@Serializable
abstract class Componente {
    abstract val id: String
}

// Módulo A
@Serializable
@SerialName("botao")
data class Botao(
    override val id: String,
    val texto: String,
    val acao: String
) : Componente()

// Módulo B
@Serializable
@SerialName("campo_texto")
data class CampoTexto(
    override val id: String,
    val placeholder: String,
    val maxLength: Int = 255
) : Componente()

// Registro de todos os subtipos
val componentesModule = SerializersModule {
    polymorphic(Componente::class) {
        subclass(Botao::class, Botao.serializer())
        subclass(CampoTexto::class, CampoTexto.serializer())
    }
}

val json = Json {
    serializersModule = componentesModule
    classDiscriminator = "componente_tipo"
}

Note o uso de classDiscriminator para customizar o nome do campo discriminador — útil quando o nome padrão type conflita com campos do seu modelo.

Tratamento de campos desconhecidos e defaults

Em APIs reais, é comum receber campos que não existem no seu modelo ou lidar com campos opcionais. A configuração correta do Json evita crashes em produção:

val jsonResilient = Json {
    // Ignora campos no JSON que não existem na classe
    ignoreUnknownKeys = true
    // Usa valores default para campos ausentes no JSON
    encodeDefaults = false
    // Permite valores nulos para campos não-nullable com default
    coerceInputValues = true
    // Aceita JSON malformado (aspas simples, trailing commas)
    isLenient = true
}

@Serializable
data class UsuarioAPI(
    val id: Long,
    val nome: String,
    val email: String,
    val premium: Boolean = false,  // Default se ausente
    val avatar: String? = null     // Nullable com default
)

Com ignoreUnknownKeys = true, o serializer não lança exceção quando a API adiciona novos campos que seu modelo ainda não contempla — essencial para manter compatibilidade com APIs que evoluem sem versionamento.

Performance e boas práticas

Algumas dicas para otimizar a serialização em projetos de produção:

// Crie uma única instância de Json e reutilize
val appJson = Json {
    ignoreUnknownKeys = true
    encodeDefaults = false
    serializersModule = SerializersModule {
        contextual(LocalDateBrSerializer)
        polymorphic(Notificacao::class) {
            subclass(Notificacao.Email::class)
            subclass(Notificacao.Push::class)
            subclass(Notificacao.Sms::class)
        }
    }
}

// Evite criar instâncias de Json dentro de loops ou funções chamadas frequentemente
// A instância de Json contém cache interno de serializers

Se você usa Ktor para APIs, a kotlinx.serialization é a escolha nativa e se integra perfeitamente com o content negotiation. Para quem trabalha com Spring Boot e Kotlin, o suporte também funciona bem como alternativa ao Jackson.

Conclusão

A kotlinx.serialization vai muito além do básico @Serializable. Com polimorfismo — via sealed classes ou registro aberto — serializers customizados e serialização contextual, você consegue modelar praticamente qualquer formato de dados que encontrar em produção.

As técnicas deste artigo são especialmente valiosas quando você integra com APIs externas que fogem dos padrões, trabalha com múltiplos formatos de saída ou precisa de performance otimizada. Se você usa coroutines e Flow para consumir dados reativamente, a serialização eficiente é peça fundamental do pipeline.

Outra linguagem que se destaca em serialização type-safe é Rust com serde — vale comparar as abordagens se você trabalha com múltiplas linguagens. Para quem vem de Python, a kotlinx.serialization oferece segurança de tipos em tempo de compilação que Pydantic e dataclasses só validam em runtime.