Null safety y tipos: hacer que el estado sea siempre valido
Kotlin trata la ausencia como parte del tipo y no como un valor que cualquier referencia pueda tomar por sorpresa, y esa decision convierte al compilador en el guardian de una propiedad que MVI necesita: que el estado, en cualquier instante, sea valido. Esta leccion recorre la disciplina de tipos que sostiene esa promesa: el tipo anulable como suma minima y por qué muchas veces es un tipo suma disfrazado que conviene nombrar; los operadores de navegacion segura y las razones por las que la doble exclamacion es una confesion de que el modelo esta mal; las value classes para que un identificador de usuario no sea intercambiable con un identificador de pedido; y el papel de Nothing, los genericos y la varianza al escribir contenedores de estado reutilizables.
La aportacion mas citada de Kotlin frente a Java es haber metido la ausencia dentro del sistema de tipos: String y String anulable son tipos distintos, y el compilador se niega a tratarlos como intercambiables. Se suele contar como una comodidad que evita una excepcion famosa, pero eso es quedarse en la superficie. Lo que de verdad ocurre es que la nulabilidad deja de ser una propiedad invisible del tiempo de ejecucion —cualquier referencia podia ser nula, y solo lo sabias al fallar— y pasa a ser una afirmacion verificable escrita en la firma. Para una arquitectura como MVI, cuyo contrato entero descansa en que el estado sea siempre valido, esa diferencia lo es todo: si el tipo del estado no puede representar la invalidez, no hace falta comprobar que es valido, y el when del render puede escribirse sin una sola rama defensiva.
- Entender el tipo anulable como suma minima y detectar cuando esconde un tipo suma que merece nombre.
- Encadenar
?.,?:yletpara tratar la ausencia sin condicionales anidados. - Reconocer la doble exclamacion como sintoma de un modelo mal hecho y saber qué reescribir.
- Usar value classes,
Nothingy varianza para que los tipos del estado no admitan mezclas invalidas.
La ausencia como parte del tipo
Antes de entrar en la mecanica conviene fijar el objetivo, porque es mas ambicioso que evitar una excepcion concreta. Lo que se persigue es que el conjunto de valores que un tipo admite coincida con el conjunto de situaciones que la pantalla puede vivir; cada valor de mas que el tipo autoriza es un camino que alguien tendra que cerrar a mano, y cada uno que falta es una situacion real que habra que representar de tapadillo.
Un tipo anulable es, formalmente, la suma mas pequeña que existe: o hay un valor del tipo base, o no hay nada. Escribirlo en la firma obliga a quien recibe el dato a decidir qué hacer con la ausencia antes de poder usarlo, y esa obligacion se verifica en compilacion y no en produccion.
data class PerfilState(
val nombre: String, // siempre hay nombre: el tipo lo garantiza
val avatar: Avatar?, // puede no haber: quien lo use debe tratarlo
)
Que nombre no sea anulable es una afirmacion fuerte y conviene leerla como tal: ningun camino del codigo puede producir un estado sin nombre, asi que ningun consumidor necesita comprobarlo. La ausencia de una comprobacion deja de ser un descuido y pasa a ser una consecuencia demostrada del tipo. Ese es el mecanismo por el que un modelo bien tipado reduce el codigo: no elimina las comprobaciones escondiendolas, las vuelve innecesarias.
Merece la pena señalar que la garantia es de compilacion, no de ejecucion, y que tiene dos grietas conocidas. La primera son los tipos plataforma que llegan de bibliotecas sin anotaciones de nulabilidad: Kotlin les concede el beneficio de la duda y permite usarlos sin comprobar, con lo que la excepcion vuelve a ser posible en la frontera. La segunda es la serializacion, que puede construir objetos saltandose los constructores y colocar un nulo donde el tipo prometia un valor. Ninguna de las dos invalida la disciplina; ambas indican dónde hay que ponerle atencion, que es exactamente en el punto donde los datos entran al sistema.
Otra propiedad valiosa del anulable es que se compone: List de elementos anulables y List anulable de elementos no anulables son tipos distintos y significan cosas distintas, y el compilador no los confunde. Poder expresar esa diferencia importa mas de lo que parece, porque en el modelado de estados casi siempre solo una de las dos formas tiene sentido y la otra es un error esperando.
Ahora bien, el anulable es una suma sin etiquetas, y por eso su gran limitacion es que no distingue motivos. Si el avatar es nulo, ¿es que el usuario no puso foto, que aun no ha cargado o que la peticion fallo? Tres situaciones distintas que el tipo colapsa en un mismo null, obligando a la vista a adivinar o al programador a añadir un booleano al lado —y ya estamos otra vez multiplicando el espacio de estados—. El anulable, en ese sentido, es un tipo suma con un caso anonimo: sabes que falta algo, pero no por qué, y esa informacion perdida acaba reconstruyendose a mano en otro sitio. La regla practica es nitida: el anulable sirve cuando la ausencia tiene un solo significado obvio; en cuanto hay dos motivos distintos para no tener el dato, el anulable es un tipo suma disfrazado y toca nombrarlo.
sealed interface EstadoAvatar {
data object SinFoto : EstadoAvatar
data object Cargando : EstadoAvatar
data class Fallo(val reintentable: Boolean) : EstadoAvatar
data class Listo(val url: String) : EstadoAvatar
}
Navegar la ausencia sin anidar condicionales
Kotlin ofrece un pequeño vocabulario para trabajar con anulables que, bien usado, elimina las escaleras de condicionales. ?. propaga la ausencia en vez de romper; ?: proporciona un valor alternativo o corta el flujo; let sobre un anulable ejecuta un bloque solo si hay valor. Encadenados leen de izquierda a derecha como una frase.
val iniciales = state.avatar?.nombreArchivo?.take(2)?.uppercase() ?: "??"
val urlSegura = state.avatar?.url
?: return // corte temprano: el elvis admite expresiones que no retornan
Merece la pena entender por qué la segunda forma compila. El operador elvis acepta a su derecha cualquier expresion, incluidas return y throw, cuyo tipo es Nothing: el tipo sin ningun valor, subtipo de todos los demas. Como Nothing encaja donde sea, el compilador puede afirmar que despues de esa linea la variable ya no es anulable. Es el mismo Nothing que aparece en las jerarquias genericas y que veremos al final; conocerlo convierte varias reglas aparentemente arbitrarias del lenguaje en consecuencias de una sola idea.
Al lado de let conviene conocer sus parientes del mismo grupo de funciones de alcance, porque se confunden a diario. let transforma y devuelve lo que produce el bloque; also ejecuta algo con el valor y devuelve el valor original; run y apply hacen lo mismo pero con el valor como receptor implicito. En codigo de estado, let sobre un anulable es el unico de los cuatro que interesa de verdad, y su virtud es que evita declarar una variable intermedia solo para poder comprobarla.
// Sin variable intermedia y sin condicional anidado
val etiqueta = state.avatar?.let { "Foto de ${it.autor}" } ?: "Sin foto"
Hay un caso donde la aritmetica de tipos importa mas de lo que parece: las colecciones. Una lista anulable y una lista vacia significan casi siempre lo mismo para la vista y sin embargo duplican los casos que el render debe cubrir. Salvo que la distincion entre “no hay lista” y “la lista esta vacia” sea semanticamente relevante —y si lo es, merece un caso con nombre—, una coleccion dentro del estado no debe ser anulable nunca: su valor por defecto es la coleccion vacia.
flowchart LR A[Dato ausente con un solo motivo] --> B[Tipo anulable basta] C[Dato ausente con varios motivos] --> D[Tipo suma con nombre por motivo] E[Coleccion] --> F[Nunca anulable valor por defecto vacia] G[Doble exclamacion] --> H[Sintoma de modelo mal hecho no de dato dificil] style B fill:#a6e3a1,color:#11111b style D fill:#a6e3a1,color:#11111b style H fill:#f38ba8,color:#11111b
La doble exclamacion es una confesion
El operador de asercion no nula convierte una garantia estatica en una apuesta en tiempo de ejecucion: le dices al compilador que confie en ti y aceptas que, si te equivocas, la aplicacion muera. Casi siempre que aparece en codigo de estado no significa “aqui el dato es dificil”, sino “aqui el modelo esta mal”. Vale la pena traducir los tres casos tipicos.
El primero es la inicializacion tardia: un campo se declara anulable porque no hay valor al construir, y luego se afirma en todos los usos. La solucion no es afirmar, es dar un valor por defecto sensato o modelar explicitamente el caso “aun no inicializado” como estado de carga. El segundo es la invariante no expresada: el dato existe solo cuando otro campo tiene cierto valor. Eso es exactamente un tipo suma esperando a ser escrito, y meter el dato dentro del caso correspondiente hace que la asercion sobre. Merece la pena distinguir la doble exclamacion de sus dos primos legitimos, porque no todos los operadores agresivos son iguales. lateinit sirve para dependencias que un contenedor inyecta antes del primer uso y falla con un mensaje util si no lo hizo; es aceptable en la infraestructura y jamas en el estado, entre otras cosas porque no admite tipos primitivos ni participa en copy. Y requireNotNull con un mensaje explicito, colocado en la frontera de entrada, documenta una precondicion y produce un fallo diagnosticable, que es exactamente lo contrario de una excepcion anonima en mitad del render.
El tercero es la frontera con codigo ajeno —una plataforma, una biblioteca sin anotaciones, un tipo de los que Kotlin llama plataforma—; ahi la afirmacion es legitima, pero su sitio es el mapeo de entrada, en una sola linea auditable, y nunca el interior del estado.
// Confesion: el dato solo existe si el modo es edicion, pero el tipo no lo dice
val id = state.idEditando!!
// Reescritura: la invariante vive en el tipo y la asercion desaparece
sealed interface Modo {
data object Creacion : Modo
data class Edicion(val id: TareaId) : Modo
}
Tipos que impiden la mezcla: value classes y varianza
La nulabilidad protege contra la ausencia, pero no contra la confusion. Si idUsuario y idPedido son ambos String, el compilador aceptara felizmente que los intercambies, y ese es un error caro que ningun test unitario suele atrapar porque ambos valores tienen el mismo aspecto. Las value classes resuelven el problema sin coste: envuelven un valor primitivo en un tipo propio que el compilador distingue y que, en la mayoria de los casos, desaparece en el bytecode dejando el primitivo desnudo.
@JvmInline value class TareaId(val valor: String)
@JvmInline value class UsuarioId(val valor: String)
fun borrar(id: TareaId) { /* ... */ }
// borrar(usuarioId) no compila: son tipos distintos aunque ambos envuelvan String
El sitio natural de esa envoltura es la frontera: el mapeo que traduce la respuesta del servidor construye los identificadores tipados una sola vez, y de ahi hacia dentro el sistema entero habla en tipos de dominio. Todo lo que viene despues —el estado, el reducer, la vista, las pruebas— hereda gratis la garantia de que un identificador de tarea nunca ocupara el lugar de uno de usuario.
data class TareasState(
val seleccionada: TareaId? = null, // no confundible con UsuarioId
val autor: UsuarioId,
)
La misma logica se extiende a los genericos cuando escribes contenedores reutilizables de estado. Un tipo como un resultado generico con un caso de exito parametrizado y un caso de fallo sin parametro necesita decidir su varianza: declararlo covariante en su parametro permite que un resultado de tarea se use donde se espera un resultado de cualquier cosa, tal como una lista de solo lectura de tareas encaja donde se espera una lista de elementos. Y el caso de fallo, que no transporta el tipo parametrizado, se declara sobre Nothing, precisamente porque Nothing es subtipo de todo y hace que ese unico objeto sirva para cualquier parametrizacion. Los tres conceptos —value class, varianza y Nothing— apuntan al mismo objetivo: que el conjunto de valores que el tipo admite coincida con el conjunto de valores que tienen sentido.
Las value classes admiten ademas un refinamiento que las convierte en autenticos tipos de dominio: validar en el bloque de inicializacion. Si un correo electronico solo es valido cuando cumple cierta forma, comprobarlo al construir significa que a partir de ese punto ningun consumidor tendra que volver a comprobarlo, porque el tipo solo existe si la validacion paso. Es el mismo movimiento de siempre —trasladar la comprobacion del uso a la construccion— aplicado al nivel mas fino posible.
@JvmInline value class Correo(val valor: String) {
init { require(valor.contains("@")) { "correo invalido" } }
}
Conviene conocer sus limites para no idealizarlas: la envoltura desaparece del bytecode solo en los casos favorables, y reaparece cuando el valor se usa como tipo generico, como anulable o a traves de una interfaz. Aun asi, el coste es despreciable comparado con el error que impiden, y ese error —cruzar dos identificadores del mismo tipo primitivo— es de los que llegan a produccion porque ninguna prueba lo mira.
Toda aplicacion habla con un exterior que no respeta tus tipos: respuestas de red con campos ausentes, bases de datos con columnas nulas, argumentos de navegacion que llegan como texto. La disciplina que funciona es concentrar toda la negociacion en la capa de mapeo y no dejarla filtrarse hacia dentro. En esa frontera se decide, con reglas explicitas, qué se hace con cada dato que no cumple: se sustituye por un valor por defecto, se descarta el elemento, o se convierte la respuesta entera en un caso de error. Lo que cruza hacia el estado ya es valido por construccion. La alternativa —arrastrar anulables desde el servidor hasta la vista— reparte esa negociacion entre decenas de puntos que la resuelven cada uno a su manera, y garantiza que un dia el render tenga que decidir qué pintar cuando falta un campo que nadie penso que pudiera faltar.
Hay una diferencia de naturaleza entre saber una regla y no poder violarla, y todo el diseño con tipos vive en esa diferencia. Cuando escribes en un comentario que el identificador solo esta presente en modo edicion, has documentado una invariante: alguien tendra que leerla, entenderla y respetarla cada vez que toque ese codigo, y bastara con que uno de esos alguien tenga prisa para que la invariante se rompa sin que nada proteste. Cuando en cambio metes el identificador dentro del caso Edicion de un tipo sellado, no has documentado nada: has hecho que la violacion sea inexpresable. Ya no existe un camino en el que el identificador falte estando en edicion, ni un camino en el que sobre estando en creacion, porque los valores que representarian esas situaciones no se pueden construir. La regla ha dejado de depender de la atencion humana y ha pasado a formar parte de la aritmetica del programa. Esa mudanza —de la disciplina al tipo— es la que explica por qué el codigo bien tipado tiende a ser mas corto y no mas largo, en contra de la intuicion de quien ve la anotacion de tipos como burocracia. Las lineas que desaparecen son las comprobaciones defensivas, los condicionales que nunca se cumplen, las ramas de error que existen solo por si acaso, los comentarios que explican lo que no se debe hacer; y desaparecen no porque se hayan escondido, sino porque el caso que las justificaba dejo de ser posible. Vale la pena leer la nulabilidad de Kotlin bajo esa luz, como el primer eslabon de una cadena que continua en las value classes, en los tipos suma y en la varianza: mecanismos distintos con un mismo proposito, que es estrechar el conjunto de lo representable hasta que coincida con el conjunto de lo que tiene sentido. Cuando esa coincidencia se alcanza, la validacion no se automatiza: se vuelve superflua.
- Recorre el estado de una pantalla tuya y, para cada campo anulable, escribe en una frase qué significa que sea nulo; si necesitas mas de una frase, conviertelo en tipo suma con nombre.
- Busca cada coleccion anulable y sustituyela por una coleccion no anulable con valor por defecto vacio, salvo que puedas defender que ausente y vacia significan cosas distintas.
- Localiza toda doble exclamacion del proyecto, clasificala en inicializacion tardia, invariante no expresada o frontera con codigo ajeno, y reescribe las dos primeras categorias.
- Introduce value classes para al menos dos identificadores del mismo tipo primitivo y comprueba que el compilador rechaza el intercambio que antes aceptaba.
- Diseña un contenedor generico de resultado con un caso de fallo sin parametro y explica por qué ese caso se declara sobre
Nothingy qué te permite hacer la covarianza.