Result: el error como dato y el puente entre dos mundos
Cuándo conviene que un fallo sea un valor que se guarda, se transporta y se inspecciona en lugar de un efecto que se propaga. La isomorfía con throws, las conversiones en ambos sentidos, los territorios que Result conserva tras async await y los lugares donde estorba.
throws y Result describen exactamente la misma situación —una operación con dos desenlaces— y sin embargo no son intercambiables. La diferencia no está en la información que transportan, que es idéntica, sino en cuándo obligan a mirarla. Un efecto se propaga solo y exige atención en el instante en que ocurre; un valor se queda quieto, se guarda en una propiedad, se mete en un array, se envía por un canal y espera a que alguien decida. Elegir entre uno y otro no es cuestión de gusto ni de escuela funcional: es una pregunta sobre el momento y el lugar donde tu programa quiere tomar la decisión, y hay una respuesta correcta en cada caso.
- Enunciar la isomorfía entre una función lanzadora y una que devuelve un fallo como valor, y por qué no implica equivalencia práctica.
- Convertir en ambos sentidos con el inicializador de captura, la extracción y las transformaciones del tipo.
- Identificar los territorios donde el error como dato sigue siendo superior tras la llegada de la concurrencia estructurada.
- Reconocer los usos en que el tipo estorba y degrada la ergonomía de quien llama.
Un fallo que se puede guardar
El tipo es un enum de dos casos con una restricción sobre el fallo, y su definición explica por sí sola casi todo lo que sigue.
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
Lo decisivo es que el fallo es un valor de primera clase. Puedes almacenarlo en una propiedad, meterlo en un diccionario junto a otros noventa y nueve resultados, compararlo si el tipo es equatable, escribirlo en una prueba unitaria como valor esperado y decidir mañana qué hacer con él. Nada de eso es posible con un error lanzado, que exige ser atendido en el marco inmediatamente superior o propagado.
De ahí sale el criterio que separa los dos mundos y que conviene fijar antes que cualquier detalle de API:
throws: cortocircuito
El primer fallo interrumpe la secuencia. Perfecto cuando lo que sigue carece de sentido sin lo anterior.
Result: acumulación
Cada elemento produce su desenlace y todos sobreviven. Necesario cuando quieres el mapa completo de qué funcionó y qué no.
Aplazar la decisión
El fallo viaja como dato hasta la capa que sabe interpretarlo, sin obligar a las intermedias a capturarlo.
Comparar y verificar
Un valor se puede afirmar en una prueba. Un efecto hay que provocarlo y atraparlo para poder examinarlo.
El caso donde la diferencia se ve mejor es el procesamiento por lotes. Con propagación, veinte archivos y un fallo en el tercero dejan diecisiete sin procesar y ninguna información sobre ellos. Con el fallo como valor tienes las veinte respuestas y decides después.
let resultados: [Result<Documento, ErrorDeAnalisis>] = rutas.map { ruta in
Result { try analizar(ruta) }
}
let logrados = resultados.compactMap { try? $0.get() }
let fallidos = resultados.compactMap { resultado -> ErrorDeAnalisis? in
guard case .failure(let e) = resultado else { return nil }
return e
}
El puente, en las dos direcciones
Las conversiones son deliberadamente triviales, porque la biblioteca estándar asume que vas a cruzar la frontera a menudo.
Del mundo del efecto al del dato, un inicializador que captura la clausura lanzadora. Del dato al efecto, un método que devuelve el éxito o relanza el fallo.
let r = Result { try cobrar(100) } // efecto → dato
let recibo = try r.get() // dato → efecto
Entre medias, el tipo ofrece las cuatro transformaciones esperables, y la clave es que ninguna desempaqueta: encadenas sin abrir la caja y solo decides al final.
let identificador = Result { try cobrar(100) }
.map(\.identificador) // transforma el éxito
.mapError(ErrorDeCaso.pago) // traduce el fallo
.flatMap(registrarEnDisco) // encadena otra operación fallable
mapError merece una nota aparte porque es la herramienta de traducción entre capas cuando trabajas con fallos tipados: el tipo es invariante en su parámetro de fallo, así que dos resultados con errores distintos no se combinan hasta que uno de los dos se traduce.
Y conviene ver la isomorfía escrita, porque explica por qué Swift eligió el efecto como mecanismo principal:
func f() throws(E) -> T // ≅
func f() -> Result<T, E>
Ambas firmas transportan la misma información. La primera obtiene, gratis, la propagación automática, la marca visible en el punto de uso, la interacción con defer y la posibilidad de escribir código lineal. La segunda obliga a desempaquetar a mano en cada paso. Por eso Swift no adoptó el estilo de valor como forma canónica: encadenar transformaciones es más costoso de leer que escribir tres líneas seguidas con try, y el lenguaje optimizó para el caso frecuente.
flowchart LR E[Efecto que se propaga] -->|Result de bloque try| D[Dato que se guarda] D -->|metodo get con try| E D --> S1[Guardar en una propiedad] D --> S2[Acumular en una coleccion] D --> S3[Cruzar una frontera de tarea] E --> P1[Propagacion automatica] E --> P2[Marca visible con try] style E fill:#f38ba8,color:#11111b style D fill:#89b4fa,color:#11111b
La flecha de vuelta importa tanto como la de ida. Un diseño sano no elige un mundo y se queda en él: usa el efecto dentro de cada capa y el dato solo en las fronteras donde el tiempo se separa. Cruzar en el punto correcto cuesta una línea; cruzar en el incorrecto contamina todas las firmas que hay por encima.
Dónde sigue ganando
La llegada de la concurrencia estructurada le quitó su uso más masivo: los manejadores de finalización con dos parámetros opcionales, aquella firma incómoda donde ambos podían ser nulos a la vez. Hoy eso se escribe con async throws y el tipo desapareció de la mitad de las bases de código. Pero conserva cuatro territorios donde nada lo sustituye.
Resultados parciales de un grupo de tareas. Cuando lanzas veinte tareas concurrentes y quieres las veinte respuestas, no la primera que falle, el elemento que recorre el grupo debe ser un dato con dos desenlaces.
El desenlace de una tarea guardado. La propiedad que expone el resultado de una tarea ya terminada es exactamente esto: un valor que puedes consultar cuando quieras.
Estado de una interfaz. Una vista que muestra contenido, error o carga necesita el fallo como parte de su estado observable, no como algo que ocurrió una vez y se fue.
Caché y reintentos. Recordar que algo falló, con qué error y hace cuánto, exige almacenarlo. Un efecto no se almacena.
Devolver este tipo desde funciones síncronas ordinarias de tu propio código. Quien llama pierde try, pierde la propagación automática y gana un switch en cada nivel intermedio que no tiene nada que decidir. Si el llamante inmediato va a desempaquetar para volver a envolver, el efecto era la herramienta correcta desde el principio.
Dónde estorba
Hay tres patrones que aparecen con regularidad y que conviene reconocer antes de escribirlos.
El primero es envolver para desenvolver. Una capa devuelve el fallo como dato, la siguiente lo extrae con la conversión inversa, se lo pasa a la tercera envuelto otra vez. Cada frontera paga una traducción que no aporta ninguna decisión. Eso no es programación funcional, es ceremonia.
El segundo es la cadena ilegible. Cinco transformaciones encadenadas donde cada una puede fallar producen un tipo anidado y un mensaje de error del compilador que habla de genéricos que nadie escribió. La versión con do y tres try seguidos dice lo mismo en menos espacio y se depura poniendo un punto de interrupción.
El tercero es el fallo sin tipo. Usarlo con el existencial como parámetro de fallo devuelve casi todos los inconvenientes del efecto sin ninguna de sus ventajas: ni exhaustividad, ni propagación, ni marca visible. Si el fallo no está tipado, la pregunta que toca hacerse es por qué se está guardando en lugar de propagarse.
Conviene además desactivar un argumento estético que aparece siempre en estas discusiones: que el estilo de valor es más funcional y por tanto mejor. La objeción tiene una respuesta técnica, no ideológica. En lenguajes sin sistema de efectos, envolver el fallo en un tipo es la única forma de que la firma sea honesta, y de ahí viene la costumbre. Swift sí tiene sistema de efectos: throws ya está en el tipo, ya obliga a marcar el punto de uso y ya impide la propagación silenciosa. Reproducir a mano lo que el lenguaje hace en la firma no añade honestidad, solo sintaxis. El tipo se justifica cuando aporta algo que el efecto no puede dar —persistencia, acumulación, transporte—, nunca cuando se limita a repetirlo.
Lo que ocurre al pasar del efecto al dato tiene un nombre preciso en teoría de lenguajes: es una reificación. Coges algo que vivía en la dimensión del control —un salto, una interrupción, un camino alternativo que el programa toma y abandona— y lo conviertes en una estructura de datos inerte que puedes copiar, guardar, enviar por un canal y examinar cuando te venga bien. Es exactamente la misma operación que convierte una continuación en un objeto, una llamada en un mensaje o un tipo en un existencial, y en todos esos casos el intercambio es idéntico: ganas control sobre el cuándo y pierdes la maquinaria automática que el lenguaje te daba mientras aquello seguía siendo control de flujo. Formulado así, la elección deja de ser estilística. La pregunta no es si prefieres el estilo funcional o el imperativo, sino si el punto donde el fallo ocurre y el punto donde tu programa sabe qué hacer con él son el mismo punto en el tiempo. Si lo son —el error nace y se atiende en la misma pila de llamadas, en el mismo instante—, el efecto es estrictamente superior y cualquier envoltura es burocracia. Si no lo son —porque el fallo cruza un hilo, se guarda para una vista que se dibujará después, se acumula con otros diecinueve, o se cachea para decidir un reintento dentro de treinta segundos—, el efecto sencillamente no puede hacerlo: no hay forma de guardar una propagación en una propiedad. Por eso la llegada de async no jubiló el tipo, solo eliminó el caso en que la separación temporal era artificial: los manejadores de finalización separaban el fallo de su tratamiento por una limitación del lenguaje, no del problema. Lo que queda son los casos donde esa separación es real, y esos no van a desaparecer nunca.
- Procesa una lista de veinte entradas con propagación y con fallo como dato; compara qué información tienes al final en cada versión.
- Escribe la conversión de efecto a dato y de vuelta, y comprueba que el fallo llega intacto con sus valores asociados.
- Toma una cadena de tres transformaciones encadenadas y reescríbela con
doytry; decide cuál publicarías y por qué. - Traduce el fallo de un resultado a otro tipo de error y explica por qué la invarianza obliga a ese paso.
- Localiza en un proyecto tuyo un uso donde el tipo solo sirve de envoltorio intermedio y elimínalo sin perder información.