Neste tutorial, você vai aprender tudo sobre Data Classes em Kotlin — um recurso que elimina toneladas de código boilerplate que você precisaria escrever em Java. Data classes geram automaticamente métodos como equals(), hashCode(), toString(), copy() e funções componentN() para destructuring. Ao final deste guia, você saberá quando e como usá-las de forma eficiente no seu código.

O Problema que Data Classes Resolvem

Em Java, para representar uma simples classe de dados como um Usuario, você precisaria escrever manualmente: construtor, getters, setters, equals(), hashCode() e toString(). São facilmente mais de 50 linhas de código para algo conceitualmente simples.

Em Kotlin, uma data class resolve tudo isso em uma única linha:

data class Usuario(val nome: String, val email: String, val idade: Int)

Essa única linha gera automaticamente todos os métodos mencionados acima. Vamos explorar cada um deles em detalhes.

Sintaxe e Regras Básicas

Para declarar uma data class, basta usar a palavra-chave data antes de class. Existem algumas regras que o compilador exige:

// Data class válida
data class Produto(val nome: String, val preco: Double, val categoria: String)

// O construtor primário PRECISA ter pelo menos um parâmetro
// Todos os parâmetros do construtor primário devem ser val ou var

Regras obrigatórias para data classes:

  • O construtor primário precisa ter pelo menos um parâmetro
  • Cada parâmetro do construtor primário deve ser marcado como val ou var
  • Data classes não podem ser abstract, open, sealed ou inner

Métodos Gerados Automaticamente

toString()

O toString() gerado é extremamente útil para debugging e logging:

data class Livro(val titulo: String, val autor: String, val paginas: Int)

val livro = Livro("O Senhor dos Anéis", "Tolkien", 1200)
println(livro)
// Livro(titulo=O Senhor dos Anéis, autor=Tolkien, paginas=1200)

Compare com uma classe regular, que imprimiria algo como Livro@1a2b3c4d. A data class mostra todos os valores de forma legível.

equals() e hashCode()

Data classes comparam por valor, não por referência. Dois objetos com os mesmos dados são considerados iguais:

data class Ponto(val x: Int, val y: Int)

val p1 = Ponto(3, 7)
val p2 = Ponto(3, 7)
val p3 = Ponto(1, 5)

println(p1 == p2)  // true — mesmos valores
println(p1 == p3)  // false — valores diferentes
println(p1 === p2) // false — referências diferentes (são objetos distintos)

// hashCode também é consistente
val conjunto = hashSetOf(p1, p2, p3)
println(conjunto.size) // 2 — p1 e p2 são "iguais"

Isso é fundamental para usar data classes como chaves em Map, em Set, ou em qualquer situação que dependa de igualdade estrutural.

copy()

O método copy() cria uma cópia do objeto, permitindo alterar apenas algumas propriedades. Isso é essencial para trabalhar com objetos imutáveis (declarados com val):

data class Configuracao(
    val tema: String,
    val idioma: String,
    val notificacoes: Boolean,
    val fontSize: Int
)

val configOriginal = Configuracao("escuro", "pt-BR", true, 16)

// Alterar apenas o tema e o tamanho da fonte
val configNova = configOriginal.copy(tema = "claro", fontSize = 18)

println(configOriginal)
// Configuracao(tema=escuro, idioma=pt-BR, notificacoes=true, fontSize=16)

println(configNova)
// Configuracao(tema=claro, idioma=pt-BR, notificacoes=true, fontSize=18)

O copy() é a base para o padrão de imutabilidade em Kotlin. Em vez de modificar um objeto existente, você cria uma nova versão com as alterações desejadas.

componentN() e Destructuring

Data classes geram funções component1(), component2(), etc., correspondendo à ordem dos parâmetros no construtor. Isso habilita o destructuring:

data class Endereco(val rua: String, val numero: Int, val cidade: String)

val endereco = Endereco("Av. Paulista", 1000, "São Paulo")

// Destructuring declaration
val (rua, numero, cidade) = endereco
println("$rua, $numero$cidade")
// Av. Paulista, 1000 — São Paulo

// Funciona em loops também
val enderecos = listOf(
    Endereco("Rua A", 10, "SP"),
    Endereco("Rua B", 20, "RJ"),
    Endereco("Rua C", 30, "BH")
)

for ((r, n, c) in enderecos) {
    println("$r, $n$c")
}

// E com lambdas!
enderecos.forEach { (r, _, c) -> // _ ignora o número
    println("$r em $c")
}

Propriedades Fora do Construtor Primário

Propriedades declaradas dentro do corpo da data class não participam dos métodos gerados:

data class Aluno(val nome: String, val matricula: Int) {
    var notaFinal: Double = 0.0  // NÃO participa de equals, hashCode, toString, copy
}

val a1 = Aluno("Maria", 12345)
a1.notaFinal = 9.5

val a2 = Aluno("Maria", 12345)
a2.notaFinal = 7.0

println(a1 == a2) // true! — notaFinal NÃO é considerada
println(a1)       // Aluno(nome=Maria, matricula=12345) — sem notaFinal

Isso é intencional: apenas as propriedades do construtor primário definem a “identidade” do objeto. Use essa separação estrategicamente.

Data Class vs Classe Regular

Quando usar data class e quando usar uma classe regular? Aqui está um comparativo claro:

// USE Data Class quando:
// - O objetivo principal é armazenar dados
// - Você precisa de equals/hashCode baseado em valores
// - Quer destructuring ou copy()

data class PedidoItem(val produtoId: Long, val quantidade: Int, val precoUnitario: Double)

// USE Classe Regular quando:
// - A classe tem comportamento complexo
// - Identidade é baseada em referência, não em valores
// - Precisa de herança (data classes não podem ser open)

class CarrinhoDeCompras {
    private val itens = mutableListOf<PedidoItem>()

    fun adicionar(item: PedidoItem) { itens.add(item) }
    fun total(): Double = itens.sumOf { it.precoUnitario * it.quantidade }
    fun limpar() { itens.clear() }
}

Limitações de Data Classes

Existem limitações importantes que você deve conhecer:

// 1. Data classes NÃO podem ser open (não permitem herança direta)
// data class Base(val x: Int) — não pode ser herdada
// class Filha(x: Int, val y: Int) : Base(x) — ERRO!

// 2. Solução: use interfaces ou sealed classes
interface Forma {
    fun area(): Double
}

data class Circulo(val raio: Double) : Forma {
    override fun area() = Math.PI * raio * raio
}

data class Retangulo(val largura: Double, val altura: Double) : Forma {
    override fun area() = largura * altura
}

// 3. Data classes podem implementar interfaces normalmente
// 4. Data classes PODEM ser combinadas com sealed classes

Exemplo Prático Completo

Vamos criar um sistema de gerenciamento de tarefas usando data classes:

enum class Prioridade { BAIXA, MEDIA, ALTA, URGENTE }
enum class Status { PENDENTE, EM_ANDAMENTO, CONCLUIDA, CANCELADA }

data class Tarefa(
    val id: Long,
    val titulo: String,
    val descricao: String,
    val prioridade: Prioridade,
    val status: Status = Status.PENDENTE,
    val tags: List<String> = emptyList()
)

fun main() {
    val tarefas = listOf(
        Tarefa(1, "Configurar CI/CD", "Pipeline do GitHub Actions", Prioridade.ALTA, tags = listOf("devops")),
        Tarefa(2, "Corrigir bug login", "Erro 401 no OAuth", Prioridade.URGENTE, Status.EM_ANDAMENTO, listOf("bug", "auth")),
        Tarefa(3, "Atualizar README", "Documentar nova API", Prioridade.BAIXA, tags = listOf("docs")),
        Tarefa(4, "Refatorar módulo X", "Aplicar SOLID", Prioridade.MEDIA, tags = listOf("refactor"))
    )

    // Filtrar e transformar usando destructuring
    val urgentes = tarefas
        .filter { it.prioridade == Prioridade.URGENTE || it.prioridade == Prioridade.ALTA }
        .sortedBy { it.prioridade }

    println("=== Tarefas Prioritárias ===")
    urgentes.forEach { (id, titulo, _, prioridade, status) ->
        println("#$id$titulo [$prioridade] ($status)")
    }

    // Usar copy para atualizar status
    val tarefaAtualizada = tarefas[0].copy(status = Status.CONCLUIDA)
    println("\nAtualizada: $tarefaAtualizada")

    // Agrupar por status
    val porStatus = tarefas.groupBy { it.status }
    porStatus.forEach { (status, lista) ->
        println("\n$status: ${lista.map { it.titulo }}")
    }
}

Erros Comuns

  1. Colocar propriedades mutáveis no construtor primário: usar var no construtor de data classes pode gerar bugs, pois equals() e hashCode() dependem dos valores. Se o valor mudar após inserir o objeto em um Set ou como chave de um Map, você pode perder o acesso a ele. Prefira val.

  2. Esperar que propriedades do corpo participem de equals(): como vimos, propriedades declaradas dentro do corpo da classe são ignoradas pelos métodos gerados. Isso é confuso para iniciantes e pode gerar comparações inesperadas.

  3. Tentar herdar de uma data class: data classes não podem ser open. Se você precisa de hierarquias, use sealed classes combinadas com data classes ou use interfaces.

  4. Abusar de data classes para tudo: nem toda classe deve ser uma data class. Classes com comportamento complexo, efeitos colaterais no construtor ou identidade baseada em referência devem ser classes regulares.

  5. Esquecer que copy() é shallow: o copy() cria uma cópia rasa. Se a data class contém listas ou outros objetos mutáveis, a cópia compartilhará as mesmas referências internas.

Conclusão e Próximos Passos

Data classes são um dos recursos mais práticos de Kotlin, eliminando centenas de linhas de boilerplate e tornando o código mais seguro e expressivo. Você aprendeu a sintaxe, os métodos gerados automaticamente, destructuring, o método copy(), as diferenças entre data classes e classes regulares, e as limitações que precisa conhecer.

Para continuar aprendendo, explore estes tópicos relacionados:

  • Sealed Classes para criar hierarquias de tipos com data classes
  • Extension Functions para adicionar funcionalidades às suas data classes
  • Lambdas para trabalhar com coleções de data classes de forma funcional
  • Generics para criar data classes parametrizadas

Comece a usar data classes sempre que precisar representar dados. Elas são a ferramenta certa para DTOs, modelos de domínio, respostas de API e qualquer estrutura cujo propósito principal seja carregar informação.