wandres.dev
ERRORES AVANZADOS · typed throws y diseño

typed throws: declarar qué error puede salir

La firma que nombra el tipo del fallo, la unificación de lanzar, no lanzar y relanzar bajo una sola gramática, el catch exhaustivo sin cláusula general, la desaparición de la caja existencial y el precio real: el error entra en tu API y componer dos dominios deja de ser gratis.

⏱ 19 min

Durante diez años la firma de una función Swift confesaba que podía fallar y callaba con qué. Esa asimetría era rara en un lenguaje que presume de precisión: el tipo de retorno se declara al detalle y el tipo del fallo se deja en any Error, una caja opaca que el llamante abre a ciegas con coincidencia de patrones y una cláusula final por si acaso. typed throws cierra el hueco añadiendo un paréntesis a throws. Pero la propuesta es mucho más ambiciosa de lo que aparenta: no añade una variante más, sino que unifica bajo una sola gramática los tres estados en que puede estar una función respecto al fallo, y de paso resuelve un problema de rendimiento que era invisible desde el fuente. El riesgo, igual de real, es usarla en todas partes.

🎯 Al terminar esta lección sabrás
  • Escribir y leer firmas con tipo de error declarado, incluidos los casos límite de Never y any Error.
  • Explicar cómo la sintaxis unifica lanzar, no lanzar y relanzar en un único mecanismo.
  • Enumerar lo que gana quien llama: exhaustividad, información en el catch y ausencia de caja existencial.
  • Decidir con criterio cuándo tipar el error mejora una API y cuándo la vuelve rígida.

La firma que nombra el fallo

La sintaxis cabe en una línea: entre paréntesis, después de throws, va el tipo del error.

enum ErrorDeAnalisis: Error {
    case tokenInesperado(linea: Int)
    case finPrematuro
}

func analizar(_ texto: String) throws(ErrorDeAnalisis) -> Documento {
    guard !texto.isEmpty else { throw .finPrematuro }   // el tipo se infiere
    ...
}

Dos detalles pequeños con consecuencias grandes. El primero: como el compilador conoce el tipo, puedes lanzar con la forma abreviada de punto, sin repetir el nombre del enum. El segundo: dentro de esa función solo puedes lanzar errores de ese tipo exacto, así que cualquier llamada a otra cosa que falle debe traducirse antes de propagarse. El compilador te obliga a hacer explícita una decisión que antes tomaba por ti al ensanchar todo a any Error.

Lo verdaderamente elegante es que la novedad no añade un tercer estado, sino que revela que siempre hubo uno solo. Una función que no lanza es una función que lanza Never, el tipo sin habitantes: prometer lanzar algo que no puede existir es prometer no lanzar. Y la vieja palabra throws a secas es azúcar para throws(any Error).

func a() -> Int                        // equivale a throws(Never)
func b() throws -> Int                 // equivale a throws(any Error)
func c() throws(ErrorDeAnalisis) -> Int

Esa unificación es la que permite expresar el polimorfismo de efectos con genéricos ordinarios, en lugar de con una palabra clave especial, y es el motivo real por el que la propuesta se aceptó.

La misma anotación aparece en dos sitios más, y conviene conocerlos porque completan el cuadro. Un bloque do admite declarar el tipo de error que va a manejar, lo que fija la inferencia cuando el cuerpo mezcla varias fuentes. Y las clausuras siguen la regla habitual: el tipo se infiere del cuerpo salvo que el contexto lo imponga, de modo que una clausura que no lanza pasa por Never sin que escribas nada.

do throws(ErrorDeAnalisis) {
    try analizar(fuente)
} catch {
    // aquí `error` es de tipo ErrorDeAnalisis, no un existencial
    señalarEnEditor(error)
}
flowchart TB
G[throws de E generico] --> N[E igual a Never]
G --> C[E igual a un tipo concreto]
G --> A[E igual a any Error]
N --> R1[La funcion no lanza]
C --> R2[Fallo tipado y exhaustivo]
A --> R3[Comportamiento clasico]
style G fill:#cba6f7,color:#11111b
style R1 fill:#a6e3a1,color:#11111b
style R2 fill:#89b4fa,color:#11111b

Lo que gana quien llama

Tres cosas concretas, y conviene separarlas porque no pesan lo mismo.

La primera es la exhaustividad. Con el tipo declarado, el bloque do se comporta como un switch sobre un enum: si cubres todos los casos, no necesitas cláusula general, y si mañana añades un caso al enum, el compilador te lleva a todos los sitios que dejaron de estar completos. Esa es la diferencia entre una lista de errores documentada en un comentario y una verificada por la máquina.

do {
    let doc = try analizar(fuente)
    mostrar(doc)
} catch .tokenInesperado(let linea) {
    señalar(linea)
} catch .finPrematuro {
    pedirMasTexto()
}                                   // sin `catch` final: ya está completo

La segunda es la información dentro del manejador. Antes, recuperar los datos asociados exigía coincidencia de patrones contra un existencial, y todo lo que no encajara caía en un cajón donde error era any Error y no se podía interrogar sin conversiones condicionales. Ahora la variable capturada llega con su tipo y sus valores asociados accesibles directamente.

La tercera es el coste. Lanzar un existencial implica, en el caso general, una caja en el montón; un error tipado viaja como un valor concreto en registros si cabe. Para código de aplicación esto es ruido, pero para Swift embebido —donde puede no haber asignador— es la diferencia entre poder usar errores y no poder. No es casualidad que la propuesta cite ese entorno como una de sus tres motivaciones.

ℹ️
Never no es una curiosidad

Que el tipo vacío signifique ausencia de efecto no es un truco sintáctico: es la lectura correcta de Never como tipo sin valores. Una función throws(Never) no puede construir jamás un valor para lanzar, luego su cláusula catch sería código inalcanzable, luego el compilador la trata como no lanzadora. Lo mismo explica por qué Never conforma a Error desde Swift 6 y por qué Result con fallo Never es un contenedor que nunca falla.

El precio: el error entra en tu API

Aquí es donde la mayoría se equivoca. Declarar el tipo del error lo convierte en parte del contrato público, con todas las obligaciones de compatibilidad que eso arrastra. Añadir un caso a un enum de errores tipado es un cambio que rompe la exhaustividad de todos los do de tus consumidores, exactamente igual que ocurre con cualquier enum público no congelado. Lo que antes era una ampliación silenciosa pasa a ser una versión mayor.

El segundo coste es la composición. Dos funciones con errores tipados distintos no se encadenan sin más: la que las llame debe elegir un tipo común y traducir.

func cargar() throws(ErrorDeRed) -> Data
func analizar(_ d: Data) throws(ErrorDeAnalisis) -> Documento

func obtener() throws(ErrorDeCaso) -> Documento {
    let datos: Data
    do { datos = try cargar() }
    catch { throw ErrorDeCaso.red(error) }        // traducción obligatoria

    do { return try analizar(datos) }
    catch { throw ErrorDeCaso.formato(error) }
}

Ese código no es peor que la alternativa; es más honesto y bastante más largo. En una capa donde las traducciones son significativas, el esfuerzo se paga solo. En una capa de pegamento que solo pasa cosas hacia arriba, es burocracia pura, y ahí any Error sigue siendo la respuesta correcta.

Hay una tentación que conviene desactivar de entrada: crear un enum enorme que reúna todos los errores posibles del programa para poder tiparlo todo. Eso convierte el tipo declarado en una etiqueta sin poder informativo —lanza cualquier cosa, otra vez— y encima obliga a todo el mundo a manejar casos imposibles en su contexto. Un tipo de error que no restringe nada es peor que any Error, porque miente.

Cuándo conviene de verdad

Tres escenarios lo justifican con claridad, y fuera de ellos el valor cae deprisa.

Código genérico que reenvía el fallo de su llamante. Es la motivación principal de la propuesta y el tema de la lección sobre rethrows: una función de orden superior que toma una clausura lanzadora y quiere devolver ese mismo tipo de error, sin ensancharlo a existencial. Aquí el tipo no lo eliges tú, lo recibes como parámetro genérico.

Entornos con restricciones de recursos. Swift embebido, código sin asignador, rutas críticas medidas donde la caja del existencial aparece en el perfil.

Dominios cerrados y pequeños. Un analizador, una máquina de estados, un decodificador: un módulo con una lista de fallos que realmente está completa y que no crecerá cada trimestre.

🍊

Genérico que reenvía

El tipo del fallo llega como parámetro y sale sin ensancharse. Es el caso que motivó la propuesta y donde el beneficio es incondicional.

🪶

Sin asignador

Swift embebido y rutas medidas: el error viaja como valor concreto y desaparece la caja en el montón.

🔒

Dominio cerrado

Un analizador, un decodificador, una máquina de estados. La lista de fallos está completa de verdad y no crece cada trimestre.

🚪

Frontera pública

Aquí conviene lo contrario. El existencial deja sitio para causas nuevas sin romper la exhaustividad de nadie.

Y el criterio negativo, igual de útil: si el error va a cruzar la frontera pública de una biblioteca con evolución activa, o si viaja por capas de una aplicación donde cada nivel añade causas nuevas, tiparlo compra poco y ata mucho. La regla práctica que resiste el uso es simple: tipa hacia dentro, ensancha hacia fuera. Dentro de un módulo, donde tú controlas a todos los consumidores y una recompilación completa es barata, la exhaustividad verificada es una red de seguridad real. En la frontera, donde los consumidores se recompilan cuando quieren y una versión mayor cuesta meses, la misma exhaustividad se convierte en una cadena.

La propuesta no trataba de errores: trataba de completar el sistema de efectos con genéricos

Si lees typed throws como una comodidad para escribir mejores catch, te pierdes lo que ocurrió de verdad. Swift tenía tres formas separadas y ad hoc de hablar del efecto de lanzar: la ausencia de la palabra, la palabra sola y rethrows, esta última una construcción especial del compilador con reglas propias, imposible de expresar en el lenguaje y por tanto imposible de extender. Lo que hace la propuesta es demostrar que las tres son instancias de una sola familia parametrizada por un tipo, con Never y any Error en los extremos del retículo y cualquier tipo concreto en medio. En cuanto el efecto se convierte en un parámetro genérico ordinario, deja de necesitar tratamiento especial: se puede propagar, restringir con where, inferir, y componer con el resto del sistema de tipos. Eso es exactamente el patrón que ha seguido Swift una y otra vez —los tipos opacos generalizaron los retornos existenciales, los paquetes de parámetros generalizaron las sobrecargas de aridad fija, las macros generalizaron la síntesis privilegiada del compilador—: coger un caso especial cocinado dentro del compilador y reemplazarlo por un mecanismo general que cualquiera pueda usar. La lección de ingeniería que te llevas no es sintáctica sino epistemológica: cuando un lenguaje tiene tres palabras clave para lo que resulta ser una sola idea con tres valores, esa multiplicidad es una deuda de diseño esperando a que alguien encuentre el parámetro que faltaba. Y la contrapartida, que en esta propuesta se dice con inusual franqueza, es que ganar precisión en un tipo siempre significa perder libertad para cambiarlo: cada dato que mueves de la documentación al sistema de tipos es un dato que tus consumidores podrán verificar y que tú ya no podrás modificar en silencio.

⚔️ Tipa, compón y arrepiéntete a tiempo
  1. Convierte una función lanzadora tuya a error tipado y elimina la cláusula general de todos sus llamantes; añade después un caso al enum y observa qué rompe.
  2. Escribe una firma genérica con el tipo de error como parámetro y compruébala instanciada con Never y con un tipo concreto.
  3. Encadena dos funciones con errores tipados distintos y escribe la traducción; mide cuántas líneas cuesta frente a la versión con any Error.
  4. Justifica por escrito, para tres funciones públicas de un proyecto, si tiparlas mejora o empeora su evolución.
  5. Construye un caso donde tipar el error empeore claramente la API y explica el mecanismo exacto del daño.