How to make API requests with validation in fpdart

Sandro Maglione

Sandro Maglione

Functional programming

All apps need to access some external service. Making an API request is the most common interaction of an app with another service.

An API request looks simple, but in reality you need to pay attention to many details to avoid errors in your app.

fpdart and functional programming can help! In this post, we are going to learn how to use the full API provided by fpdart to handle API requests in your app.


Why API requests are troublesome

There are many approaches to structure an app. Each of these involve some sort of layers.

If we reduce this concept to its core, imagine your app divided in 2 layers:

  • Internal layer: all the code in your app that works on internal classes and methods (animations, UI, state management)
  • External layer: code responsible to access external services (database, API, local storage, analytics)

Everything that deals with the internal structure of our app is relatively safe. The "only" possible source of runtime errors comes from forgetting to handle an Exception.

In the External layer instead, everything is a mess. Any request may fail for countless reasons (no network, no data, invalid data, missing data, corrupted data). The External layer is responsible for handling all these cases, blocking them from our internal safe layer.

API request case study

In this post we are going to see an common example of API request (this example comes from an issue on the fpdart repository).

import 'dart:convert';
import 'package:fpdart/fpdart.dart';
 
/// Mock [Response] implementation
class Response {
  final String body;
  Response(this.body);
}
 
/// Mock for `post` API request
Response post(
  Uri uri, {
  Map<String, String>? headers,
}) =>
    Response('');
 
TaskEither<String, String> request() => TaskEither.tryCatch(
      () async {
        final Response getPrice = await post(
          Uri.parse("URL"),
          headers: {
            'Content-Type': 'application/json; charset=UTF-8',
          },
        );
 
        final Map<String, dynamic> json =
            jsonDecode(getPrice.body) as Map<String, dynamic>;
 
        if (!json.containsKey("pricing")) {
          throw Exception("I don't have price");
        }
 
        return json["pricing"].toString();
      },
      (error, stackTrace) {
        return error.toString();
      },
    );

The code is relatively simple:

  • We make an API request using the post method
  • We decode the response from json using jsonDecode
  • We validate that the pricing value is present in the response
    • If the value is not found, we throw an Exception
    • Otherwise we return it as a String

This logic is wrapped inside TaskEither.tryCatch from fpdart, used to handle any possible Exception in the request.

TaskEither.tryCatch requires 2 parameters:

  • The first parameter is the function to perform the request
  • The second parameter is a fallback error returned in case the request fails for any reason

TaskEither returns an Either when executed. An Either is a type that can contain a valid response or an error. Either is used to handle errors in functional programming instead of throwing Exceptions.

In this example, the return type is TaskEither<String, String>:

  • The first String is the type of the error (error.toString())
  • The second String is the type of the valid response (json["pricing"].toString())

fpdart at its full potential

The above code works. The function is safe, meaning that runtime errors are prevented using TaskEither.

Nonetheless, it does not take full advantage of the API provided by fpdart. In fact, it is possible to expand the example to introduce more safety and control. That's what this post is about!

We are going to discuss the following points:

  • What is the purpose of TaskEither.tryCatch
  • How to chain methods to TaskEither
  • How to perform validation using TaskEither
  • How to handle errors in fpdart

What is the purpose of TaskEither.tryCatch

At its core, tryCatch is an utility function to run a function that may throw and catch any possible Exception. In fact, the implementation of tryCatch internally is a try / catch statement:

task_either.dart
factory TaskEither.tryCatch(Future<R> Function() run,
        L Function(Object error, StackTrace stackTrace) onError) =>
    TaskEither<L, R>(() async {
      try {
        return Right<L, R>(await run());
      } catch (error, stack) {
        return Left<L, R>(onError(error, stack));
      }
    });

The purpose of tryCatch is to run code that can potentially throw. Only this. All other concerns, like other requests, validation, or mapping, should be implemented by chaining other TaskEither.

The point of TaskEither (and functional programming in general) is composability: chaining requests with ease.

Based on this principle, we can refactor the code to include only the API request inside tryCatch:

TaskEither<String, Response> makeRequest(String url) =>
    TaskEither<String, Response>.tryCatch(
      () async => post(
        Uri.parse(url),
        headers: {
          'Content-Type': 'application/json; charset=UTF-8',
        },
      ),
      (error, stackTrace) => error.toString(),
    );

How to chain methods to TaskEither

If I should not use tryCatch for mapping the response, how should I do it then?

The answer is chaining, which for TaskEither means using flatMap and map.

In this example, the second step is mapping the response to json. We implement a function that takes a Response and returns Map<String, dynamic>:

Map<String, dynamic> mapToJson(Response response) =>
    jsonDecode(response.body) as Map<String, dynamic>;

We then use the map method to change the content of TaskEither from Response to Map<String, dynamic>:

TaskEither<RequestError, Map<String, dynamic>> mappingRequest(String url) =>
    makeRequest(url).map(mapToJson);

map allows to extract the value inside TaskEither and change its content to another type (in this example, Response to Map<String, dynamic>).

How to perform validation using TaskEither

The next step is validation.

We want to ensure that the response contains a pricing value:

  • In case pricing is found, return it
  • Otherwise return an error at the end of the response

Every time we want to chain any kind of code that may fail in some way we use flatMap.

flatMap allows to take the value inside TaskEither and return another TaskEither. If the new TaskEither fails, then the final TaskEither will also fail as well.

flatMap is similar to map, but it returns another TaskEither that is then "flatted" (flat + map). The result looks like this:

TaskEither<String, String> validationRequest(Map<String, dynamic> json) =>
    !json.containsKey("pricing")
        ? TaskEither.left("I don't have price")
        : TaskEither.of(json["pricing"].toString());

We can then combine all together to obtain the final request method:

TaskEither<String, String> requestTE(String url) =>
    makeRequest(url).map(mapToJson).flatMap(validationRequest);

As you can see, the final method is self-explanatory: make a request (makeRequest), map it to json (mapToJson), and validate it (validationRequest).

How to handle errors in fpdart

We can improve even further our implementation by making the return types more clear.

First of all, we can use a typedef to rename the returning String to a more readable name, like Pricing:

typedef Pricing = String;
 
TaskEither<String, Pricing> requestTE(String url) =>
    makeRequest(url).map(mapToJson).flatMap(validationRequest);

The second step is making the error more explicit.

The request should not be responsible to convert an error to the corresponding message. Therefore, we can refactor the error from String to a custom RequestError class.

For this example, we can define RequestError as an abstract class that converts the error to a message:

abstract class RequestError {
  String get message;
}

Now we can create different implementations of RequestError based on the type of error returned by the request:

abstract class RequestError {
  String get message;
}
 
class ApiRequestError implements RequestError {
  final Object error;
  final StackTrace stackTrace;
 
  ApiRequestError(this.error, this.stackTrace);
 
  @override
  String get message => "Error in the API request";
}
 
class MissingPricingRequestError implements RequestError {
  @override
  String get message => "Missing pricing in API response";
}

Finally, we replace the error type in TaskEither from String to RequestError:

TaskEither<RequestError, Response> makeRequest(String url) =>
    TaskEither<RequestError, Response>.tryCatch(
      () async => post(
        Uri.parse(url),
        headers: {
          'Content-Type': 'application/json; charset=UTF-8',
        },
      ),
      (error, stackTrace) => ApiRequestError(error, stackTrace),
    );
 
Map<String, dynamic> mapToJson(Response response) =>
    jsonDecode(response.body) as Map<String, dynamic>;
 
TaskEither<RequestError, Map<String, dynamic>> mappingRequest(String url) =>
    makeRequest(url).map(mapToJson);
 
TaskEither<RequestError, String> validationRequest(Map<String, dynamic> json) =>
    !json.containsKey("pricing")
        ? TaskEither.left(MissingPricingRequestError())
        : TaskEither.of(json["pricing"].toString());
 
TaskEither<RequestError, Pricing> requestTE(String url) =>
    makeRequest(url).map(mapToJson).flatMap(validationRequest);

With this, we decouple the request from the code responsible to define the error message.

How to further improve the request

You may be surprised by the amount of code added by this refactoring.

Nonetheless, this is required to handle all possible cases in a simple API request. And not even all of them 🤯.

There are some ways we can improve even further this code. I report them here below without going into more details for now. Feel free to ask for any clarification or examples on these:

  • The function is not technically pure. We are accessing the post, Uri.parse, and jsonDecode methods from a global scope. This methods create implicit dependencies. Furthermore, they make testing more complex to perform, since we cannot swap their implementation. A more solid solution would be to pass these functions as inputs to the request
  • Using the as keyword we are converting a dynamic (returned by jsonDecode) to a Map<String, dynamic>. This is not really safe, since it is still possible that the response is not actually a Map. We would need to validate the correct shape of the response instead of using as
  • Using typedef only creates an alias for String. What if the price returned by the response is invalid (for example < 0)? Ideally we should further validate the price by wrapping it into a Price class. The goal of this class is to ensure that every instance of Price contains indeed a validated price

There is a lot going on even for a simple API request. Do not be scared by this example. Not all these steps are required, the original implementation is still perfectly valid.

This post aims to show you the full extent of the available API of fpdart.

Feel free to send me a message or comment on my Twitter @SandroMaglione for any clarification or request for further examples.

Thanks for reading.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.