Introducción: ¿Por qué el try/catch estándar no es suficiente?
\n\nEl desarrollo en Kotlin, especialmente en el contexto de aplicaciones de servidor o Android, a menudo se enfrenta a la necesidad de un manejo robusto de errores. El bloque estándar try/catch es la base, pero en la práctica conduce a una lógica dispersa y dificulta las pruebas. A nivel avanzado, no solo queremos capturar excepciones, sino diseñar el sistema para que los errores sean parte del tipo de datos, no un flujo de control inesperado.
En este artículo, exploraremos cómo ir más allá de try/catch y utilizar enfoques funcionales: Result, Either (de bibliotecas) y sealed class. Aprenderá a hacer que el código sea predecible, type-safe y fácil de mantener.
1. Try/catch clásico: ¿cuándo sigue siendo útil?
\n\nNo debe abandonar completamente try/catch. Es indispensable para trabajar con código heredado, bibliotecas externas (por ejemplo, al analizar JSON) y llamadas al sistema (entrada/salida). Sin embargo, hay matices importantes.
Estructura correcta y recursos
\nEn Kotlin, a diferencia de Java, no hay excepciones verificadas, pero hay una elegante construcción use para cerrar recursos automáticamente:
fun readFile(path: String): String? = try {\n BufferedReader(FileReader(path)).use { it.readText() }\n} catch (e: IOException) {\n println("Error al leer el archivo: ${e.message}")\n null\n}\n\nAquí, use garantiza que el archivo se cierre, incluso si ocurre una excepción internamente. Es un reemplazo elegante de try-with-resources.
Problema: pérdida de contexto
\nEl principal inconveniente de try/catch es que no le informa al código llamante que la función puede "fallar". La excepción es un efecto secundario oculto. La llamada a readFile() puede devolver null o lanzar una IOException, pero la firma de la función no lo refleja. Esto conduce a errores en tiempo de ejecución.
La solución es pasar a una representación explícita de errores.
\n\n2. Result: mónada incorporada para éxito o fracaso
\n\nKotlin proporciona la clase estándar Result<T>. Es una estructura algebraica simple: ya sea éxito (valor T) o fracaso (Throwable). Esto hace que el manejo de errores sea parte del sistema de tipos.
Uso básico
\nfun riskyDivision(a: Int, b: Int): Result<Int> = runCatching {\n a / b\n}\n\nfun main() {\n val result = riskyDivision(10, 0)\n result.onSuccess { println("Resultado: $it") }\n .onFailure { println("Error: ${it.message}") }\n}\n\nrunCatching es un envoltorio sobre try/catch que devuelve un Result. Los métodos onSuccess y onFailure permiten reaccionar al resultado sin interrumpir la cadena de llamadas.
Transformación y cadenas
\nPuede transformar el resultado sin preocuparse por las excepciones dentro de map:
\n\nval finalResult = riskyDivision(10, 2)\n .map { it * 2 } // si es éxito — multiplicamos\n .recover { error -> // si es error — devolvemos un valor de respaldo\n println("Usando valor de respaldo debido a: ${error.message}")\n 0\n }\nprintln(finalResult.getOrDefault(-1)) // 10\n\nImportante: Result no está diseñado para almacenarse en colecciones o transferirse entre hilos sin procesamiento adicional. Para estos fines, son más adecuados sealed class o bibliotecas como Arrow.
3. Either: diferenciando el tipo de error
\n\nLa clase Result solo captura Throwable. Pero a menudo queremos diferenciar tipos de errores: "no encontrado", "permisos insuficientes", "error de validación". Aquí es donde Either<L, R> de las bibliotecas funcionales (por ejemplo, Arrow o propias) viene al rescate.