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

Los operadores: acceso seguro, elvis y la aserción

El vocabulario completo para trabajar con tipos nulables: el acceso seguro y el tipo que devuelve, el elvis como función total y el truco de Nothing en su rama derecha, la aserción no nula y por qué casi siempre delata un fallo de diseño, y let sobre un nulable con la trampa que casi nadie ve.

⏱ 19 min

Una vez que T y T? son tipos distintos, hace falta un vocabulario para moverse entre ellos, y Kotlin lo reduce a tres símbolos y una función de la biblioteca estándar. No son atajos sintácticos intercambiables: cada uno expresa una intención diferente sobre qué debe pasar cuando el valor falta, y elegir mal no produce un error de compilación sino un programa que miente sobre sus propias garantías. El más corto de los tres, la doble admiración, es además el único que puede tirar tu programa abajo, y aprender a leerlo como un síntoma en vez de como una herramienta es probablemente la mayor mejora de estilo que puedes hacer en tu Kotlin.

🎯 Al terminar esta lección sabrás
  • Encadenar accesos seguros sabiendo qué tipo produce cada eslabón y dónde se corta la cadena.
  • Usar el elvis como operación total, incluida su rama derecha de tipo Nothing.
  • Reconocer los tres usos legítimos de !! y sustituirlo por alternativas con diagnóstico.
  • Aplicar let sobre un nulable evitando la trampa de combinarlo con elvis.

El acceso seguro y el elvis

El acceso seguro, ?., evalúa el receptor y, si es nulo, produce null sin invocar el miembro. Lo esencial es su tipo de retorno: siempre es nulable, aunque el miembro devuelva un tipo que no lo sea.

class Direccion(val ciudad: String)
class Usuario(val direccion: Direccion?)

val u: Usuario? = obtener()
val ciudad = u?.direccion?.ciudad     // tipo: String?, aunque ciudad sea String

La cadena se evalúa de izquierda a derecha y cortocircuita en el primer eslabón nulo: si u es nulo no se evalúa nada más. Esto no es un manejo de excepciones disfrazado, es un cortocircuito idéntico en espíritu al de &&. También funciona sobre valores de tipo función, accion?.invoke(x), y en la parte izquierda de una asignación, con un matiz peligroso:

u?.perfil = nuevoPerfil    // si u es nulo, la asignacion NO ocurre y nadie se entera

Ese silencio es correcto según los tipos y casi nunca es lo que quieres: una escritura que se descarta sin traza es un error que no deja huella. Prefiere una comprobación explícita cuando la escritura importa.

El elvis, ?:, completa la pareja: convierte un valor nulable en uno total aportando la alternativa. Su tipo es el supremo de ambos lados.

val longitud = texto?.length ?: 0                 // Int
val nombre = usuario?.nombre ?: "anonimo"         // String

La sutileza que lo convierte en una herramienta de control de flujo es que su rama derecha admite expresiones de tipo Nothing. Como Nothing es subtipo de todo, el tipo del conjunto no se contamina y puedes salir de la función ahí mismo:

fun procesar(id: String?) {
    val identificador = id ?: return
    val cuenta = repositorio.buscar(identificador)
        ?: throw IllegalStateException("cuenta inexistente: $identificador")
    // a partir de aqui ambos son no nulos, sin anidamiento
}

Este patrón de guarda temprana es el que mantiene plano el código Kotlin idiomático. Recuerda su precedencia: elvis liga más flojo que la aritmética y más fuerte que las conjunciones, de modo que a ?: b + 1 se lee como a ?: (b + 1), y es asociativo por la derecha, así que a ?: b ?: c encadena sin paréntesis.

flowchart TD
A[Tengo un valor de tipo nulable] --> B[Necesito un valor si o si]
B -->|si| E[Elvis con alternativa o con return o throw]
B -->|no| C[Solo actuo cuando existe]
C -->|si| L[Acceso seguro o let sobre el nulable]
C -->|no| D[Puedo demostrar la no nulidad con una comprobacion]
D -->|si| R[Reestructura y deja que el smart cast haga el trabajo]
D -->|no| X[Aserciones no nulas indican un modelo mal elegido]
style E fill:#a6e3a1,color:#11111b
style L fill:#89b4fa,color:#11111b
style X fill:#f38ba8,color:#11111b

La aserción no nula y por qué casi siempre sobra

x!! significa exactamente esto: el tipo dice que puede faltar, yo afirmo que no falta, y no puedo demostrarlo. El compilador acepta la afirmación y emite una llamada intrínseca que lanza un NullPointerException si resulta ser falsa. No es una conversión: es una apuesta con el sistema de tipos, y el precio de perderla lo paga el usuario.

val n: Int = texto!!.length     // si texto es nulo, excepcion aqui mismo

Sobre un genérico, x!! tiene tipo T & Any, el tipo intersección definitivamente no nulo de la lección anterior. La regla de higiene mínima es una aserción por línea: en a!!.b!!.c!! la excepción no te dice cuál de las tres falló salvo que la máquina virtual tenga activados los mensajes detallados, y aun así estás pidiendo a la traza que compense lo que el código no explica.

🧪

Legítimo: pruebas y prototipos

En un test, donde la ausencia es un fallo del propio test y la traza es el diagnóstico, !! es ruido aceptable. assertNotNull es mejor porque nombra la expectativa.

🚧

Legítimo: frontera con invariante externo

Un valor que llega de Java o de un framework y que el contrato garantiza no nulo. Aquí lo correcto es fallar rápido en la frontera, pero con mensaje: usa requireNotNull o checkNotNull.

🏗️

Legítimo: inicialización diferida imposible

Cuando ni lateinit ni by lazy encajan por restricciones del ciclo de vida. Es raro, y conviene documentar por qué.

🩻

Síntoma: todo lo demás

Si escribes !! porque el compilador no ve algo que tú sí ves, el problema es que la información existe pero no está expresada en los tipos. Ahí la solución no es afirmar, es rediseñar.

Las alternativas con diagnóstico son estrictamente mejores porque conservan el fallo rápido y añaden causa:

val cfg = requireNotNull(config) { "falta la configuracion del cliente" }
val ses = checkNotNull(sesion) { "invariante roto: sesion nula tras autenticar" }

Ambas llevan contratos en la biblioteca estándar, así que además propagan la no nulidad al análisis de flujo: después de la línea, la variable original queda promovida sin necesidad de reasignar. require señala un argumento inválido y lanza IllegalArgumentException; check señala un estado inválido del receptor y lanza IllegalStateException. La distinción no es cosmética: comunica de quién es la culpa.

let sobre un nulable, y su trampa

?.let es el puente entre el mundo nulable y un bloque donde el valor ya es seguro. Se usa cuando el smart cast no está disponible —el asunto de la próxima lección— o cuando quieres nombrar el valor no nulo sin declarar una variable.

usuario?.direccion?.let { dir ->
    enviar(dir.ciudad)        // dir es Direccion, no Direccion?
}

Combinarlo con elvis para simular un if/else es un idiom muy extendido y sutilmente roto:

val etiqueta = usuario?.let { formatear(it) } ?: "desconocido"

Si formatear devuelve un tipo nulable y en este caso concreto devuelve null, el elvis dispara aunque usuario no fuera nulo, y acabas mostrando el valor por defecto de la rama equivocada. El bloque let devuelve su última expresión, de modo que la condición efectiva no es el receptor existe sino el resultado del bloque existe. Cuando de verdad quieres dos ramas, escríbelas:

val etiqueta = if (usuario != null) formatear(usuario) else "desconocido"

Del mismo grupo, takeIf y takeUnless hacen el viaje contrario: convierten un valor total en uno nulable según un predicado, y encadenan de maravilla con elvis. apply y also devuelven el receptor, así que sobre un nulable siguen produciendo un nulable y no sirven para escapar de él. Y run sin receptor no es un operador de nulabilidad en absoluto: mezclar los cinco por costumbre es la forma más rápida de que un lector pierda de vista qué está garantizado en cada línea.

⚠️
Un let anidado no es una comprobación conjunta

Tres ?.let anidados para exigir tres valores producen una pirámide ilegible y no expresan la intención real, que es o están los tres o no hago nada. Con guardas de elvis y return el código queda plano; si de verdad necesitas la forma expresión, extrae una función que reciba los tres nulables y devuelva un nulable.

Cada aserción no nula es una demostración que existía y no supiste escribir

Vale la pena mirar !! con el rigor con el que se mira un axioma añadido a mano en una demostración: no es incorrecto por sí mismo, pero cada vez que aparece estás declarando que hay una verdad sobre tu programa que no puede derivarse de las premisas escritas. Y eso, cuando ocurre, casi nunca significa que el sistema de tipos sea demasiado débil. Significa que la información sí existe —tú la conoces, la usas para razonar, incluso podrías explicarla en voz alta— pero vive en tu cabeza, en un comentario o en una convención de equipo, en vez de vivir en las firmas. La consecuencia práctica es brutal: una verdad que no está en los tipos no puede ser verificada por el compilador, no sobrevive a una refactorización, no viaja al siguiente programador y no se comprueba en la revisión de código. Por eso la pregunta correcta al encontrarte con una aserción nunca es cómo la evito aquí, sino qué sé yo que el compilador no sabe, y por qué no se lo he dicho. Las respuestas son sorprendentemente repetitivas. A veces la propiedad debería estar en el constructor en vez de asignarse después, y el nulable existe solo para cubrir una ventana de inicialización que un rediseño elimina. A veces el valor viene de una función que devuelve nulable por un caso que en ese punto del flujo ya está descartado, y la solución es dividir esa función en dos o mover la comprobación antes. A veces la clase tiene cuatro campos nulables que codifican tres estados válidos, y la traducción correcta es una jerarquía sellada donde el estado imposible no se puede construir. A veces la información se perdió al cruzar la frontera con Java y basta con anotarla o envolverla una vez. En los cuatro casos, quitar la aserción no es un ejercicio de estilo: es recuperar una garantía que el compilador puede sostener por ti para siempre. Un módulo con cero aserciones no es un módulo escrito por alguien obsesivo, es un módulo donde todo lo que se sabe está dicho.

⚔️ Elimina tus aserciones
  1. Cuenta los !! de tu proyecto con una búsqueda y clasifícalos en los cuatro casos de las tarjetas; espera que la mayoría caiga en el cuarto.
  2. Coge la aserción más antigua y reescríbela con requireNotNull y un mensaje que explique el invariante; comprueba que el mensaje es más útil que la traza.
  3. Escribe una función con tres guardas de elvis y return y su versión equivalente con tres ?.let anidados; muéstraselas a alguien y pregúntale cuál entiende antes.
  4. Provoca a propósito la trampa del elvis tras let: haz que el bloque devuelva null con un receptor no nulo y observa cómo se dispara la rama por defecto.
  5. Busca una asignación con acceso seguro en la parte izquierda y decide si el descarte silencioso es aceptable; si no lo es, hazlo explícito.