Value classes e inline classes

Intermedio
Kotlin
Kotlin
Actualizado: 04/05/2026

Diagrama: tutorial-kotlin-value-classes

Las value classes resuelven un problema clásico del diseno de tipos: distinguir conceptos que comparten la misma representación en memoria. En una API típica, muchos parámetros son String o Long, y el compilador acepta con gusto que se pase un correo donde se esperaba un teléfono. Una value class envuelve ese valor primitivo en un tipo propio con semántica clara sin introducir sobrecoste de memoria en la JVM.

La clave es que la JVM sigue tratando el valor como el tipo subyacente en la mayoria de situaciones. El compilador sustituye la instancia por su campo interno siempre que puede, lo que evita asignar objetos intermedios. Esto es lo que se conoce como inlining y es lo que da nombre al mecanismo en versiones anteriores (inline class), ahora sustituido por el término estable value class.

Declaración básica

Una value class se declara con la palabra clave value y se acompana de la anotación @JvmInline para activar la representación en línea en el bytecode.

@JvmInline
value class CorreoElectronico(val valor: String)

@JvmInline
value class IdentificadorUsuario(val valor: Long)

fun enviarBienvenida(id: IdentificadorUsuario, a: CorreoElectronico) {
    println("Enviando bienvenida al usuario ${id.valor} en ${a.valor}")
}

fun main() {
    val id = IdentificadorUsuario(42)
    val correo = CorreoElectronico("hola@kotlin.es")
    enviarBienvenida(id, correo)
}

La llamada solo admite un IdentificadorUsuario y un CorreoElectronico. Pasar cadenas o enteros directos produce un error de compilación, lo que elimina toda una clase de bugs de "parámetros invertidos".

Una value class debe tener exactamente una propiedad en el constructor primario y debe ser inmutable (val). No puede tener estado adicional en el cuerpo ni campos derivados con backing field.

Validación en el constructor

La clase puede incluir un bloque init para validar el valor envuelto. El resto del programa solo podrá crear instancias que cumplan la invariante.

@JvmInline
value class Porcentaje(val valor: Double) {
    init {
        require(valor in 0.0..100.0) { "Porcentaje fuera de rango: $valor" }
    }

    fun aDecimal(): Double = valor / 100.0
}

fun main() {
    val iva = Porcentaje(21.0)
    println("IVA en decimal: ${iva.aDecimal()}")
}

Cualquier intento de construir Porcentaje(-5.0) lanza IllegalArgumentException. La validación queda centralizada en el tipo y no hace falta repetirla en cada función que reciba un porcentaje.

Métodos y propiedades calculadas

Una value class puede exponer métodos, propiedades con getter y extensiones. Lo que no puede tener es propiedades con backing field adicionales ni bloques init que almacenen estado.

@JvmInline
value class Dinero(val centimos: Long) {
    val euros: Double get() = centimos / 100.0

    operator fun plus(otro: Dinero): Dinero = Dinero(centimos + otro.centimos)
    operator fun times(factor: Int): Dinero = Dinero(centimos * factor)

    override fun toString(): String = "%.2f EUR".format(euros)
}

fun main() {
    val precio = Dinero(1299)
    val total = precio * 3 + Dinero(500)
    println(total)
}

La sobrecarga de operadores y la propiedad euros permiten manipular cantidades con una semántica rica sin perder la garantía de tipo. La representación en el bytecode sigue siendo un simple long cuando el compilador puede evitar el boxing.

Por que la anotación @JvmInline

El JVM no tiene soporte nativo para tipos de valor al estilo de estructuras. Kotlin lo simula con una transformación del código: cuando es seguro, sustituye cada uso de la value class por el tipo envuelto. La anotación @JvmInline es obligatoria para activar este tratamiento y recordarle al lector que la clase es plana en tiempo de ejecución.

Cuando no se puede inlinear (por ejemplo al guardar la instancia en una lista de Any, al pasarla como tipo genérico o al serializarla por reflexion), el compilador introduce un objeto envoltorio. En esos casos se habla de boxing. Comprender cuando ocurre ayuda a diagnosticar comportamientos inesperados en código crucial para rendimiento.

En uso directo (parámetro de función, variable local, retorno) el bytecode trabaja con el tipo subyacente y no se asigna objeto adicional. En uso indirecto (la instancia viaja como Any, como argumento genérico T, dentro de una List<Porcentaje> o se serializa por reflexión) el compilador asigna un objeto envoltorio que conserva el tipo en runtime. Conocer dónde aparece el boxing es clave en rutas calientes y, ante la duda, conviene medir con el profiler de IntelliJ IDEA antes de suponer que no hay coste.

Ejemplo: modelado de tipos de dominio

Uno de los usos mas extendidos en backend es tipar identificadores y cadenas con significado.

@JvmInline
value class IdPedido(val valor: String)

@JvmInline
value class IdCliente(val valor: String)

@JvmInline
value class CodigoCupon(val valor: String)

data class Pedido(val id: IdPedido, val cliente: IdCliente, val cupon: CodigoCupon? = null)

fun buscarPedidosDeCliente(id: IdCliente): List<Pedido> = TODO()

fun main() {
    val cliente = IdCliente("u-100")
    val pedido = Pedido(IdPedido("p-500"), cliente, CodigoCupon("NAVIDAD"))
    println(pedido)
}

Sin value classes, id, cliente y cupon serian todos String. Las firmas de funciones y los modelos quedan mucho mas expresivos con una capa de tipos finos. Además, el IDE puede ofrecer mejor autocompletado y ayuda para refactorizar.

Limitaciones importantes

Conocer las restricciones evita sorpresas al adoptar este mecanismo.

  • 1. Una sola propiedad en el constructor primario: si necesitas mas campos, recurre a una data class.
  • 2. No admiten herencia: son clases final implícitas. Si requieres un marcador común, usa una interfaz.
  • 3. Pueden implementar interfaces: pero los métodos invocados via interfaz siempre usan el objeto envoltorio (boxing).
  • 4. Igualdad y hashCode son los del valor envuelto: dos instancias con el mismo interior son equals.
  • 5. No compatibles con open, abstract ni con herencia de clase: el disenno se orienta a tipos planos.

Para casos que no encajan, la data class sigue siendo la herramienta adecuada. Las value classes no reemplazan a los records ni a las entidades con múltiples campos, sino que rellenan el hueco entre un alias typealias y una clase completa.

typealias frente a value class

Un typealias solo da un nuevo nombre al mismo tipo. El compilador sigue tratandolos como intercambiables.

typealias Telefono = String

fun registrar(t: Telefono) = println(t)

fun main() {
    registrar("600000000")
    registrar("cualquier texto")
}

El compilador acepta ambas llamadas. La value class, en cambio, obliga a envolver el valor con un constructor explícito. Para dominios donde confundir dos cadenas puede corromper datos o cruzar cables entre servicios, la value class es la opción correcta.

Ejemplo con kotlinx.serialization

Las value classes se integran con kotlinx.serialization y viajan como el valor envuelto en el JSON resultante, lo que las hace transparentes para los consumidores externos.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
@JvmInline
value class Cuenta(val iban: String)

@Serializable
data class Transferencia(val origen: Cuenta, val destino: Cuenta, val importeCentimos: Long)

fun main() {
    val t = Transferencia(
        origen = Cuenta("ES7600750100010600123456"),
        destino = Cuenta("ES7621000418400100050332"),
        importeCentimos = 150000
    )
    println(Json.encodeToString(t))
}

El JSON resultante muestra los campos como cadenas normales. El código Kotlin conserva los tipos estrictos, mientras que la serialización sigue produciendo un payload idiomatico para otros lenguajes y servicios.

Criterios prácticos de adopción

La experiencia acumulada en equipos grandes sugiere un uso progresivo y dirigido por dolor.

  • 1. Empieza por los ids: IdUsuario, IdPedido, IdPago. El cambio es mecánico y el retorno es inmediato.
  • 2. Sigue con unidades físicas y monetarias: centimos, milisegundos, bytes. Tipos que se mezclan con frecuencia en cabeceras de métodos.
  • 3. Considera dominios con invariantes: percentajes, rangos o identificadores con formato específico.
  • 4. No tipifiques todo: convertir cada String en una value class genera ruido. Selecciona los que aportan claridad.

Las value classes se han convertido en un estandar en codebases maduras de Kotlin por su combinación de tipado fuerte y cero coste aparente. Encajan bien en arquitecturas hexagonales, donde el dominio se beneficia de dejar fuera cualquier ambiguedad sobre que representan los parámetros.

Interoperabilidad con Java

En un proyecto mixto Kotlin y Java, las value classes se exponen como el tipo envuelto en la mayoria de firmas. Un método Kotlin que recibe Porcentaje aparece desde Java como uno que recibe double. Es conveniente tenerlo presente cuando se publican APIs hacia código Java ya existente, porque el control de tipos se pierde en esa frontera.

Cuando se quiere forzar que la firma exponga el tipo envoltorio (por ejemplo en APIs públicas consumidas desde Java que requieran validación), se puede marcar la función con @JvmName alternativo o usar una data class de una sola propiedad. En la practica, la gran mayoria de value classes son internas del dominio Kotlin y la frontera Java no se toca directamente.

Ejemplo completo: validación de código IBAN

Un escenario de negocio que combina varias técnicas es modelar un IBAN con value class, sus validaciones y extensiones propias para formateo.

@JvmInline
value class Iban(val valor: String) {
    init {
        val limpio = valor.replace(" ", "").uppercase()
        require(limpio.matches(Regex("^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$"))) {
            "IBAN con formato invalido"
        }
    }

    fun formatear(): String {
        val limpio = valor.replace(" ", "").uppercase()
        return limpio.chunked(4).joinToString(" ")
    }

    val pais: String get() = valor.substring(0, 2)
}

fun main() {
    val iban = Iban("ES7600750100010600123456")
    println(iban.formatear())
    println("Pais: ${iban.pais}")
}

La clase garantiza que cualquier instancia es un IBAN con formato válido. Las funciones de formateo y extracción de país se apoyan en ese invariante sin tener que repetir la validación. Este enfoque concentra el conocimiento del formato en un solo lugar.

Las value classes convierten el "válido o no" en una cuestion resuelta en el momento de la creación. Las funciones consumidoras no necesitan preguntarlo otra vez.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Kotlin es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Kotlin

Explora más contenido relacionado con Kotlin y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Utilizar value classes con JvmInline para envolver tipos primitivos sin sobrecoste, tipar dominios fuertemente, garantizar invariantes y evitar confusiones de parámetros del mismo tipo.