Rarezas de Kotlin: lo que el compilador deja pasar

Kotlin atrapa muchos errores antes de ejecutar, empezando por los null, pero no todos: a veces compila limpio un código que falla, y a veces hace algo distinto de lo que parece. Estas son quince rarezas de Kotlin, compiladas y ejecutadas con Kotlin 2.4.21 sobre Java 25, con la explicación de por qué pasan y la costumbre que las evita.

Kotlin tiene fama de lenguaje seguro: el compilador distingue lo que puede ser null de lo que no, obliga a cubrir todos los casos de un when y no hace conversiones de tipos a escondidas. Esa fama es merecida, pero no es absoluta. Hay rincones donde el código compila limpio y falla al ejecutar, y otros donde hace algo distinto de lo que parece. Muchos vienen de que Kotlin corre sobre la JVM y comparte sus reglas con Java. Cada fragmento de esta página se compiló con Kotlin 2.4.21 y se ejecutó de verdad en Java 25.

Los mensajes de kotlinc salen en inglés porque el compilador no tiene traducciones, y las excepciones también, porque las escribe la JVM. Los copiamos tal cual para que los puedas buscar.

127 === 127 es true, pero 128 === 128 es false

fun main() {
    val a: Int? = 127
    val b: Int? = 127
    println(a === b)

    val c: Int? = 128
    val d: Int? = 128
    println(c === d)
    println(c == d)
}
kotlincidentidad.kt:4:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
    println(a === b)
            ^^^^^^^
identidad.kt:8:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
    println(c === d)
            ^^^^^^^
salidatrue
false
true

Por qué: en Kotlin, == compara valores y === compara identidad: si las dos variables apuntan al mismo objeto. Un Int normal se guarda como un número primitivo de la JVM, pero un Int? tiene que poder ser null, así que se guarda en un objeto java.lang.Integer. La JVM tiene una caché con los Integer de -128 a 127 y los reutiliza: por eso dos 127 son el mismo objeto y dos 128 no. Es la misma rareza que en Java con Integer, y el compilador avisa: el mensaje dice «prohibited», pero es solo un warning, así que el programa compila y se ejecuta.

A la izquierda, dos variables Int? con 127 apuntan al mismo objeto Integer de la caché de la JVM y a === b da true. A la derecha, con 128 cada variable apunta a su propio objeto y a === b da false. Con == los dos casos dan true=== con Int?: 127 sale de una caché, 128 noval a: Int? = 127arefbrefInteger127a === b es trueval a: Int? = 128arefbref128128a === b es falseCon == (igualdad de valor) los dos lados dan true: usa siempre ==.

Con Int?, los números de -128 a 127 salen de una caché y comparten objeto; los demás no. Esquema: decodigo.com.
Cómo evitarlo: usa == para comparar números, siempre. === solo tiene sentido con objetos de tus clases, cuando de verdad quieres saber si son el mismo. Si vienes de Java, en las rarezas de Java está la versión original de este problema.

Una List de solo lectura que cambia

fun main() {
    val compras = mutableListOf("pan", "leche")
    val vista: List<String> = compras

    compras.add("huevos")
    println(vista)

    val fija = listOf("a", "b")
    (fija as MutableList<String>)[0] = "z"
    println(fija)
    fija.add("c")
}
salida con error[pan, leche, huevos]
[z, b]
Exception in thread "main" java.lang.UnsupportedOperationException
    at Solo_lecturaKt.main(solo_lectura.kt:11)

Por qué: List en Kotlin significa de solo lectura, no inmutable. La interfaz no tiene add, pero el objeto que hay detrás puede cambiar: vista es la misma lista que compras vista por una ventana sin botones de escribir. Y listOf devuelve, en la JVM, una lista de Java (Arrays.asList) que deja cambiar elementos con un cast pero no añadirlos: por eso el [0] = "z" funciona y el add lanza UnsupportedOperationException.

Cómo evitarlo: no hagas cast de List a MutableList, y si recibes una lista que tienes que guardar, haz tu propia copia con toList(). Para listas inmutables de verdad existe la biblioteca kotlinx.collections.immutable. Las colecciones están en la lección 3.

Dos arrays iguales que == dice que no lo son

data class Lectura(val sensor: String, val valores: IntArray)

fun main() {
    println(intArrayOf(1, 2) == intArrayOf(1, 2))
    println(intArrayOf(1, 2).contentEquals(intArrayOf(1, 2)))
    println(listOf(1, 2) == listOf(1, 2))
    println(Lectura("t1", intArrayOf(20, 21)) == Lectura("t1", intArrayOf(20, 21)))
}
kotlincarrays_igual.kt:4:30: warning: '==' on arrays compares only references. Replace '==' with 'contentEquals' to compare the arrays' contents or use '===' to remove the warning.
    println(intArrayOf(1, 2) == intArrayOf(1, 2))
                             ^^
salidafalse
true
true
false

Por qué: los arrays de Kotlin son arrays de la JVM, y su equals compara referencias. Las listas, en cambio, comparan elemento por elemento. Lo peor es la última línea: una data class genera su equals llamando al equals de cada propiedad, así que dos lecturas con los mismos datos salen distintas. El compilador avisa del == directo entre arrays, pero no de la data class.

Cómo evitarlo: en las data class, usa List en lugar de arrays; para comparar arrays sueltos, contentEquals.

Dos data classes iguales con datos distintos

data class Producto(val nombre: String, val precio: Double) {
    var existencias: Int = 0
}

fun main() {
    val a = Producto("Café", 189.0).apply { existencias = 3 }
    val b = Producto("Café", 189.0).apply { existencias = 50 }
    println(a == b)
    println(a)
}
salidatrue
Producto(nombre=Café, precio=189.0)

Por qué: todo lo que genera una data class (equals, hashCode, toString, copy y los componentN) usa solo las propiedades del constructor primario. existencias está en el cuerpo, así que no cuenta para comparar ni sale al imprimir. A veces se hace a propósito, pero casi siempre es un descuido.

Cómo evitarlo: pon en el constructor primario todo lo que forma parte del valor. Las data class están en la lección 5.

copy() no copia las listas de dentro

data class Carrito(val cliente: String, val articulos: MutableList<String>)

fun main() {
    val original = Carrito("Ana", mutableListOf("pan"))
    val copia = original.copy(cliente = "Eva")
    copia.articulos.add("leche")
    println(original)
    println(copia)
}
salidaCarrito(cliente=Ana, articulos=[pan, leche])
Carrito(cliente=Eva, articulos=[pan, leche])

Por qué: copy crea un objeto nuevo, pero copia las referencias de cada propiedad, no su contenido. Original y copia comparten la misma MutableList, y la leche aparece en el carrito de Ana.

Cómo evitarlo: en las data class, propiedades val con tipos de solo lectura (List, no MutableList). Para «añadir», crea otra lista: c.copy(articulos = c.articulos + "leche").

Un return dentro de forEach sale de la función

fun buscar(nombres: List<String>) {
    nombres.forEach {
        if (it == "Luis") return
        println("Reviso a $it")
    }
    println("Terminé de buscar")
}

fun main() {
    buscar(listOf("Ana", "Luis", "Eva"))
}
salidaReviso a Ana

Por qué: forEach es una función inline, y dentro de un lambda que se pasa a una función inline, return sale de la función que lo contiene (buscar), no del lambda. Ni Eva se revisa ni se imprime «Terminé de buscar». Para saltar solo ese elemento, como haría un continue, se usa un return con etiqueta:

fun buscar(nombres: List<String>) {
    nombres.forEach {
        if (it == "Luis") return@forEach
        println("Reviso a $it")
    }
    println("Terminé de buscar")
}

fun main() {
    buscar(listOf("Ana", "Luis", "Eva"))
}
salidaReviso a Ana
Reviso a Eva
Terminé de buscar
Cómo evitarlo: si necesitas break o continue, usa un for normal; en un forEach, return@forEach. Los lambdas e inline están en la lección 6.

«$nombre.length» no imprime la longitud

fun main() {
    val nombre = "Ana"
    val usuario = mapOf("nombre" to "Eva")
    println("Letras: $nombre.length")
    println("Letras: ${nombre.length}")
    println("Hola, $usuario[nombre]")
}
salidaLetras: Ana.length
Letras: 3
Hola, {nombre=Eva}[nombre]

Por qué: en una plantilla, $ toma solo un nombre: $nombre. Lo que sigue (.length, [nombre]) es texto normal. Para una expresión hacen falta las llaves: ${nombre.length}. Compila sin avisos porque es perfectamente válido.

Cómo evitarlo: usa ${...} en cuanto haya un punto, unos corchetes o una operación. Las plantillas están en la lección 1.

-5.absoluteValue da -5

import kotlin.math.absoluteValue

fun main() {
    println(-5.absoluteValue)
    println((-5).absoluteValue)
    println(-7 % 3)
    println((-7).mod(3))
    println(-7 / 2)
}
salida-5
5
-1
2
-3

Por qué: el - delante de un número no forma parte del literal: es un operador, y la llamada con punto tiene más prioridad. -5.absoluteValue se lee -(5.absoluteValue). Además, % es el resto y conserva el signo del dividendo (-1), como en Java; el módulo matemático, siempre positivo para un divisor positivo, es mod. Y la división entera corta hacia cero: -3, no -4.

Cómo evitarlo: paréntesis alrededor de los negativos antes de un punto, y mod en vez de % para cosas circulares (días de la semana, índices que dan la vuelta).

Un Int que se desborda sin avisar

fun main() {
    val segundosEnUnSiglo = 60 * 60 * 24 * 365 * 100
    println(segundosEnUnSiglo)
    val bien = 60L * 60 * 24 * 365 * 100
    println(bien)
    println(Int.MAX_VALUE + 1)
}
salida-1141367296
3153600000
-2147483648

Por qué: todos los literales son Int (32 bits, hasta 2,147,483,647), así que la multiplicación se hace en Int y se desborda en silencio: da la vuelta a los negativos. Que el resultado se guarde luego en algo más grande no ayuda, porque el daño ya está hecho. Llama la atención que kotlinc 2.4.21 compiló esto sin ningún aviso, aunque todas las cifras son constantes.

Cómo evitarlo: si una cuenta puede pasar de unos dos mil millones, empieza con un Long (60L). Para detectar el desbordamiento, Math.multiplyExact lanza una excepción en lugar de dar la vuelta.

-0.0 == 0.0 depende del tipo de la variable

fun main() {
    println(0.1 + 0.2 == 0.3)
    val a: Double = -0.0
    val b: Any = -0.0
    println(a == 0.0)
    println(b == 0.0)
    println(Double.NaN == Double.NaN)
    val n: Any = Double.NaN
    println(n == Double.NaN)
}
salidafalse
true
false
false
true

Por qué: la primera es la de siempre: 0.1 y 0.2 no tienen representación exacta en binario. Lo raro viene después. Cuando el compilador sabe que la variable es un Double, == sigue la norma IEEE 754: -0.0 es igual a 0.0 y NaN no es igual a nada, ni a sí mismo. Cuando la variable es Any (o Double?, o está en una colección), == llama a equals, que distingue -0.0 de 0.0 y considera NaN igual a NaN. El mismo valor, la misma comparación, dos respuestas según el tipo declarado.

Cómo evitarlo: no compares Double con ==; comprueba que la diferencia sea pequeña (abs(a - b) < 1e-9) y usa isNaN(). Para dinero, BigDecimal, como explicamos en BigDecimal en Java, que funciona igual desde Kotlin.

Un NullPointerException en código sin un solo !!

Una clase de Java, como las que usarás a montones en Android o con Spring:

public class Clientes {
    public static String correo(int id) {
        return id == 1 ? "ana@example.com" : null;
    }
}
fun main() {
    val a = Clientes.correo(1)
    println(a.uppercase())

    val b: String = Clientes.correo(2)
    println("Nunca llego aquí: $b")
}
salida con errorANA@EXAMPLE.COM
Exception in thread "main" java.lang.NullPointerException: correo(...) must not be null
    at PlataformaKt.main(plataforma.kt:5)

Por qué: Java no dice si un String puede ser null, así que Kotlin no puede saberlo. Lo que devuelve un método de Java es un tipo de plataforma (String! en los mensajes del IDE): Kotlin te deja tratarlo como quieras, sin avisos, y la responsabilidad es tuya. Al guardarlo en una variable String, Kotlin añade una comprobación y, si llega null, lanza ahí mismo el NullPointerException con el nombre del método. Es lo mejor que puede hacer, pero el error llega al ejecutar.

Cómo evitarlo: declara como String? todo lo que venga de Java si no sabes si puede ser null, y trátalo con ?. y ?:. Si escribes la parte de Java, ponle anotaciones @Nullable/@NonNull (JSpecify, JetBrains o las de Android): Kotlin las lee y convierte el tipo de plataforma en uno normal. Está en la lección 4.

lateinit: el compilador confía en ti

class Pedido {
    lateinit var cliente: String
}

fun main() {
    val p = Pedido()
    println("Pedido creado")
    println(p.cliente)
}
salida con errorPedido creado
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property cliente has not been initialized
    at Pedido.getCliente(lateinit_.kt:2)

Por qué: lateinit es una promesa: «esta propiedad no es null, pero le daré valor más tarde». Existe para los frameworks que inyectan los valores después de crear el objeto (Android, Spring, las pruebas). El compilador se fía y no comprueba nada; si lees antes de asignar, la excepción llega al ejecutar. Al menos el mensaje dice exactamente qué propiedad fue.

Cómo evitarlo: si puedes, pasa el valor en el constructor. Si de verdad necesitas lateinit, comprueba con ::cliente.isInitialized donde no estés seguro, o usa by lazy si el valor se puede calcular la primera vez que se pide.

Un val que es null aunque su tipo no lo permite

open class Animal {
    open val nombre: String = "animal"

    init {
        println("Creando: ${nombre.length} letras")
    }
}

class Perro : Animal() {
    override val nombre: String = "Firulais"
}

fun main() {
    Perro()
}
salida con errorException in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "Animal.getNombre()" is null
    at Animal.<init>(init_orden.kt:5)
    at Perro.<init>(init_orden.kt:9)

Por qué: al crear un Perro, primero se ejecuta el constructor de Animal, con su init. Ese init lee nombre, pero como es open y Perro lo sobrescribe, la lectura va al getNombre() de Perro, cuyo campo todavía no se ha inicializado: en la JVM vale null. Así, una propiedad String, que según el tipo no puede ser null, lo es. kotlinc 2.4.21 lo compiló sin ningún aviso; IntelliJ IDEA sí lo marca («Accessing non-final property in constructor»).

Cómo evitarlo: nunca uses propiedades o funciones open en un init o en el inicializador de una propiedad de la clase base. La herencia y open están en la lección 5.

Un val que cambia cada vez que lo lees

class Contador {
    private var veces = 0
    val siguiente: Int
        get() = ++veces
}

fun main() {
    val c = Contador()
    println(c.siguiente)
    println(c.siguiente)
    println(c.siguiente)
}
salida1
2
3

Por qué: val significa «no tiene setter»: nadie puede asignarle un valor desde fuera. No significa que su valor sea constante. Con un getter propio, cada lectura ejecuta código y puede dar un resultado distinto.

Cómo evitarlo: si una propiedad calcula algo con efectos (cambia un contador, lee la hora, consulta una base de datos), hazla función: fun siguiente(): Int. Quien lee c.siguiente espera algo barato y sin sorpresas. Para constantes de verdad está const val.

La desestructuración no mira los nombres

data class Contacto(val nombre: String, val correo: String)

fun main() {
    val c = Contacto("Ana", "ana@example.com")
    val (correo, nombre) = c
    println("Escribir a $correo")
}
salidaEscribir a Ana

Por qué: val (a, b) = c se traduce a c.component1() y c.component2(): va por posición, en el orden del constructor, y los nombres de la izquierda dan igual. Como las dos propiedades son String, no hay error de tipos que lo delate, y kotlinc 2.4.21 tampoco avisó.

Cómo evitarlo: desestructura solo tipos pequeños donde el orden es obvio (Pair, una coordenada), con los mismos nombres que las propiedades, o lee las propiedades por su nombre: c.correo.

Resumen: la regla detrás de cada rareza

  • RarezaLa causaLa costumbre que la evita
  • === con Int?Caché de Integer de -128 a 127Comparar números con ==
  • List que cambiaSolo lectura no es inmutableCopiar con toList(), sin casts
  • Arrays con ==equals de la JVM, por referenciacontentEquals o List
  • Propiedades en el cuerpodata class solo usa el constructor primarioTodo el valor en el constructor
  • copy()Copia referencias, no contenidoPropiedades de solo lectura
  • return en forEachSale de la función que contiene el lambdareturn@forEach o un for
  • $nombre.length$ toma solo un nombre${...} para expresiones
  • -5.absoluteValueEl punto tiene más prioridad que el signoParéntesis; mod en vez de %
  • DesbordamientoLos literales son IntEmpezar con Long
  • -0.0 y NaNIEEE 754 o equals, según el tipoComparar con margen e isNaN()
  • Tipos de plataformaJava no dice si algo puede ser nullString? y anotaciones en Java
  • lateinitEl compilador no comprueba la promesaConstructor, isInitialized o lazy
  • open en un initLa subclase aún no se ha inicializadoNada open en la construcción
  • val que cambiaval es «sin setter», no constanteFunciones para lo que tiene efectos
  • DesestructuraciónVa por posición, no por nombreLeer por nombre

Varias de estas rarezas vienen directamente de la JVM, así que si programas en Java te sonarán: compáralas con las rarezas de Java. ¿Quieres comprobar si ya piensas como el compilador? Prueba el test de Kotlin, con 24 preguntas sobre si un código compila y qué escribe. Y si alguna te tomó por sorpresa, el curso de Kotlin explica la base de cada una.

Todo el código de esta página se compiló con Kotlin 2.4.21 (kotlinc) y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con kotlinc -version.