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

Diseñar sin nulls: cuándo el nulable esconde un modelo pobre

El nulable es una respuesta correcta a la ausencia y una respuesta pésima a casi todo lo demás. Los cuatro casos en que es legítimo, la explosión combinatoria de una clase con campos opcionales, la traducción a jerarquías selladas y tipos de dominio, y la doctrina de empujar la ausencia hacia los bordes del sistema.

⏱ 20 min

Las cuatro lecciones anteriores enseñaron a manejar la nulabilidad con precisión. Esta pregunta algo distinto y bastante más incómodo: si debería estar ahí. Un tipo nulable es una afirmación sobre el dominio —este valor puede legítimamente no existir— y cuando esa afirmación es cierta, el nulable es la herramienta exacta, barata y clara. El problema es que también es el vertedero más cómodo del lenguaje: sirve para señalar ausencia, pero igual de bien para señalar error, para señalar no inicializado, para señalar valor por defecto y para señalar no aplicable en este estado, y cuando esos cinco significados comparten una única representación, el sistema de tipos deja de contar la verdad.

🎯 Al terminar esta lección sabrás
  • Identificar los casos en que un tipo nulable modela correctamente la ausencia.
  • Calcular la explosión combinatoria de estados que introduce un conjunto de campos opcionales.
  • Traducir un modelo con campos nulables correlacionados a una jerarquía sellada exhaustiva.
  • Aplicar tipos de dominio y la doctrina de empujar la ausencia hacia las fronteras.

Cuándo el nulable es la respuesta correcta

Hay un criterio que resuelve la mayoría de los casos: el nulable es adecuado cuando la ausencia es un hecho legítimo del dominio, sin más información asociada, y quien llama puede hacer algo razonable con ella. Las tres condiciones importan y la segunda es la que más se incumple.

🔍

Búsqueda que puede no encontrar

Map.get, firstOrNull, find. No hay nada más que decir sobre el fallo: la clave no estaba. La convención del sufijo OrNull frente a la variante que lanza es una de las mejores de la biblioteca estándar.

📭

Dato opcional de verdad

Un segundo apellido, una fecha de baja, un comentario del usuario. La ausencia no es un fallo ni un estado: es que ese dato no existe para esa entidad, y no existirá.

🧩

Análisis que puede fracasar sin causa útil

toIntOrNull sobre una cadena. Si a quien llama solo le importa si se pudo o no, un nulable dice justo eso y nada más.

Inicialización controlada

Un valor que un framework rellena después de construir. Aquí lateinit suele ser mejor porque no obliga a comprobar en cada uso y falla con un mensaje que nombra la propiedad.

Fuera de ese perímetro empiezan los usos que degradan el modelo. Un nulable para señalar error pierde la causa: fun cargar(id: Id): Usuario? no distingue entre no existe, no tienes permiso y la red falló, y el llamante solo puede mostrar un mensaje genérico. Un nulable en una colección obliga a todos los llamantes a ramificar por nada, cuando la lista vacía ya expresa perfectamente ninguno, así que List<T>? es casi siempre List<T> mal escrito. Y Boolean? es directamente un tipo con tres estados llamado como si tuviera dos: si el tercer estado importa, dale nombre.

La explosión combinatoria

El daño estructural aparece cuando varios campos nulables de la misma clase están correlacionados. Cada campo opcional multiplica por dos el número de estados representables, y la inmensa mayoría de esos estados no tiene sentido alguno.

class Pedido(
    val creadoEn: Instant,
    val enviadoEn: Instant?,
    val entregadoEn: Instant?,
    val canceladoEn: Instant?,
    val motivoCancelacion: String?,
)

Cuatro campos opcionales son dieciséis combinaciones posibles, de las cuales solo cuatro corresponden a pedidos reales. Las otras doce son basura que el sistema de tipos permite construir: entregado sin haber sido enviado, cancelado sin motivo, motivo de cancelación sin cancelación, enviado y cancelado a la vez. Y como el compilador las admite, cada función que lea este objeto tiene que decidir qué hacer con ellas, normalmente con una cascada de comprobaciones que nadie mantiene sincronizada y que termina en la aserción de la segunda lección.

Las señales de alarma son reconocibles a simple vista: dos o más campos nulables que siempre se rellenan o se vacían juntos; un Boolean acompañado de un nulable que solo tiene valor cuando el booleano es cierto; una función que empieza con tres guardas seguidas sobre campos del mismo objeto; un comentario que explica qué combinaciones son válidas. Ese comentario es la especificación de un tipo que no llegaste a escribir.

// El clasico booleano con satelite: dos campos para un solo hecho
class Cuenta(
    val bloqueada: Boolean,
    val motivoBloqueo: String?,   // solo tiene sentido si bloqueada es cierto
)

Nada impide construir una cuenta bloqueada sin motivo ni una cuenta activa con motivo de bloqueo, y ninguna de las dos significa nada. El booleano y su satélite son siempre el mismo dato mal partido en dos.

flowchart TD
A[Un campo puede faltar] --> B[La ausencia va ligada a otros campos]
B -->|si| S[Jerarquia sellada por estado]
B -->|no| C[La ausencia lleva una causa que importa]
C -->|si| R[Tipo de resultado sellado con la causa]
C -->|no| D[Existe un valor neutro razonable]
D -->|si| V[Valor por defecto o coleccion vacia]
D -->|no| N[Nulable: es la herramienta correcta]
style S fill:#cba6f7,color:#11111b
style R fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b

Sellado y tipos de dominio

La traducción es mecánica: los estados válidos se convierten en variantes, y los campos que solo existen en un estado se mudan dentro de esa variante. Lo que era una combinación ilegal deja de ser construible.

sealed interface EstadoPedido {
    data object Creado : EstadoPedido
    data class Enviado(val en: Instant) : EstadoPedido
    data class Entregado(val enviadoEn: Instant, val en: Instant) : EstadoPedido
    data class Cancelado(val en: Instant, val motivo: MotivoCancelacion) : EstadoPedido
}

class Pedido(val creadoEn: Instant, val estado: EstadoPedido)

fun descripcion(p: Pedido): String = when (val e = p.estado) {
    EstadoPedido.Creado -> "pendiente de envio"
    is EstadoPedido.Enviado -> "en transito desde " + e.en
    is EstadoPedido.Entregado -> "entregado el " + e.en
    is EstadoPedido.Cancelado -> "cancelado por " + e.motivo
}

Han desaparecido los cuatro nulables y con ellos las doce combinaciones sin sentido. El when es exhaustivo, así que el día que añadas un estado el compilador te llevará a cada punto que hay que actualizar, en lugar de dejar que un else silencioso se lo trague. Y el motivo de cancelación ya no puede faltar donde importa ni sobrar donde no aplica, porque vive dentro de la única variante donde tiene sentido.

El mismo razonamiento aplicado al fallo produce un resultado sellado en vez de un nulable mudo:

sealed interface Carga<out T> {
    data class Exito<T>(val valor: T) : Carga<T>
    data object NoEncontrado : Carga<Nothing>
    data object SinPermiso : Carga<Nothing>
    data class ErrorDeRed(val causa: Throwable) : Carga<Nothing>
}

Fíjate en Carga<Nothing> para las variantes sin datos: como Nothing es subtipo de todo, esas variantes encajan en cualquier instanciación gracias a la varianza. El llamante ya no recibe no hay usuario, recibe qué pasó y puede reintentar, pedir credenciales o mostrar un vacío según el caso.

La otra mitad del diseño es hacer que el propio dato cargue con su garantía, de modo que la nulabilidad aparezca una sola vez, en el punto donde el valor entra al sistema, y nunca más:

@JvmInline
value class Email private constructor(val valor: String) {
    companion object {
        fun deOrNull(bruto: String): Email? =
            bruto.trim().takeIf { it.contains("@") }?.let(::Email)
    }
}

fun registrar(email: Email) { /* aqui ya no hay nada que validar */ }

El nulable vive en la factoría, que es la frontera. A partir de ahí, un Email es por construcción un correo válido, y ninguna función río abajo necesita comprobarlo ni documentar que lo espera. Es la misma doctrina de la lección anterior sobre la interoperabilidad, generalizada: validar una vez, en el borde, y convertir el resultado en un tipo que ya no admita el estado inválido. Como la clase es de valor, en la mayoría de los usos el envoltorio desaparece al compilar y la garantía sale gratis.

Queda una tercera herramienta para el caso concreto de la inicialización diferida, cuando un framework construye el objeto y rellena una propiedad después: lateinit var. Evita el nulable en todos los usos posteriores y falla, si se accede antes de tiempo, con una excepción que nombra la propiedad. Su precio es real y hay que conocerlo: obliga a var, no admite tipos primitivos, no puede ser nulable y traslada la verificación al tiempo de ejecución. Es una válvula de escape controlada, no un sustituto general del nulable, y ::propiedad.isInitialized permite consultarla cuando de verdad hace falta.

💡
No selles lo que no está correlacionado

La medicina también se pasa de dosis. Un único campo genuinamente opcional y sin relación con nada más no necesita una jerarquía de dos variantes: eso añade ceremonia, ruido en la construcción y una indirección sin ganar ninguna garantía. La regla es la correlación: sella cuando la presencia de un campo determina la de otro, y deja el nulable cuando la ausencia es un hecho aislado.

Los tipos no describen el programa: deciden qué programas pueden existir

El salto conceptual de este nivel se completa aquí, y es más grande de lo que parece. Casi todo el mundo aprende a pensar en los tipos como documentación ejecutable: una etiqueta que describe qué guarda una variable y que de paso evita algunas equivocaciones tontas. Con esa mentalidad, un campo nulable es una descripción fiel —a veces está, a veces no— y por tanto un modelo correcto. Pero un tipo no describe: delimita. Define el conjunto exacto de valores que pueden existir, y por tanto el conjunto exacto de programas que pueden compilarse. Cuando declaras cuatro campos opcionales correlacionados no estás describiendo cuatro datos que a veces faltan; estás autorizando dieciséis configuraciones de tu entidad, doce de las cuales tu dominio considera imposibles pero tu programa puede construir, guardar en base de datos, serializar y enviar por la red. Y aquí está la asimetría cruel: los estados imposibles nunca son imposibles en la práctica, solo son improbables, y lo improbable ocurre en producción a escala. Cada uno de ellos es una rama que alguien tendrá que manejar defensivamente, un caso que las pruebas no cubrirán porque nadie escribe pruebas para estados que considera imposibles, y una llamada de madrugada esperando fecha. Cuando conviertes esos cuatro campos en una jerarquía sellada, no has reorganizado código: has reducido el espacio de estados de dieciséis a cuatro, has eliminado doce clases enteras de fallo antes de que existan, y has convertido cada futura extensión del dominio en un error de compilación que te lleva de la mano por todos los puntos que hay que revisar. Ese es el sentido profundo de la máxima de hacer irrepresentables los estados ilegales, y por eso el nivel entero, que empezó pareciendo una discusión sobre un signo de interrogación, era en realidad una discusión sobre modelado. La nulabilidad en el tipo te dio la herramienta para no desreferenciar la ausencia; el diseño sin nulls te da algo mayor: la posibilidad de que la ausencia ni siquiera se plantee, porque el modelo no la permite. La primera es seguridad. La segunda es claridad, y la claridad es lo único que escala cuando el sistema crece más allá de lo que cabe en una cabeza.

⚔️ Reduce tu espacio de estados
  1. Busca en tu dominio la clase con más campos nulables, cuenta las combinaciones representables y enumera cuáles son válidas de verdad.
  2. Traduce esa clase a una jerarquía sellada y comprueba cuántas comprobaciones defensivas desaparecen de los llamantes.
  3. Añade después una variante nueva a la jerarquía y recorre los errores de compilación: eso es la exhaustividad trabajando para ti.
  4. Localiza una función que devuelva un nulable para señalar fallo y conviértela en un resultado sellado con causas nombradas; observa qué puede hacer ahora el llamante que antes no podía.
  5. Elige el identificador de cadena que más circula por tu sistema, envuélvelo en una value class con factoría validadora y cuenta cuántas validaciones repetidas puedes borrar.