Diseñar con opcionales: cuándo sí y cuándo esconden un enum
El opcional como decisión de modelado y no como recurso por defecto. Qué afirma en una firma, por qué varios opcionales correlacionados son siempre un enum disfrazado, dónde colocar la validación y qué opcionales degenerados conviene borrar del dominio.
Las cuatro lecciones anteriores tratan de cómo manejar un opcional que ya existe. Esta trata de la decisión anterior, que es la que de verdad determina la calidad del código: si ese opcional debía existir. Un opcional bien colocado captura una ausencia real del dominio y hace imposible ignorarla. Uno mal colocado es un enum aplastado hasta perder sus nombres, y todo el trabajo de desenvolver que viene después es el precio de esa pérdida.
- Leer qué afirma exactamente un opcional cuando aparece en una firma pública.
- Detectar opcionales correlacionados y reconstruir el enum que ocultan.
- Colocar la validación en la frontera y mantener el núcleo sin opcionales.
- Eliminar los opcionales degenerados que no añaden ningún estado.
Qué afirma un opcional en una firma
Un opcional en un tipo público es una declaración muy concreta: este valor puede faltar, su ausencia es un estado normal del dominio y no hay ninguna causa que explicar. Cuando las tres partes son ciertas, el opcional es la abstracción correcta y no hay nada mejor.
extension Array { var first: Element? { get } } // un array vacío no tiene primero
let n = Int("abc") // esa cadena no es un número
struct Persona { var segundoApellido: String? } // hay culturas sin segundo apellido
En cuanto una de las tres partes falla, el opcional se queda corto. Si la ausencia tiene causa —fichero ilegible, servidor caído, campo mal formado— el tipo correcto es Result o una función que lance, porque el nil tira la única información que el usuario del API necesitaba. Si la ausencia no es normal sino un fallo del programa, el tipo correcto es no tener opcional y establecer la invariante en el constructor. Y si la ausencia depende de otros campos, entonces no era una ausencia: era un caso de un enum, y es el asunto de la sección siguiente.
Tres conceptos que la costumbre confunde. La ausencia es normal y no lleva explicación: Optional. El fallo es anómalo, tiene causa y quien llama puede reaccionar: throws o Result. El error de programación es una invariante rota que nadie puede reparar en ejecución: precondition y muerte inmediata. Elegir el mecanismo equivocado no rompe el programa, pero obliga a todos los que lo usen a inventarse la información que tú borraste.
Varios opcionales correlacionados son un enum disfrazado
Este es el diagnóstico más rentable de todo el nivel. Cuenta los estados que tu tipo puede representar y compáralos con los que tu dominio admite de verdad:
struct EstadoPantalla {
var cargando: Bool
var articulos: [Articulo]?
var error: Error?
}
Ese tipo admite ocho combinaciones. El dominio tiene cuatro estados, y las cuatro sobrantes son absurdas: cargando con error, datos y error a la vez, ni datos ni error ni carga. Cada una de esas combinaciones imposibles es un if defensivo que alguien escribirá, un caso que nadie probará y un fallo que aparecerá el día de la demostración. La aritmética lo dice sin ambigüedad: un opcional tiene tantos habitantes como el tipo envuelto más uno, y un struct los multiplica. El enum, en cambio, los suma.
enum EstadoPantalla {
case inactivo
case cargando
case cargado([Articulo])
case fallo(Error)
}
Cuatro estados, exactamente cuatro, con nombres del dominio y con exhaustividad garantizada por el compilador. Además, cada carga vive donde tiene sentido: los artículos solo existen dentro de cargado, así que ya no hay forma de leerlos mientras se carga. El mismo diagnóstico funciona en dominios menos evidentes:
// Antes: dos opcionales que solo tienen sentido juntos.
struct Cuenta { var correo: String?; var verificadoEn: Date? }
// Después: los estados que existen de verdad, y ninguno más.
enum Correo {
case ninguno
case sinVerificar(String)
case verificado(direccion: String, fecha: Date)
}
flowchart TD
A[Un valor puede faltar] --> B{La ausencia tiene causa}
B -- Si --> C[Usa Result o una funcion que lanza]
B -- No --> D{Depende de otros campos}
D -- Si --> E[Es un enum: da nombre a cada estado]
D -- No --> F{El vacio del tipo ya lo expresa}
F -- Si --> G[Quita el opcional: lista vacia o cadena vacia]
F -- No --> H[Optional es la abstraccion correcta]
style C fill:#f9e2af,color:#11111b
style E fill:#cba6f7,color:#11111b
style H fill:#a6e3a1,color:#11111bLa validación va en la frontera, no en el núcleo
Si los opcionales se te reproducen por todo el código, casi siempre es porque la validación ocurre tarde y en muchos sitios. La disciplina que lo corta es sencilla: los datos entran opcionales y salen validados, y esa conversión ocurre una sola vez, en el borde del sistema.
// Frontera: refleja el JSON tal cual llega, con toda su incertidumbre.
struct UsuarioDTO: Decodable {
let id: String?
let correo: String?
}
// Núcleo: si existe una instancia, sus datos son válidos. No hay nada que comprobar.
struct Usuario {
let id: UUID
let correo: Correo
init(dto: UsuarioDTO) throws {
guard let bruto = dto.id, let id = UUID(uuidString: bruto) else {
throw ValidacionError.idInvalido
}
self.id = id
self.correo = dto.correo.map(Correo.sinVerificar) ?? .ninguno
}
}
El beneficio no es cosmético. Cada campo que dejas de declarar opcional en el núcleo elimina todos los desenvolvimientos que ese campo habría provocado en cada pantalla, cada prueba y cada informe. Y traslada el fallo al único punto donde alguien puede hacer algo útil con él: el momento en que los datos entran. Un init? sirve cuando la única respuesta posible es “no era válido”; un constructor que lanza es mejor en cuanto quieras decir por qué.
Opcionales degenerados: los que no añaden ningún estado
Quedan los casos en que el opcional es redundante porque el tipo envuelto ya sabe representar el vacío.
[Articulo]?— un array vacío ya significa “no hay artículos”. Los tres estados solo se justifican sinilsignifica otra cosa, como “aún no se ha cargado”, y en ese caso lo que necesitas es el enum de estado de la sección anterior.Bool?— tres valores para algo que llamas booleano. Casi siempre hay tres nombres del dominio esperando:sinResponder,si,no.String?conviviendo con la cadena vacía — dos representaciones del mismo hecho, comparadas de forma inconsistente en sitios distintos. Normaliza en la frontera y quédate con una.- El opcional doble en un API público — señal casi segura de un modelado a medias. La excepción legítima existe: distinguir campo ausente de campo enviado como nulo en una actualización parcial, donde la diferencia es real y significativa.
Esa última excepción merece verse escrita, porque es el ejemplo canónico de un opcional doble que sí gana su sitio. En una actualización parcial hay tres intenciones distintas y las tres deben viajar por la red:
struct ParcheUsuario: Encodable {
// ausente: no tocar el campo. Presente y vacío: borrarlo. Presente con valor: asignarlo.
var apodo: String??
}
Fíjate en que la justificación es exactamente la misma que rechaza los otros casos: aquí el dominio tiene tres estados, así que el tipo debe tener tres. Aun así, un enum con los nombres sinCambios, borrar y asignar diría lo mismo con más claridad, y solo la conveniencia de la codificación automática inclina la balanza.
Antes de aceptar un campo opcional, intenta escribir un constructor que lo prohíba. Si consigues que el tipo se construya siempre con ese dato presente, sobraba el opcional. Si no lo consigues sin inventarte un valor de relleno, el opcional era real y acabas de demostrarlo. La prueba tarda un minuto y decide bien casi siempre.
Aquí se cierra el nivel entero, y la idea es más grande que los opcionales. Todo tipo que declaras es una afirmación sobre cuántas situaciones distintas puede haber en tu programa; el compilador se toma esa afirmación al pie de la letra y te obliga a manejarlas todas. Si tu tipo admite ocho combinaciones y tu dominio tiene cuatro estados, has creado cuatro situaciones imposibles que sin embargo se pueden escribir, y a partir de ahí el trabajo no consiste en programar el dominio sino en defenderse de tu propio modelo: comprobaciones redundantes, ramas inalcanzables, comentarios que suplican que esa combinación nunca ocurra. Al revés también duele: si el tipo admite menos estados de los que existen, aparecen los valores centinela —el cero que significa “no medido”, la cadena vacía que significa “todavía no”, la fecha de 1970 que significa “nunca”— y con ellos vuelve exactamente el problema que el opcional venía a resolver, solo que ahora sin ayuda del compilador. El objetivo es la igualdad exacta: tantos habitantes como estados, ni uno más. Optional es la herramienta perfecta para el caso concreto en que el dominio tiene un estado extra sin nombre y sin causa; cuando el estado extra sí tiene nombre, ponle nombre; cuando tiene causa, ponle causa. Elegir eso bien es lo que hace que el resto del programa se escriba casi solo, porque los estados imposibles dejan de poder teclearse.
- Busca un
structtuyo con dos o más opcionales, cuenta sus combinaciones posibles y anota cuántas son legales de verdad. - Reescríbelo como enum con un caso por estado real y comprueba cuántos
if letdesaparecen de quien lo usa. - Separa un tipo que hoy hace de frontera y de núcleo a la vez en un tipo de transferencia con opcionales y otro validado sin ellos.
- Localiza un
[T]?o unBool?y decide si el tercer estado existe; si existe, dale nombre, y si no, bórralo. - Elige una función que devuelva un opcional donde la ausencia sí tiene causa y cámbiala para que lance, midiendo qué información gana quien la llama.