Saltar al contenido principal

Manejo de errores con objetos Result

Mejora el manejo de errores en las clases con objetos Result.

Dart proporciona un mecanismo de manejo de errores integrado con la capacidad de lanzar y capturar excepciones.

Como se menciona en la documentación de manejo de errores, las excepciones de Dart son excepciones no controladas. Esto significa que los métodos que lanzan excepciones no necesitan declararlas, y tampoco se requiere que los métodos que las llaman las capturen.

Esto puede llevar a situaciones en las que las excepciones no se manejen correctamente. En proyectos grandes, los desarrolladores podrían olvidar capturar excepciones, y las diferentes capas y componentes de la aplicación podrían lanzar excepciones que no estén documentadas. Esto puede provocar errores y fallas inesperadas (crashes).

En esta guía, aprenderás sobre esta limitación y cómo mitigarla utilizando el patrón result.

Flujo de errores en aplicaciones Flutter

#

Las aplicaciones que siguen las pautas de arquitectura de Flutter suelen estar compuestas por view models, repositorios y servicios, entre otras partes. Cuando una función en uno de estos componentes falla, debe comunicar el error al componente que la llama.

Típicamente, eso se hace con excepciones. Por ejemplo, un servicio de cliente de API que no logra comunicarse con el servidor remoto podría lanzar una excepción de error HTTP. El componente llamador, por ejemplo un repositorio, tendría que capturar esta excepción o ignorarla y dejar que el view model llamador la maneje.

Esto se puede observar en el siguiente ejemplo. Considera estas clases:

  • Un servicio, ApiClientService, realiza llamadas de API a un servicio remoto.
  • Un repositorio, UserProfileRepository, proporciona el UserProfile suministrado por el ApiClientService.
  • Un view model, UserProfileViewModel, utiliza el UserProfileRepository.

El ApiClientService contiene un método, getUserProfile, que lanza excepciones en ciertas situaciones:

  • El método lanza una HttpException si el código de respuesta no es 200.
  • El método de parseo JSON lanza una excepción si la respuesta no tiene el formato correcto.
  • El cliente HTTP podría lanzar una excepción debido a problemas de red.

El siguiente código realiza pruebas para una variedad de excepciones posibles:

dart
class ApiClientService {
  // ···

  Future<UserProfile> getUserProfile() async {
    try {
      final request = await client.get(_host, _port, '/user');
      final response = await request.close();
      if (response.statusCode == 200) {
        final stringData = await response.transform(utf8.decoder).join();
        return UserProfile.fromJson(jsonDecode(stringData));
      } else {
        throw const HttpException('Invalid response');
      }
    } finally {
      client.close();
    }
  }
}

El UserProfileRepository no necesita manejar las excepciones provenientes del ApiClientService. En este ejemplo, simplemente devuelve el valor del API Client.

dart
class UserProfileRepository {
  // ···

  Future<UserProfile> getUserProfile() async {
    return await _apiClientService.getUserProfile();
  }
}

Finalmente, el UserProfileViewModel debería capturar todas las excepciones y manejar los errores.

Esto se puede hacer envolviendo la llamada al UserProfileRepository con un try-catch:

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  Future<void> load() async {
    try {
      _userProfile = await userProfileRepository.getUserProfile();
      notifyListeners();
    } on Exception catch (exception) {
      // handle exception
    }
  }
}

En la realidad, un desarrollador podría olvidar capturar adecuadamente las excepciones y terminar con el siguiente código. Compila y se ejecuta, pero falla inesperadamente (crashes) si ocurre una de las excepciones mencionadas anteriormente:

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  Future<void> load() async {
    _userProfile = await userProfileRepository.getUserProfile();
    notifyListeners();
  }
}

Puedes intentar solucionar esto documentando el ApiClientService, advirtiendo sobre las posibles excepciones que podría lanzar. Sin embargo, dado que el view model no utiliza el servicio directamente, otros desarrolladores que trabajen en la base de código podrían pasar por alto esta información.

Usar el patrón Result

#

Una alternativa a lanzar excepciones consiste en envolver la salida de la función en un objeto Result.

Cuando la función se ejecuta con éxito, el Result contiene el valor de retorno. Sin embargo, si la función no se completa correctamente, el objeto Result contiene el error.

Un Result es una clase sealed que puede heredar tanto de la subclase Ok como de la clase Error. Devuelve el valor exitoso con la subclase Ok, y el error capturado con la subclase Error.

El siguiente código muestra una clase Result de ejemplo que ha sido simplificada con fines de demostración. Una implementación completa se encuentra al final de esta página.

dart
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
sealed class Result<T> {
  const Result();

  /// Creates an instance of Result containing a value
  factory Result.ok(T value) => Ok(value);

  /// Create an instance of Result containing an error
  factory Result.error(Exception error) => Error(error);
}

/// Subclass of Result for values
final class Ok<T> extends Result<T> {
  const Ok(this.value);

  /// Returned value in result
  final T value;
}

/// Subclass of Result for errors
final class Error<T> extends Result<T> {
  const Error(this.error);

  /// Returned error in result
  final Exception error;
}

En este ejemplo, la clase Result utiliza un tipo genérico T para representar cualquier valor de retorno, que puede ser un tipo primitivo de Dart como String o un int o una clase personalizada como UserProfile.

Crear un objeto Result

#

Para las funciones que utilizan la clase Result para devolver valores, en lugar de un valor, la función devuelve un objeto Result que contiene el valor.

Por ejemplo, en el ApiClientService, getUserProfile se cambia para que devuelva un Result:

dart
class ApiClientService {
  // ···

  Future<Result<UserProfile>> getUserProfile() async {
    // ···
  }
}

En lugar de devolver el UserProfile directamente, devuelve un objeto Result que contiene un UserProfile.

Para facilitar el uso de la clase Result, esta contiene dos constructores con nombre: Result.ok y Result.error. Úsalos para construir el Result según el resultado deseado. Asimismo, captura cualquier excepción lanzada por el código y envuélvela en el objeto Result.

Por ejemplo, aquí el método getUserProfile() se ha cambiado para utilizar la clase Result:

dart
class ApiClientService {
  // ···

  Future<Result<UserProfile>> getUserProfile() async {
    try {
      final request = await client.get(_host, _port, '/user');
      final response = await request.close();
      if (response.statusCode == 200) {
        final stringData = await response.transform(utf8.decoder).join();
        return Result.ok(UserProfile.fromJson(jsonDecode(stringData)));
      } else {
        return const Result.error(HttpException('Invalid response'));
      }
    } on Exception catch (exception) {
      return Result.error(exception);
    } finally {
      client.close();
    }
  }
}

La sentencia return original se reemplazó por una sentencia que devuelve el valor utilizando Result.ok. El throw HttpException() se reemplazó por una sentencia que devuelve Result.error(HttpException()), envolviendo el error en un Result. Asimismo, el método está envuelto con un bloque try-catch para capturar cualquier excepción lanzada por el cliente HTTP o el parser JSON en un Result.error.

La clase repository también debe modificarse, y en lugar de devolver un UserProfile directamente, ahora devuelve un Result<UserProfile>.

dart
Future<Result<UserProfile>> getUserProfile() async {
  return await _apiClientService.getUserProfile();
}

Desempaquetar el objeto Result

#

Ahora el view model no recibe el UserProfile directamente, sino que recibe un Result que contiene un UserProfile.

Esto obliga al desarrollador que implementa el view model a desempaquetar el Result para obtener el UserProfile, y evita tener excepciones no controladas.

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  UserProfile? userProfile;

  Exception? error;

  Future<void> load() async {
    final result = await userProfileRepository.getUserProfile();
    switch (result) {
      case Ok<UserProfile>():
        userProfile = result.value;
      case Error<UserProfile>():
        error = result.error;
    }
    notifyListeners();
  }
}

La clase Result se implementa utilizando una clase sealed, lo que significa que solo puede ser de tipo Ok o Error. Esto permite que el código evalúe el resultado con un switch result o una expresión switch.

En el caso Ok<UserProfile>, se obtiene el valor utilizando la propiedad value.

En el caso Error<UserProfile>, se obtiene el objeto de error utilizando la propiedad error.

Mejorar el flujo de control

#

Envolver el código en un bloque try-catch asegura que las excepciones lanzadas se capturen y no se propaguen a otras partes del código.

Considera el siguiente código.

dart
class UserProfileRepository {
  // ···

  Future<UserProfile> getUserProfile() async {
    try {
      return await _apiClientService.getUserProfile();
    } catch (e) {
      try {
        return await _databaseService.createTemporaryUser();
      } catch (e) {
        throw Exception('Failed to get user profile');
      }
    }
  }
}

En este método, el UserProfileRepository intenta obtener el UserProfile utilizando el ApiClientService. Si falla, intenta crear un usuario temporal en un DatabaseService.

Debido a que cualquiera de los métodos del servicio puede fallar, el código debe capturar las excepciones en ambos casos.

Esto se puede mejorar utilizando el patrón Result:

dart
Future<Result<UserProfile>> getUserProfile() async {
  final apiResult = await _apiClientService.getUserProfile();
  if (apiResult is Ok) {
    return apiResult;
  }

  final databaseResult = await _databaseService.createTemporaryUser();
  if (databaseResult is Ok) {
    return databaseResult;
  }

  return Result.error(Exception('Failed to get user profile'));
}

En este código, si el objeto Result es una instancia de Ok, entonces la función devuelve ese objeto; de lo contrario, devuelve Result.Error.

Poniéndolo todo junto

#

En esta guía, has aprendido cómo usar una clase Result para devolver valores de resultado.

Las conclusiones clave son:

  • Las clases Result obligan al método llamador a comprobar si hay errores, reduciendo la cantidad de bugs causados por excepciones no controladas.
  • Las clases Result ayudan a mejorar el flujo de control en comparación con los bloques try-catch.
  • Las clases Result son sealed y solo pueden devolver instancias de Ok o Error, lo que permite al código desempaquetarlas con una sentencia switch.

A continuación puedes encontrar la clase Result completa tal como se implementó en el ejemplo de Compass App para las pautas de arquitectura de Flutter.

dart
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
///   case Ok(): {
///     print(result.value);
///   }
///   case Error(): {
///     print(result.error);
///   }
/// }
/// ```
sealed class Result<T> {
  const Result();

  /// Creates a successful [Result], completed with the specified [value].
  const factory Result.ok(T value) = Ok._;

  /// Creates an error [Result], completed with the specified [error].
  const factory Result.error(Exception error) = Error._;
}

/// A successful [Result] with a returned [value].
final class Ok<T> extends Result<T> {
  const Ok._(this.value);

  /// The returned value of this result.
  final T value;

  @override
  String toString() => 'Result<$T>.ok($value)';
}

/// An error [Result] with a resulting [error].
final class Error<T> extends Result<T> {
  const Error._(this.error);

  /// The resulting error of this result.
  final Exception error;

  @override
  String toString() => 'Result<$T>.error($error)';
}