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

La nulabilidad vive en el tipo

Kotlin no añade comprobaciones al NullPointerException: parte el universo de tipos en dos. T y T? son tipos distintos con una relación de subtipo que el compilador explota, Nothing? es el tipo del literal null y Any? la cima de la jerarquía. Por qué nada de esto sobrevive a la compilación y qué cuesta de verdad en el binario.

⏱ 18 min

Tony Hoare llamó a la referencia nula que introdujo en ALGOL W en 1965 su error de mil millones de dólares. Sesenta años después seguimos pagándolo, y la lección que el ecosistema tardó en aprender es que no se corrige comprobando más, porque comprobar más es disciplina y la disciplina no escala. Se corrige haciendo que el compilador sepa distinguir una referencia que puede faltar de una que no. Kotlin no envuelve nada, no añade defensas alrededor del NullPointerException ni introduce un contenedor: reorganiza su sistema de tipos para que la posibilidad de ausencia sea una propiedad estática del tipo. Todo el nivel se apoya en esa idea; esta lección la construye desde los cimientos.

🎯 Al terminar esta lección sabrás
  • Leer T y T? como dos tipos distintos y no como un tipo con una marca añadida.
  • Situar Nothing, Nothing?, Any y Any? en la jerarquía de subtipos que genera la nulabilidad.
  • Explicar por qué un parámetro de tipo sin cota puede ser nulable y cómo se impide.
  • Justificar que la nulabilidad se resuelve al compilar y saber qué queda de ella en el bytecode.

Dos tipos donde antes había uno

La declaración más importante del nivel cabe en dos líneas, y lo que separa a quien las entiende de quien las memoriza es saber que la segunda no es una variante de la primera.

val nombre: String = "Ada"
val quiza: String? = null

nombre.length      // compila siempre
quiza.length       // error: length solo existe sobre String, no sobre String?

String? no es String con un aviso pegado. Es un tipo diferente cuyo conjunto de valores es el de String más un habitante extra, y cuyo conjunto de operaciones disponibles es, en consecuencia, mucho más pequeño: sobre él solo puedes invocar lo que esté definido para Any? y las extensiones declaradas explícitamente sobre un receptor nulable. Por eso quiza.toString() sí compila —la biblioteca estándar declara fun Any?.toString(): String, que devuelve la cadena null cuando el receptor lo es— mientras que quiza.length no.

La relación entre ambos es de subtipo, y la dirección importa. String es subtipo estricto de String?, así que la conversión es libre hacia arriba y prohibida hacia abajo sin una prueba:

val abierto: String? = nombre   // ensanchar: siempre legal, sin coste
val cerrado: String = quiza     // estrechar: error de compilacion

Esto es exactamente lo que hace que la nulabilidad sea contagiosa en el sentido correcto: puedes pasar un valor seguro a un hueco que admite ausencia, nunca al revés. Cada vez que quieres cruzar la frontera en el sentido peligroso, el compilador te obliga a producir una justificación —una comprobación, un valor por defecto, una aserción— y esa obligación es todo el mecanismo.

ℹ️
No es un contenedor

String? no es análogo a Optional de Java ni a un envoltorio de la biblioteca. Un Optional es un objeto real que existe en tiempo de ejecución, ocupa memoria, añade una indirección y —la ironía definitiva— puede él mismo ser nulo. String? no es un objeto que contiene un String: es el mismo String visto con menos permisos. La diferencia no es de estilo, es de categoría.

La jerarquía completa

Duplicar cada tipo genera una jerarquía con dos mitades unidas por arriba y por abajo, y conocer sus extremos explica muchos mensajes de error que de otro modo parecen arbitrarios.

flowchart BT
N[Nothing] --> S[String]
N --> I[Int]
S --> A[Any]
I --> A
N --> NN[Nothing nullable]
NN --> SN[String nullable]
NN --> IN[Int nullable]
S --> SN
I --> IN
SN --> AN[Any nullable]
IN --> AN
A --> AN
style N fill:#f38ba8,color:#11111b
style NN fill:#f9e2af,color:#11111b
style AN fill:#a6e3a1,color:#11111b

Los cuatro vértices de esa figura merecen nombre propio, porque casi todos los mensajes de error sobre nulabilidad que verás en tu vida los mencionan.

🌐

Any? — la cima absoluta

Todo tipo de Kotlin es subtipo suyo. Es lo que hay que escribir cuando de verdad aceptas cualquier cosa, incluida la ausencia, y es la cota superior implícita de un parámetro de tipo sin restricción.

🏔️

Any — la cima segura

Solo domina la mitad no nulable. Sorprende a quien viene de Java esperando que Object acepte cualquier cosa: una función que reciba Any rechaza null en compilación.

🕳️

Nothing — el tipo vacío

No tiene ningún habitante y es subtipo de todos los demás. Es el tipo de una expresión que nunca devuelve el control: un throw, un return, una llamada a TODO() o un bucle infinito.

🎯

Nothing? — el tipo del literal

Su único habitante es null. Ser subtipo de todo tipo nulable es lo que permite escribir null allí donde se espera cualquier T? sin conversión alguna.

Que Nothing sea subtipo de todo es la pieza que hace funcionar el elvis con return y throw a su derecha, y ese será el primer truco de la próxima lección.

De ahí salen varios comportamientos que conviene poder predecir:

val x = null                  // inferido: Nothing?
val lista = listOf(null)      // inferido: List<Nothing?>
val y = if (hoy) "texto" else null   // inferido: String?, el supremo de String y Nothing?

La inferencia calcula el supremo de las ramas, y como Nothing? es subtipo de todo tipo nulable, ese supremo es siempre la versión nulable del otro operando. No hay magia: es aritmética de la jerarquía.

En genéricos aparece la consecuencia menos intuitiva. Un parámetro de tipo sin cota explícita tiene como cota superior implícita Any?, no Any, de modo que puede sustituirse por un tipo nulable:

fun <T> identidad(v: T): T = v
identidad<String?>(null)      // legal: T se instancia a String?

fun <T : Any> exigente(v: T): T = v
exigente<String?>(null)       // error: String? no cumple la cota Any

Dentro de identidad, el parámetro v no admite v.length ni siquiera si el llamante usó String, porque el cuerpo se compila una sola vez para toda instanciación posible. Cuando necesitas dentro del cuerpo la garantía de no nulidad sin cerrar la firma, existe el tipo intersección T & Any, el llamado definitely non-nullable estable desde Kotlin 1.7: es lo que devuelve v!! sobre un genérico y lo que puedes escribir explícitamente en una firma para expresar la idea sin prohibir que T sea nulable en otros usos.

Nada de esto existe al ejecutar

Aquí está la clave que separa el modelo mental correcto del aproximado. En la JVM no hay ninguna clase String?. Tanto String como String? se compilan al mismo java.lang.String, y toda la información de nulabilidad viaja como metadatos: las anotaciones que el compilador emite en la firma y el bloque de metadatos de Kotlin que otro módulo lee al compilar contra el tuyo. La nulabilidad es una disciplina del comprobador de tipos, no una estructura del programa ejecutable.

Eso tiene tres consecuencias directas. La primera es que la seguridad es gratis: no hay asignación, no hay indirección, no hay envoltorio que abrir. Convertir un String? demostrado no nulo en un String no genera ni una instrucción, porque no hay nada que desenvolver.

La segunda es la excepción que confirma el modelo: los tipos primitivos. Int se representa como el primitivo int, que no tiene manera de codificar la ausencia, así que Int? se representa como java.lang.Integer y paga una asignación —o un acierto en la caché de enteros pequeños—.

val a: Int = 1_000        // primitivo int, sin objeto
val b: Int? = 1_000       // objeto Integer, con boxing
val c: Int? = 1_000

println(b == c)           // true: compara por equals
println(b === c)          // false fuera del rango cacheado: identidades distintas

Un Array<Int?> o una List<Int?> en un bucle caliente cuestan un objeto por elemento. Cuando esa diferencia importa, la solución no es abandonar la nulabilidad sino rediseñar el dato para que la ausencia no sea representable —el tema de la última lección del nivel—.

La tercera es que la garantía solo vale dentro de Kotlin. Java, la reflexión y las bibliotecas de deserialización pueden inyectar null en un hueco declarado no nulo sin que ningún tipo se lo impida. Por eso el compilador siembra comprobaciones intrínsecas en las fronteras públicas, un mecanismo que estudiaremos a fondo en la cuarta lección.

📝
Los otros backends cuentan la misma historia

Nada de esto es una peculiaridad de la máquina virtual de Java. En Kotlin/Native la nulabilidad tampoco genera envoltorio: un puntero opcional es el mismo puntero, y la ausencia es el puntero nulo del binario. En Kotlin/JS, null y undefined colapsan sobre el mismo tipo nulable del lado de Kotlin. El patrón es constante en las cuatro plataformas: la nulabilidad es un lenguaje que hablan el comprobador de tipos y el generador de código, y que el programa ejecutable ya no necesita.

Un sistema de tipos no evita errores: hace que ciertos programas dejen de existir

Merece la pena detenerse en lo que realmente ocurrió aquí, porque es una de las demostraciones más limpias de para qué sirve un sistema de tipos. La alternativa histórica al NullPointerException fue siempre añadir vigilancia: comprobaciones defensivas al principio de cada método, aserciones, revisiones de código, analizadores estáticos externos, convenciones de nombres, documentación que dice qué puede devolver ausencia. Todas esas técnicas comparten un defecto estructural: son opcionales y actúan sobre valores. Puedes olvidarte de una, el programa compila igual, y el fallo aparece a las tres de la mañana en producción, a menudo a muchas capas de distancia del lugar donde alguien decidió devolver null. Kotlin hizo algo cualitativamente distinto: no reforzó la vigilancia sobre los valores, sino que cambió la gramática de lo que se puede escribir. Al desdoblar cada tipo en dos y ordenar la relación entre ellos, el programa que desreferencia una ausencia sin haberla tratado deja simplemente de ser un programa válido; no es que falle en pruebas, es que no llega a existir como binario. Esa es la diferencia entre detectar y eliminar, y explica por qué el coste en tiempo de ejecución es cero: no hay nada que vigilar cuando la clase entera de errores fue descartada antes de generar código. Fíjate además en la economía de la decisión, que es lo que la hace de ingeniería y no de teoría. Kotlin no importó el envoltorio funcional canónico, que habría sido más puro y habría añadido una asignación por cada valor opcional, una indirección en cada acceso y una interoperabilidad dolorosa con veinte años de bibliotecas Java. Eligió el sufijo ?, que se borra por completo al compilar y deja el mismo bytecode que el código inseguro equivalente. Seguridad total en el comprobador de tipos, coste nulo en el binario, compatibilidad íntegra con el ecosistema existente. Cuando entiendas que T y T? son dos tipos y no un tipo con adorno, habrás dejado de escribir Kotlin defensivo para empezar a escribir Kotlin en el que la defensa sobra.

⚔️ Manipula la jerarquía con las manos
  1. Declara val x = null en el REPL o en un scratch y pide el tipo inferido: confirma que es Nothing? y razona por qué no es Any?.
  2. Escribe una función con firma fun <T> primero(lista: List<T>): T e intenta llamar a un método sobre el resultado; después añade la cota T : Any y observa qué cambia y qué se rompe en los llamantes.
  3. Crea val a: Int? = 127 y val b: Int? = 127, compara con == y con ===; repite con 1000 y explica el resultado a partir del boxing.
  4. Compila una función que reciba String? y otra que reciba String, decompila el bytecode y compara ambas firmas: localiza dónde vive la nulabilidad.
  5. Busca en tu código una función que devuelva un tipo nulable y pregúntate si el llamante puede hacer algo útil con la ausencia; guarda la respuesta para la quinta lección.