wandres.dev
NULL SAFETY · el sistema de tipos contra el NPE

La frontera con Java: platform types

Java no lleva la nulabilidad en sus tipos, así que Kotlin inventó un tercer estado que no puedes escribir en el código fuente. Qué garantiza y qué no el compilador ahí, cómo las anotaciones de nulabilidad y JSpecify devuelven la información perdida, y por qué el compilador siembra comprobaciones intrínsecas en cada frontera pública.

⏱ 19 min

Todo lo anterior descansa sobre una premisa que se rompe en cuanto tu programa toca una biblioteca ajena: que la nulabilidad está declarada en los tipos. En Java no lo está, y Java es la mitad del ecosistema en el que vive Kotlin. Ese choque obligó a tomar una de las decisiones más comprometidas del diseño del lenguaje, porque las dos salidas puras eran igual de malas: tratar todo lo que venga de Java como nulable habría hecho la interoperabilidad insufrible, y tratarlo como no nulo habría convertido la garantía en una mentira. Kotlin eligió una tercera vía, deliberadamente pragmática y deliberadamente imperfecta, y conviene saber exactamente qué te está prometiendo en ella.

🎯 Al terminar esta lección sabrás
  • Explicar qué es un tipo de plataforma y por qué no se puede escribir en el código fuente.
  • Distinguir lo que el compilador garantiza en la frontera de lo que solo desplaza en el tiempo.
  • Recuperar la información de nulabilidad con anotaciones y con el estándar JSpecify.
  • Reconocer las comprobaciones intrínsecas en el bytecode y saber qué familia de fallos previenen.

El tipo que no puedes escribir

Cuando Kotlin lee una firma de Java sin anotaciones de nulabilidad, no sabe nada, y en vez de fingir que sabe produce un tipo de plataforma. En los mensajes del compilador y en el editor aparece con un sufijo de admiración, String!, pero esa notación no existe en el lenguaje: no puedes declararla, no puedes escribirla en una firma, solo puedes recibirla.

// Java, sin anotar
public class Registro {
    public String buscar(String clave) { ... }   // puede devolver null, o no
}
val r = Registro()

val a = r.buscar("k")            // tipo de plataforma: el compilador no opina
val b: String = r.buscar("k")    // yo afirmo que no es nulo
val c: String? = r.buscar("k")   // yo asumo que puede serlo

La propiedad definitoria de un tipo de plataforma es que es asignable a las dos versiones. El compilador te delega la decisión y no emite ningún aviso, porque no tiene información para emitirlo. Si eliges String?, entras en el régimen seguro de las lecciones anteriores y el análisis vuelve a funcionar con normalidad. Si eliges String, el compilador inserta una comprobación en el punto de la asignación: no te está creyendo, te está tomando la palabra y verificándola justo ahí.

Y esa es exactamente la línea entre lo que garantiza y lo que no. No garantiza que el valor no sea nulo: no hay ninguna verificación estática posible sobre código Java sin anotar. Lo que garantiza es que, si resulta nulo, el programa falla en la frontera, en la línea donde tú declaraste el tipo, y no doscientas llamadas más adentro con una traza que apunta a un módulo inocente. La aportación no es seguridad, es localidad del fallo, que en depuración vale casi tanto.

⚠️
Los tipos de plataforma se propagan si no los detienes

Si asignas el resultado a una variable con tipo inferido, el tipo de plataforma sigue vivo y viaja: puede acabar dentro de una colección, cruzar tres funciones y desreferenciarse lejísimos del origen. La disciplina que resuelve esto es sencilla y no negociable en código serio: anota el tipo explícitamente en el primer punto donde el valor entra en tu código Kotlin. Un tipo de plataforma nunca debería sobrevivir más de una línea.

flowchart LR
J[Metodo Java sin anotar] --> P[Tipo de plataforma]
P --> K1[Declaro el tipo como no nulo]
P --> K2[Declaro el tipo como nulable]
K1 --> I[El compilador inserta una comprobacion intrinseca]
I --> E[Fallo inmediato en la frontera con traza util]
K2 --> Z[Trato la ausencia con los operadores del lenguaje]
JA[Metodo Java anotado o bajo NullMarked] --> T[Tipo Kotlin real y verificado estaticamente]
style E fill:#f9e2af,color:#11111b
style Z fill:#a6e3a1,color:#11111b
style T fill:#a6e3a1,color:#11111b

Devolverle la información al compilador

El tipo de plataforma es la respuesta a la ignorancia, así que la cura es eliminar la ignorancia. Si la biblioteca Java está anotada, Kotlin deja de inventar y produce tipos reales: una firma marcada como no nula llega como String con verificación estática completa, y una marcada como nulable llega como String? con la obligación de tratarla.

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked                       // todo es no nulo salvo marca explicita
public class Registro {
    public String obligatorio(String clave) { ... }   // llega como String
    public @Nullable String opcional(String clave) { ... } // llega como String?
}

Kotlin reconoce varias familias de anotaciones: las de JetBrains, las de la especificación JSR-305 con sus valores por defecto a nivel de paquete, las de AndroidX, las de Jakarta y, sobre todo, JSpecify, que es el estándar hacia el que ha convergido el ecosistema y el único que modela además la nulabilidad dentro de los genéricos. Desde Kotlin 2.1 los avisos derivados de JSpecify se tratan como errores por defecto, lo que significa que una biblioteca bien anotada te da en Kotlin prácticamente la misma seguridad que el código Kotlin nativo.

La estrategia práctica en la frontera se resume en tres reglas. En código propio con módulos Java, anota el paquete con la marca global y trata solo las excepciones. En bibliotecas ajenas anotadas, confía en los tipos que llegan. Y en bibliotecas ajenas sin anotar, envuélvelas: una capa fina de funciones Kotlin que reciben lo que venga y devuelven tipos honestos concentra toda la incertidumbre en un archivo auditable en vez de esparcirla por todo el proyecto.

// La capa de envoltura: el unico sitio del proyecto que ve tipos de plataforma
private val bruto = Registro()

fun buscarClave(clave: String): String? = bruto.buscar(clave)

fun exigirClave(clave: String): String =
    bruto.buscar(clave) ?: error("clave ausente en el registro heredado: $clave")

Doce líneas como esas eliminan los tipos de plataforma del resto del código base. A partir de ahí, todo lo que circula por tu programa son tipos Kotlin verificados y las lecciones anteriores vuelven a aplicarse íntegras.

ℹ️
Los genéricos son la parte difícil

La nulabilidad de un parámetro de tipo es un problema aparte del de la nulabilidad del contenedor, y la mayoría de familias de anotaciones antiguas no lo modelan: no distinguen una lista no nula de elementos nulables de una lista nulable de elementos no nulos. JSpecify se diseñó precisamente para cubrir ese hueco, y esa es la razón de fondo por la que se convirtió en el estándar hacia el que ha migrado el ecosistema en lugar de quedarse en una alternativa más.

Las comprobaciones que el compilador siembra

La nulabilidad se borra al compilar, pero la garantía tiene que sostenerse aunque el llamante no sea Kotlin. La solución del compilador es sembrar aserciones baratas exactamente en los puntos donde el sistema de tipos deja de tener jurisdicción.

fun saludar(nombre: String) = println("hola $nombre")

En la JVM, el cuerpo generado no empieza por el println. Traducido de vuelta a Java, lo que el compilador emitió se parece a esto:

public static final void saludar(String nombre) {
    Intrinsics.checkNotNullParameter(nombre, "nombre");
    System.out.println("hola " + nombre);
}

Esa primera línea es la razón de que un null procedente de Java produzca un NullPointerException con un mensaje que nombra el parámetro y la función, en lugar de reventar tres capas más abajo con una traza que no señala a nadie. Hay tres familias de comprobación y conviene distinguirlas porque solo una es culpa tuya:

🚪

Parámetros públicos

checkNotNullParameter en cada parámetro no nulo de una función visible desde fuera. Protege de llamantes Java, de la reflexión y de cualquier cosa que ignore los metadatos.

🔁

Valores de retorno de plataforma

checkNotNullExpressionValue cuando asignas un tipo de plataforma a un tipo no nulo. Es la materialización de la promesa que hiciste en la frontera.

Aserciones explícitas

checkNotNull es la traducción de !!. La única de las tres que aparece porque tú lo pediste, y la única que puedes eliminar rediseñando.

El coste es una comparación contra cero por parámetro, que el compilador JIT elimina en la mayoría de los caminos calientes. Existen banderas para suprimir estas comprobaciones, pero desactivarlas cambia el modo de fallo de excepción con mensaje en la frontera a comportamiento indefinido en el interior, que es exactamente el problema que Kotlin vino a resolver.

Fíjate en dónde no aparecen. Entre dos funciones privadas del mismo archivo, o en una función interna que solo se llama desde Kotlin, el compilador no emite nada: el sistema de tipos ya demostró la propiedad y no hay ninguna vía por la que un null pueda entrar. Las comprobaciones marcan con precisión quirúrgica el perímetro donde la garantía estática deja de valer, y leer el bytecode buscándolas es la forma más directa de ver dibujada la frontera real de tu módulo.

Queda la familia de fallos más desconcertante del mundo real, y ahora tienes las piezas para explicarla. Un deserializador de la generación anterior puede construir objetos saltándose el constructor mediante mecanismos de bajo nivel de la máquina virtual y escribir los campos por reflexión. Así, un val nombre: String acaba conteniendo null sin que ninguna comprobación se haya ejecutado nunca, porque nunca se ejecutó el constructor donde vivía la aserción. El resultado es un valor que el sistema de tipos declara imposible, y el fallo aparece mucho después, en un sitio donde el smart cast había concedido acceso directo. La cura no es la desconfianza generalizada sino usar deserializadores conscientes de Kotlin, que leen los metadatos, respetan los valores por defecto y validan la nulabilidad al construir.

La garantía de un sistema de tipos vale exactamente lo que valga su frontera

Aquí es donde la lección de este nivel deja de ser sobre Kotlin y pasa a ser sobre ingeniería en general, porque el patrón se repite en cada sistema con garantías estáticas que haya existido. Un sistema de tipos es un teorema condicional: si todos los valores entraron por donde debían, entonces ciertas familias de fallo son imposibles. Ese si es todo. En el interior de un módulo compilado íntegramente en Kotlin, la garantía es absoluta y gratuita, y por eso el compilador no emite comprobación alguna entre dos funciones internas. En la frontera, la garantía se convierte en una afirmación que alguien tiene que sostener, y el diseño de Kotlin es notable precisamente por no fingir lo contrario: en vez de proclamar seguridad total y romperse en silencio ante el primer null que llegue de una biblioteca de 2009, inventó un estado explícito para la ignorancia, te obligó a decidir tú, y colocó una comprobación exactamente donde tú tomaste la decisión. Es honestidad de diseño convertida en código generado. Y la moraleja operativa es la que vale para toda tu carrera, mucho más allá de este lenguaje: identifica las fronteras de tu sistema —interoperabilidad, deserialización, reflexión, entrada del usuario, respuestas de red, columnas de base de datos— y trátalas como el único lugar donde vive la incertidumbre. Valida ahí, una vez, en un sitio auditable, y convierte el resultado en tipos que ya carguen con la garantía. Todo lo que esté detrás de esa frontera podrá razonarse con el sistema de tipos en la mano y sin comprobaciones defensivas. Todo lo que la atraviese sin validar convierte tu teorema en una creencia, y una creencia no compila.

⚔️ Audita tu frontera
  1. Llama a un método de una biblioteca Java sin anotar, deja que el tipo se infiera y comprueba en el editor que aparece con sufijo de admiración; después anótalo explícitamente.
  2. Decompila una función Kotlin pública con un parámetro no nulo y localiza la llamada intrínseca al principio del cuerpo.
  3. Escribe una clase Java que devuelva null desde un método sin anotar, asígnalo a un tipo no nulo en Kotlin y compara la traza con la que obtendrías si el valor viajara tres funciones más adentro.
  4. Anota un paquete Java propio con la marca global y observa cuántos tipos de plataforma desaparecen del lado Kotlin.
  5. Deserializa un JSON al que le falta un campo declarado no nulo con dos bibliotecas distintas, una consciente de Kotlin y otra no, y compara qué ocurre.