Saltar al contenido
K
Swift: el lenguajePaso a paso40 min de lectura · 8 min de máquina Intermedio

registrar lanza errores: throws tipado y Result

Al terminar: Hacer que registrar lance errores con throws tipado y Result.

Hasta ahora Torneo.registrar(partido) aceptaba cualquier cosa: un gol de alguien que no juega, un minuto 200, un equipo sin inscribir. Al terminar esta lección registrar lanzará un error que dice por qué falló, y main decidirá qué hacer con él: un registro inválido ya no se cuela ni tumba el programa.

Para ello aprenderás dos piezas más de los errores: el throws tipado, que dice exactamente qué error lanza una función, y el Result que conserva ese tipo. Al final, una caja corta cuenta cómo pausar un programa a mitad de ejecución con un punto de interrupción (breakpoint) de Xcode.

throws tipado: la función dice qué error lanza

Con throws a secas, una función puede lanzar cualquier error, y dentro del catch el error es un any Error genérico (un error «de algún tipo»). Con el tipo entre paréntesis, throws(ErrorDeReserva), la función promete que solo lanza ese tipo, y dentro del catch el error ya es un ErrorDeReserva: puedes mirar sus casos sin adivinar.

El throws tipado llegó con la versión 6 del compilador de Swift (la de tu Xcode, que ya viste con xcrun swift --version); no depende del modo de lenguaje, que es otra cosa. Por eso compila aunque el paquete siga en modo Swift 5 desde Closures. Si todas las llamadas con try de un do lanzan ese mismo tipo, el catch lo conserva; si una lanza un error de otro tipo, error vuelve a ser un any Error.

Tres piezas más que aparecen en el ejemplo:

  • throw .sinNombre: lanza un caso del enum. El punto inicial funciona porque, con throws tipado, el compilador conoce el tipo del error.
  • @discardableResult: quien llama puede ignorar el valor devuelto sin aviso (lo viste en struct frente a class, en nombrarCapitan).
  • Una sobrecarga es una segunda función con el mismo nombre y otros parámetros: aquí, reservar(nombre:) junto a reservar(nombre:mesa:).

Y un detalle: [1, 2] + [5] une dos arrays en uno nuevo. El ejemplo, en un restaurante con una lista de reservas:

ejemplo mínimo: throws tipado, throw con punto, sobrecarga, + entre arrays y switch sobre el error (con comentarios)
enum ErrorDeReserva: Error, Equatable {
    case sinNombre
    case mesaOcupada(Int)
}
 
final class Restaurante {
    private(set) var reservas: [String] = []
    let mesasLibres = [1, 2, 5]
 
    // throws(ErrorDeReserva): solo puede lanzar ese tipo de error.
    @discardableResult
    func reservar(nombre: String, mesa: Int) throws(ErrorDeReserva) -> String {
        guard !nombre.isEmpty else { throw .sinNombre }        // el punto basta: el tipo se sabe
        // Dos condiciones separadas por coma: tienen que cumplirse las dos.
        guard mesa > 0, mesasLibres.contains(mesa) else { throw .mesaOcupada(mesa) }
        let reserva = "\(nombre) en la mesa \(mesa)"
        reservas.append(reserva)
        return reserva
    }
 
    // Sobrecarga: mismo nombre, otros parámetros. Llama a la de arriba con try.
    @discardableResult
    func reservar(nombre: String) throws(ErrorDeReserva) -> String {
        try reservar(nombre: nombre, mesa: mesasLibres[0])
    }
}
 
let casaPepe = Restaurante()
try casaPepe.reservar(nombre: "Lucía")         // usa la sobrecarga: mesa 1
print(casaPepe.reservas)                       // ["Lucía en la mesa 1"]
print([1, 2] + [5])                            // + une dos arrays: [1, 2, 5]
 
// Con throws tipado, dentro del `catch` el `error` ya es un ErrorDeReserva:
// un `switch` sobre sus casos es exhaustivo y no hace falta `default`.
do {
    try casaPepe.reservar(nombre: "", mesa: 2)
} catch {
    switch error {
    case .sinNombre: print("Falta el nombre")
    case let .mesaOcupada(mesa): print("La mesa \(mesa) no está libre")
    }
}
["Lucía en la mesa 1"]
[1, 2, 5]
Falta el nombre

Result con la closure tipada

Un Result es un valor que guarda o lo que salió bien (.success) o el error (.failure). Result { … } ejecuta la closure que le pasas y guarda su valor o su error; es el equivalente del runCatching de Kotlin. Para que conserve el tipo del error hace falta escribir la «firma» de la closure:

  • { () throws(ErrorDeReserva) in … }: se lee de izquierda a derecha. () es «no recibe parámetros», throws(ErrorDeReserva) es «y, si falla, lanza un ErrorDeReserva», y in separa esa firma del cuerpo. Sin la anotación, Swift supone que la closure puede lanzar cualquier error y el compilador protesta: invalid conversion of thrown error type 'any Error' to 'ErrorDeReserva'. Con ella, el Result conserva el tipo: Result<String, ErrorDeReserva>.
  • case let .success(texto): un case de switch que abre el valor guardado en el Result; .failure(error) abre el error.
ejemplo mínimo: Result con closure tipada y switch sobre Result (con comentarios)
enum ErrorDeReserva: Error, Equatable {
    case sinNombre
    case mesaOcupada(Int)
}
 
// Una versión suelta de reservar: la mesa 3 siempre está ocupada.
func reservar(nombre: String, mesa: Int) throws(ErrorDeReserva) -> String {
    guard !nombre.isEmpty else { throw .sinNombre }
    guard mesa != 3 else { throw .mesaOcupada(mesa) }
    return "\(nombre), mesa \(mesa)"
}
 
// La closure lleva su firma escrita: () no recibe nada, throws(ErrorDeReserva) dice qué lanza.
// Así el Result conserva el tipo: Result<String, ErrorDeReserva>.
func intentarReservar(nombre: String, mesa: Int) -> Result<String, ErrorDeReserva> {
    Result { () throws(ErrorDeReserva) in try reservar(nombre: nombre, mesa: mesa) }
}
 
// Dos intentos: la mesa 3 (ocupada) y la 2 (libre).
for mesa in [3, 2] {
    switch intentarReservar(nombre: "Lucía", mesa: mesa) {
    case let .success(texto): print("Hecho: \(texto)")        // abre el valor guardado
    case let .failure(error): print("Falló: \(error)")        // abre el error guardado
    }
}
Falló: mesaOcupada(3)
Hecho: Lucía, mesa 2
KotlinSwift
Result<Partido> (el error es un Throwable cualquiera)Result<Partido, ErrorDeTorneo> (el tipo del error se conserva)

Reto · Ejercicio: un cajero con throws tipado y Result

En tu Playground, escribe un cajero automático con 100 billetes y una tarjeta que puede estar bloqueada. Su función para retirar solo puede lanzar el error del cajero (tarjeta bloqueada, o no hay billetes suficientes para lo pedido) y devuelve los billetes que quedan; quien la llama puede ignorar ese valor. Otra función, para intentar retirar, devuelve un resultado con lo que queda o con ese error, sin lanzar nada. Retira 30 y luego 90 con la segunda, imprimiendo lo que queda o el error. Después bloquea la tarjeta, intenta retirar 10 con la primera dentro de un bloque que atrape errores y, en el aviso, distingue los dos casos del error con un switch sin caso por defecto. Lo que debe salir:

Retiras 30: quedan 70
Retiras 90: sinBilletes(pedido: 90)
Tarjeta bloqueada

Para pensar: ¿por qué ese switch no necesita un caso por defecto?

Ver solución
solución: el cajero
enum ErrorDeCajero: Error {
    case tarjetaBloqueada
    case sinBilletes(pedido: Int)
}
 
final class Cajero {
    private(set) var billetes = 100
    var bloqueada = false
 
    @discardableResult
    func retirar(_ cantidad: Int) throws(ErrorDeCajero) -> Int {
        guard !bloqueada else { throw .tarjetaBloqueada }
        guard cantidad <= billetes else { throw .sinBilletes(pedido: cantidad) }
        billetes -= cantidad
        return billetes
    }
 
    func intentarRetirar(_ cantidad: Int) -> Result<Int, ErrorDeCajero> {
        Result { () throws(ErrorDeCajero) in try retirar(cantidad) }
    }
}
 
let cajero = Cajero()
for cantidad in [30, 90] {
    switch cajero.intentarRetirar(cantidad) {
    case let .success(quedan): print("Retiras \(cantidad): quedan \(quedan)")
    case let .failure(error): print("Retiras \(cantidad): \(error)")
    }
}
cajero.bloqueada = true
do {
    try cajero.retirar(10)
} catch {
    switch error {                       // con throws(ErrorDeCajero), error ya es un ErrorDeCajero
    case .tarjetaBloqueada: print("Tarjeta bloqueada")
    case let .sinBilletes(pedido): print("No hay \(pedido)")
    }
}

Respuesta: con el throws tipado, error es un ErrorDeCajero y el switch sobre sus dos casos ya es exhaustivo (cubre todas las posibilidades), así que no hace falta un default.

Paso 1 · registrar lanza errores

Objetivo. Que Torneo.registrar compruebe el partido y lance un ErrorDeTorneo si algo no cuadra. Al terminar el paso, el paquete no compila (las llamadas viejas a registrar no llevan try): es lo esperado, y lo arreglas en los dos pasos siguientes.

Torneo
Sources
Torneo
Torneo.swiftse modifica
Lo que tocas en este paso: un fichero que cambia.

Las piezas son las del ejemplo del restaurante: throws(ErrorDeTorneo), throw .caso, @discardableResult, una sobrecarga y + entre arrays (partido.local.plantilla + partido.visitante.plantilla junta las dos plantillas en una lista).

Intenta escribirlo tú.

  1. registrar, que recibe un partido sin etiqueta, solo puede lanzar un ErrorDeTorneo y devuelve el partido registrado (quien llama puede ignorarlo). Lanza equipoContraSiMismo si el local y el visitante son el mismo, y equiposNoInscritos, con el nombre del torneo, si alguno no está inscrito; después valida cada evento y, si todo va bien, guarda el partido.
  2. Una sobrecarga de registrar con las etiquetas local, visitante y eventos (ninguno si no se dan), que construye el partido y llama a la anterior.
  3. Una función privada, validar, que recibe un evento y (con la etiqueta en) su partido, y lanza el error que toca: el minuto tiene que estar entre 0 y 120; un gol tiene que ser de uno de los dos equipos y de un jugador de ese equipo; una tarjeta, de alguien que juega el partido; y un cambio, entre jugadores del partido.

Qué se sustituye y dónde. En Torneo.swift (Sources → Torneo) ya tienes una función registrar(_:) de una sola línea (partidos.append(partido)): bórrala entera y en su lugar escribe el primer bloque (registrar y su sobrecarga, juntas). validar va justo debajo de la sobrecarga. El resto del fichero no cambia.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Torneo.swift
    // Registrar puede fallar (equipo no inscrito, gol de alguien ajeno…), y eso no
    // es una situación excepcional sino esperable. `throws(ErrorDeTorneo)` es un
    // `throws` TIPADO (del compilador 6 en adelante, sea cual sea el modo de
    // lenguaje): el compilador sabe que solo puede lanzar ese error, así que
    // quien lo capture no necesita un `catch` genérico. Con `throws` a secas el
    // error sería un `any Error` cualquiera.
    // Devuelve el partido registrado; `@discardableResult` permite ignorarlo.
    @discardableResult
    public func registrar(_ partido: Partido) throws(ErrorDeTorneo) -> Partido {
        guard partido.local != partido.visitante else { throw .equipoContraSiMismo }
        guard equipos.contains(partido.local), equipos.contains(partido.visitante) else {
            throw .equiposNoInscritos(torneo: nombre)
        }
        for evento in partido.eventos {
            try validar(evento, en: partido)
        }
        partidos.append(partido)
        return partido
    }
 
    @discardableResult
    public func registrar(
        local: Equipo,
        visitante: Equipo,
        eventos: [EventoDePartido] = []
    ) throws(ErrorDeTorneo) -> Partido {
        try registrar(Partido(local: local, visitante: visitante, eventos: eventos))
    }

validar, justo debajo de la sobrecarga (el switch sobre un evento lo usaste en Enums con valores asociados):

Torneo/Sources/Torneo/Torneo.swift
    private func validar(_ evento: EventoDePartido, en partido: Partido) throws(ErrorDeTorneo) {
        guard (0...120).contains(evento.minuto) else { throw .minutoFueraDelPartido(evento.minuto) }
        let jugadoresDelPartido = partido.local.plantilla + partido.visitante.plantilla
        // `switch` exhaustivo sobre el enum: si aparece un evento nuevo, no compila.
        switch evento {
        case let .gol(_, jugador, equipo):
            guard equipo == partido.local || equipo == partido.visitante else {
                throw .golDeEquipoAjeno(equipo: equipo.nombre)
            }
            guard equipo.plantilla.contains(jugador) else {
                throw .jugadorNoJuegaEnElEquipo(jugador: jugador.nombre, equipo: equipo.nombre)
            }
        case let .tarjeta(_, jugador, _):
            guard jugadoresDelPartido.contains(jugador) else {
                throw .jugadorNoJuegaElPartido(jugador: jugador.nombre)
            }
        case let .cambio(_, sale, entra):
            guard jugadoresDelPartido.contains(sale), jugadoresDelPartido.contains(entra) else {
                throw .cambioConAjenos
            }
        }
    }

Compila con xcrun swift build. Falla, y es lo esperado:

Paso 2 · main atiende los errores

Objetivo. Que main.swift compile otra vez y que un registro inválido no tumbe el programa. Al ejecutar verás No se registró: Intruso no juega en Lobos.

La pieza nueva de este paso es intentarRegistrar, el equivalente del Result<Partido> de Kotlin: devuelve un Result con el partido o con su ErrorDeTorneo, sin lanzar nada, con la closure tipada del ejemplo de arriba.

Primero, en Torneo.swift, entre la sobrecarga de registrar y validar. Intenta escribirlo tú: intentarRegistrar, una función pública que recibe un partido sin etiqueta, lo registra y devuelve un Result con el partido o con su ErrorDeTorneo, sin lanzar nada.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Torneo.swift
    // El equivalente del `Result<Partido>` de Kotlin: el error pasa a ser un valor.
    // Con `throws` tipado, `Result { … }` conserva el tipo del error.
    public func intentarRegistrar(_ partido: Partido) -> Result<Partido, ErrorDeTorneo> {
        Result { () throws(ErrorDeTorneo) in try registrar(partido) }
    }

Después, dos cambios en main.swift (Sources → torneo-cli):

  • Sustituye desde la línea let torneo = … hasta la línea mismoTorneo.registrar(alFinal), ambas incluidas (son tres líneas), por el primer bloque de abajo: añade lobos a equipos y pasa el registro de alFinal a un do/catch.
  • Añade el bloque nuevo entre la línea print("Partidos vistos desde …") y el bucle for texto in ["10", "diez", "150"] de la lección anterior.

Intenta escribirlo tú:

  • El registro de alFinal, que si falla imprime «No se registró:» y el texto del error.
  • En el bloque nuevo: un Lobos contra Toros con un gol en el minuto 10 de un jugador, «Intruso» con el 99, que no es de los Lobos, registrado con la sobrecarga (imprime «Registrado» o «No se registró:» y el texto del error); un Lobos contra Toros con un gol del primer jugador de los Lobos en el minuto 10, registrado con intentarRegistrar, que imprime el marcador o el error; y un Lobos contra Lobos que imprime cuántos eventos tiene, o -1 si falla.

Lo que debes ver, con las líneas del bucle de dorsales justo detrás:

No se registró: Intruso no juega en Lobos
Marcador: 1-0
Eventos (o -1 si falla): -1
«10» -> dorsal 10
«diez» no es un número
«150» -> fueraDeRango(150)
Con try?: nil
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Las tres líneas que sustituyen a las que van desde let torneo = Torneo(…) hasta mismoTorneo.registrar(alFinal):

Torneo/Sources/torneo-cli/main.swift
let torneo = Torneo(nombre: nombreDelTorneo, equipos: [rayo, toros, lobos])
let mismoTorneo = torneo        // MISMA referencia: class
do {
    try mismoTorneo.registrar(alFinal)
} catch {
    print("No se registró: \(error.localizedDescription)")
}

El bloque nuevo, entre print("Partidos vistos desde …") y el bucle de dorsales:

Torneo/Sources/torneo-cli/main.swift
// Errores: un registro inválido no tumba el programa.
let intruso = Jugador(nombre: "Intruso", dorsal: 99)
do {
    try torneo.registrar(local: lobos, visitante: toros, eventos: [.gol(minuto: 10, jugador: intruso, equipo: lobos)])
    print("Registrado")
} catch {
    // Con `throws(ErrorDeTorneo)`, `error` es un ErrorDeTorneo, no un `any Error`.
    print("No se registró: \(error.localizedDescription)")
}
 
// `Result`: el error como valor, como el `Result` de Kotlin.
let partidoValido = Partido(local: lobos, visitante: toros)
    .registrando(.gol(minuto: 10, jugador: lobos.plantilla[0], equipo: lobos))
switch torneo.intentarRegistrar(partidoValido) {
case let .success(partido): print("Marcador: \(partido.golesLocal)-\(partido.golesVisitante)")
case let .failure(error): print("Error: \(error)")
}
print("Eventos (o -1 si falla): \((try? torneo.registrar(local: lobos, visitante: lobos).eventos.count) ?? -1)")

Compila y ejecuta, desde ~/Marcador-Swift/Torneo:

xcrun swift build
xcrun swift run torneo-cli

Fíjate en la línea que empieza por Eventos (o -1 si falla): es solo una demostración de try?. Pide registrar un partido de lobos contra sí mismo, que lanza equipoContraSiMismo; try? convierte el error en nil y ?? -1 entrega el valor de respaldo, así que imprime Eventos (o -1 si falla): -1. El -1 es la marca de «falló»; si el registro hubiera salido bien, imprimiría cuántos eventos tiene el partido (de ahí el .eventos.count).

Paso 3 · Los tests

Ahora xcrun swift test falla en StructYClassTests.swift por el mismo motivo: dos tests llaman a registrar sin try. El mensaje cambia un poco porque la llamada está dentro de una función que no declara throws:

error: call can throw, but it is not marked with 'try' and the error is not handled

Un test de Swift Testing también puede declarar throws: si lanza, el test falla con ese error. Abre StructYClassTests.swift (Tests → TorneoTests) y haz el mismo cambio en los dos tests que llaman a registrar, torneoAcumulaPartidosYLoQueSaleEsUnaCopia y dosVariablesDelMismoTorneoVenLoMismo: la firma gana throws y la llamada, try.

ejemplo: un test, antes
@Test func dosVariablesDelMismoTorneoVenLoMismo() {
    // …
    mismo.registrar(Partido(local: rayo, visitante: toros))
ejemplo: el mismo test, después
@Test func dosVariablesDelMismoTorneoVenLoMismo() throws {
    // …
    try mismo.registrar(Partido(local: rayo, visitante: toros))

(El primer test lleva la llamada torneo.registrar(…); el cambio es idéntico.)

Un test nuevo con #expect(throws:). El fichero ErroresTests.swift tiene once tests. Casi todos son de la misma forma, y el que enseña algo nuevo es este:

ejemplo: extracto de ErroresTests.swift
@Test func dorsalValidoDiceSuMotivo() throws {
    // Sin error: se comprueba el valor.
    #expect(try dorsalValido("10") == 10)
    // Con error: `#expect(throws: X) { código }` pasa solo si el código lanza
    // justo X (el error tiene que ser `Equatable`). Si no lanza, o lanza otro, falla.
    #expect(throws: ErrorDeDorsal.noEsUnNumero("diez")) { try dorsalValido("diez") }
    #expect(throws: ErrorDeDorsal.fueraDeRango(150)) { try dorsalValido("150") }
}

Es el assertFailsWith de Kotlin, pero comprueba también cuál fue el error. El resto usa lo mismo con los errores de registrar, y Result con .failure(…). No hace falta escribirlos: se rellena con el botón Copiar fichero, que copia el contenido al portapapeles y no escribe nada en tu proyecto (se explicó en Clases y objetos). El orden es siempre el mismo: crear el fichero, pulsar el botón, seleccionar todo, pegar.

  1. En el Project navigator, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe ErroresTests y pulsa Create. (Con la Terminal: touch Tests/TorneoTests/ErroresTests.swift.)
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro de ErroresTests.swift, selecciona todo con Cmd+A y pega con Cmd+V.
ErroresTests.swift

Después, desde ~/Marcador-Swift/Torneo:

xcrun swift test

Punto de control

  • xcrun swift build (desde ~/Marcador-Swift/Torneo) compila sin errores.
  • xcrun swift run torneo-cli imprime No se registró: Intruso no juega en Lobos, Marcador: 1-0 y Eventos (o -1 si falla): -1 (el -1 es el valor de respaldo de try?), y después las líneas del bucle de dorsales («10» -> dorsal 10, «diez» no es un número y «150» -> fueraDeRango(150)), y termina con Con try?: nil.
  • xcrun swift test termina con Test run with 69 tests in 9 suites passed: los 58 de la lección anterior más los 11 nuevos de ErroresTests.
  • Provócalo: quita el try de una llamada a registrar en main.swift y ejecuta xcrun swift build; verás call can throw but is not marked with 'try'. Devuelve el try.
  • Si probaste el breakpoint opcional, no queda ninguno puesto en Errores.swift.
  • Sabes decir qué aporta throws(ErrorDeTorneo) frente a un throws a secas y qué conserva un Result con la closure tipada.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Qué aporta throws(ErrorDeTorneo) frente a un throws sin tipo?

  2. 2.¿Qué tipo tiene Result { () throws(ErrorDeTorneo) in try registrar(partido) }?

  3. 3.¿Para qué sirve @discardableResult en registrar, que devuelve el partido registrado?

  4. 4.Pones un breakpoint en una línea y ejecutas el programa desde Xcode. ¿Qué hace?

Faltan 4 de 4