Las enumeraciones son una de las herramientas más expresivas de Swift. Un switch exhaustivo obliga a contemplar todos sus casos y convierte muchos cambios del modelo en errores de compilación fáciles de localizar. Esa ventaja, sin embargo, se convierte en una limitación cuando la enumeración forma parte de la API pública de un paquete.
Desde Swift 6.2.3, la propuesta SE-0487 permite marcar una enumeración con @nonexhaustive. El atributo comunica que los casos publicados son los conocidos hoy, pero que podrían aparecer más en versiones posteriores.
El problema de las enumeraciones públicas
En un módulo no resiliente frente a cambios, como los que normalmente distribuye Swift Package Manager, una enumeración pública se considera cerrada. El código cliente puede procesarla sin incluir un caso genérico:
// Módulo SyncKit
public enum SyncState {
case idle
case uploading(progress: Double)
case completed
}
// Aplicación que importa SyncKit
func statusText(for state: SyncState) -> String {
switch state {
case .idle:
"En espera"
case .uploading(let progress):
"Subiendo: \(progress.formatted(.percent))"
case .completed:
"Completado"
}
}
Si SyncKit añade después un estado como .paused, el switch deja de ser exhaustivo y la aplicación ya no compila al actualizar la dependencia. Aunque el cambio parezca aditivo, desde el punto de vista de la compatibilidad de código fuente es una ruptura de la API y exigiría una nueva versión mayor según el versionado semántico.
Esto resulta útil dentro de una aplicación: al añadir un caso, el compilador señala todos los lugares que deben adaptarse. Para una librería que no controla el código de sus consumidores, en cambio, puede convertir cualquier enumeración que deba crecer en una trampa de diseño.
Reservar espacio para casos futuros
El autor del paquete puede declarar desde el principio que la enumeración no es exhaustiva:
@nonexhaustive
public enum SyncState {
case idle
case uploading(progress: Double)
case completed
}
Fuera del paquete, un switch que enumere únicamente esos tres casos ya no será suficiente. En el modo de lenguaje Swift 6, el compilador exige una rama capaz de recibir valores futuros:
func statusText(for state: SyncState) -> String {
switch state {
case .idle:
"En espera"
case .uploading(let progress):
"Subiendo: \(progress.formatted(.percent))"
case .completed:
"Completado"
@unknown default:
"Estado no disponible"
}
}
La rama @unknown default no es equivalente a un default normal. Ambas capturan cualquier valor no procesado, pero la primera mantiene la ayuda del compilador: cuando una versión nueva de SyncKit incorpore .paused y la aplicación vuelva a compilar, Swift avisará de que existe un caso conocido que no se ha tratado de forma explícita. El programa puede seguir compilándose gracias a la alternativa genérica, mientras el aviso invita a adoptar el nuevo caso.
Un default convencional silenciaría también ese aviso. Por eso @unknown default es la opción adecuada cuando interesa conservar la capacidad de evolución sin renunciar a descubrir cambios en la dependencia.
Dentro del paquete sigue siendo exhaustiva
@nonexhaustive afecta al código que consume la enumeración desde otro paquete. Los módulos que pertenecen al mismo paquete se consideran una unidad desarrollada y versionada conjuntamente, por lo que pueden continuar usando switch exhaustivos sin @unknown default.
Esta distinción conserva una propiedad importante: si el propio paquete añade .paused, sus implementaciones internas dejan de compilar allí donde todavía no lo gestionen. Los consumidores externos, sobre los que el autor no tiene control, disponen en cambio de la ruta de compatibilidad que definieron para valores futuros.
No es lo mismo que activar la evolución de librerías
Swift ya permitía que las enumeraciones de módulos compilados con -enable-library-evolution ganasen casos. En esas librerías resilientes, las enumeraciones públicas son no exhaustivas por defecto y @frozen se utiliza para prometer que su lista de casos no cambiará.
@nonexhaustive lleva esa decisión a los módulos no resilientes y, en especial, a los paquetes distribuidos como código fuente. No convierte por sí solo una librería en estable a nivel binario ni activa la evolución de su ABI. Su objetivo es que añadir un caso pueda ser compatible a nivel de código fuente.
Los atributos @nonexhaustive y @frozen expresan contratos opuestos y el compilador no permite aplicarlos a la vez. El primero reserva la posibilidad de crecer; el segundo garantiza que la forma del tipo permanecerá cerrada.
Cómo migrar una enumeración existente
Añadir directamente @nonexhaustive a una enumeración ya publicada también rompe el código de los clientes cuyos switch no tienen una rama genérica. Para preparar la transición existe @nonexhaustive(warn):
@nonexhaustive(warn)
public enum SyncState {
case idle
case uploading(progress: Double)
case completed
}
Con esta variante, los consumidores reciben un aviso para incorporar @unknown default, pero sus compilaciones no fallan todavía por ese motivo. En una versión mayor posterior se puede sustituir por @nonexhaustive y, después, empezar a añadir casos sin romper a quienes hayan atendido el aviso.
La migración gradual reduce la sorpresa, pero no elimina por completo la necesidad de una versión mayor: los consumidores pueden ignorar el aviso. El atributo permite anunciar el contrato futuro con antelación y repartir el trabajo entre varias versiones.
Cuándo merece la pena utilizarlo
@nonexhaustive encaja en enumeraciones públicas que representan errores, estados de protocolos, respuestas de servicios o conceptos definidos por estándares que pueden ampliarse. También evita recurrir a estructuras con propiedades estáticas o a un caso genérico con un valor asociado solo para conservar margen de evolución.
No debería aplicarse por rutina. Para conjuntos realmente cerrados, como los ejes de una cuadrícula o los resultados de una comparación, la exhaustividad estricta sigue siendo una garantía valiosa. Tampoco suele aportar ventajas en código de aplicación que se compila y publica como una sola unidad, donde interesa que un caso nuevo obligue a revisar cada decisión afectada.
La rama desconocida debe ofrecer además un comportamiento seguro y útil. Terminar siempre con fatalError conserva la compilación, pero anula en la práctica la tolerancia que buscaba el atributo. Mostrar una representación neutra, registrar el valor inesperado o degradar una función de forma controlada suele ser una estrategia más coherente.
Un contrato explícito para evolucionar
@nonexhaustive no cambia la naturaleza de las enumeraciones: sigue siendo el autor del tipo quien decide qué valores puede crear. Lo que cambia es el contrato con los consumidores. La API deja claro desde su primera versión que la lista visible no tiene por qué ser definitiva, y cada switch externo debe definir qué hará cuando el paquete evolucione.
Con esa pequeña anotación, Swift mantiene las ventajas del modelado mediante enumeraciones y evita que una ampliación razonable obligue a rediseñar el tipo o a publicar una nueva versión mayor. La exhaustividad continúa donde el código se desarrolla conjuntamente; en los límites de una dependencia, se sustituye por una compatibilidad deliberada y verificable por el compilador.