Durante años, throws ha tenido una limitación peculiar dentro de un lenguaje tan orientado al sistema de tipos como Swift: una función podía avisar de que fallaba, pero no podía expresar en su firma qué tipo concreto de error era capaz de propagar. Para el código que llamaba a esa función, el error recibido por catch era siempre any Error, aunque la implementación solo pudiera lanzar un único enum. Swift 6 corrige esta asimetría con los errores tipados, introducidos por SE-0413.
La nueva sintaxis es sencilla: basta con escribir el tipo entre paréntesis después de throws. Imaginemos un importador de documentos que solo puede fallar por tres motivos perfectamente conocidos:
enum DocumentImportError: Error {
case unsupportedFormat
case corruptedFile
case emptyDocument
}
func importDocument(_ data: Data) throws(DocumentImportError) -> Document {
guard !data.isEmpty else {
throw .emptyDocument
}
guard isSupported(data) else {
throw .unsupportedFormat
}
guard let document = decodeDocument(data) else {
throw .corruptedFile
}
return document
}
La firma ya forma parte del contrato: importDocument(_:) puede lanzar un DocumentImportError y ningún otro error. Esto también proporciona contexto dentro de la propia función, de modo que podemos escribir throw .emptyDocument en lugar de throw DocumentImportError.emptyDocument. Si intentamos propagar un error de otro tipo, el compilador lo rechazará.
La diferencia se aprecia todavía mejor al consumir la API. Con un throws tradicional tendríamos que comprobar el tipo del error o tratarlo de forma genérica. Con throws(DocumentImportError), el error implícito del catch ya tiene ese tipo concreto:
do {
let document = try importDocument(data)
open(document)
} catch {
switch error {
case .unsupportedFormat:
showMessage("Formato no compatible")
case .corruptedFile:
showMessage("El documento está dañado")
case .emptyDocument:
showMessage("El documento está vacío")
}
}
El switch puede ser exhaustivo directamente y el compilador conoce todos los casos posibles. Esa es probablemente la ventaja práctica más importante de la característica: el tipo de error deja de perderse al atravesar throws. Cuando añadimos un nuevo caso a un error que controlamos y necesitamos manejar de forma totalmente controlada, el compilador puede señalar los lugares que deben actualizarse.
En realidad, Swift 6 generaliza throws de una forma bastante elegante. La documentación del lenguaje define tres niveles. Una función normal equivale conceptualmente a throws(Never), porque Never no puede tener ningún valor y, por tanto, no existe ningún error que pueda lanzarse. Una función con throws(MyError) puede lanzar ese error concreto. Y el throws tradicional equivale ahora a throws(any Error).
func cachedValue() throws(Never) -> Int {
42
}
func validatedValue() throws(ValidationError) -> Int {
// ...
}
func remoteValue() throws(any Error) -> Int {
// ...
}
// Equivalente a:
func anotherRemoteValue() throws -> Int {
// ...
}
Esta relación forma además una jerarquía dentro de los tipos de función. Una función que no falla puede utilizarse donde se espera una que sí pueda fallar; una función que lanza ValidationError puede utilizarse donde se acepta any Error; y una función que lanza un error más específico puede sustituir a otra cuyo error sea más general. El tipo de error pasa a ser, por tanto, una parte real del tipo de la función y no solo una anotación destinada a mejorar la documentación.
Esto tiene una consecuencia especialmente interesante en código genérico. Históricamente Swift utiliza rethrows para expresar que una función de orden superior solo falla si falla uno de los bloques que recibe. map es el ejemplo clásico: si la transformación no lanza errores, no necesitamos try; si lanza alguno, map lo propaga.
Con errores tipados podemos expresar esa relación utilizando un parámetro genérico:
func transform<Input, Output, Failure>(
_ value: Input,
using operation: (Input) throws(Failure) -> Output
) throws(Failure) -> Output {
try operation(value)
}
No hace falta escribir explícitamente Failure: Error: cuando un parámetro genérico aparece como tipo de error de throws, Swift infiere esa conformidad. Si operation no puede fallar, Failure será Never y transform tampoco requerirá try. Si el bloque lanza ParsingError, la función propagará exactamente ParsingError.
Esta idea ya ha llegado a la librería estándar. APIs como Sequence.map u Optional.map utilizan actualmente un tipo de error genérico E, y Result también se beneficia directamente del cambio. Result.get() puede lanzar su Failure concreto y Result(catching:) puede conservar el tipo del error del bloque que evalúa, en lugar de convertirlo necesariamente a any Error.
enum IdentifierError: Error {
case malformed
}
func parseIdentifier(_ value: String) throws(IdentifierError) -> Int {
guard let id = Int(value) else {
throw .malformed
}
return id
}
let result = Result { try parseIdentifier("A-42") }
// Result<Int, IdentifierError>
Sin embargo, aquí aparece uno de los detalles más fáciles de pasar por alto. La inferencia completa del tipo de error de los bloques literales no llegó con Swift 6.0 y continúa siendo una dirección futura de la propuesta. Swift puede inferir que un bloque lanza errores, pero no siempre conserva automáticamente el tipo concreto de esos errores.
Por ejemplo, una referencia directa a una función mantiene la información:
let parser: (String) throws(IdentifierError) -> Int = parseIdentifier
let values = ["1", "2", "X"]
do {
_ = try values.map(parser)
} catch {
// error es IdentifierError
}
Pero escribir la misma lógica pero como un bloque literal puede ampliar el error a any Error:
do {
_ = try values.map { value in
try parseIdentifier(value)
}
} catch {
// El tipo concreto puede haberse perdido y ser any Error.
}
Es una diferencia sutil porque ambas versiones parecen equivalentes a simple vista. Cuando conservar el error concreto sea importante, conviene pasar la función directamente o anotar explícitamente el tipo del bloque:
let parse: (String) throws(IdentifierError) -> Int = { value in
try parseIdentifier(value)
}
let ids = try values.map(parse)
También podemos fijar el tipo de error en un bloque do mediante do throws(ErrorType). Esto resulta útil cuando queremos que el compilador compruebe que dentro de ese ámbito solo se propaga un error determinado:
do throws(DocumentImportError) {
let document = try importDocument(data)
open(document)
} catch {
handleImportError(error)
}
Si dentro de ese do intentamos llamar con try a una operación que puede propagar cualquier otro error sin capturarlo antes, el compilador impedirá que el error atraviese ese límite. Es una forma de mantener la precisión sin convertir necesariamente toda la función exterior a errores tipados.
¿Qué ocurre cuando una operación puede fallar por causas de distintas capas? Swift no permite escribir algo como throws(NetworkError, DecodingError). SE-0413 limita deliberadamente cada función a un solo tipo de error. Podemos crear un enum que represente todos los fallos del dominio, traducir errores internos a ese tipo o volver al throws tradicional cuando el conjunto sea abierto.
enum ProfileLoadError: Error {
case transport
case invalidResponse
case decoding
}
func loadProfile() async throws(ProfileLoadError) -> Profile {
do {
let data = try await apiClient.profileData()
return try decodeProfile(data)
} catch is NetworkError {
throw .transport
} catch is ResponseError {
throw .invalidResponse
} catch {
throw .decoding
}
}
Esta traducción puede ser adecuada en una capa de dominio donde realmente queremos ocultar los detalles de infraestructura. Pero utilizarla únicamente para poder escribir throws(ProfileLoadError) puede ser contraproducente. Un error tipado es una promesa de API: los consumidores pueden depender de ese tipo exacto y escribir manejo exhaustivo sobre él.
Esa promesa es precisamente la razón por la que la propia propuesta recomienda no convertir todos los throws en throws(MyError). Imaginemos una API pública que hoy solo accede a almacenamiento local y declara throws(StorageError). Mañana podría incorporar sincronización remota, cifrado o una base de datos distinta. Si el tipo de error ya forma parte de la firma pública, evolucionar la implementación obliga a traducir todos esos fallos a StorageError o a cambiar el contrato de la API.
El throws no tipado sigue siendo deliberadamente flexible. Su falta de precisión permite que una función cambie sus dependencias y sus causas de fallo sin romper a quienes la consumen. En límites públicos de un framework o de una API que vaya a evolucionar, esa flexibilidad continúa siendo una ventaja importante, no una carencia.
Los errores tipados encajan especialmente bien en tres escenarios. El primero es código dentro de un mismo módulo o paquete, donde controlamos productor y consumidor de mismo y queremos manejo exhaustivo. El segundo es código genérico que únicamente propaga el error recibido, donde un parámetro como Failure conserva información sin acoplar la API a un error concreto. El tercero son entornos restringidos, como Embedded Swift, donde evitar el contenedor existencial de any Error puede reducir costes de tamaño, asignación y ejecución.
throws(ErrorType) no sustituye al modelo de errores de Swift: lo completa. throws continúa siendo una buena opción por defecto, mientras que los errores tipados permiten conservar información cuando esa precisión forma parte realmente del diseño. La clave no está en utilizar el tipo más específico que el compilador permita, sino en decidir cuánto queremos ligar la API con las causas concretas de fallo. Swift 6 nos da ahora las dos opciones y permite elegir ese contrato de forma explícita.