Flutter Supabase Functional Programming with fpdart

Sandro Maglione

Sandro Maglione

Mobile development

Supabase is an open source Firebase alternative. Supabase makes super easy adding Authentication, Database, Storage, and more in your app:

In this article we are going to built on top of the previous setup by using fpdart and functional programming. We are going to learn how to:

  • Add fpdart and functional programming to your Flutter app
  • Refactor our code using fpdart types
  • How to extract and display actionable error messages to the user
  • How the combination of fpdart and Supabase makes our codebase powerful and safe

We are also going to use some best practices to structure the app to make it ready to scale to millions of users 🚀

Every method and class mentioned in this post has a link to the official API documentation for more details 📃

The final app is fully available Open Source on Github 👇


Adding fpdart to pubspec.yaml

The first step is adding fpdart to our app. Just update your pubspec.yaml file with fpdart and run pub get:

dependencies:
  flutter:
    sdk: flutter
 
  supabase_flutter: ^1.1.0
 
  # Routing
  auto_route: ^4.2.1
 
  # Dependency injection
  injectable: ^1.5.3
  get_it: ^7.2.0
 
  # Functional programming
  fpdart: ^0.3.0

Update repository from Future to TaskEither

This first step when refactoring using fpdart and functional programming is to update the type signatures of your functions.

In this case, we need to change the return type of our repository methods from Future to TaskEither.

TaskEither is used to perform async request (just like Future).

Unlike Future, TaskEither also allows us to provide an error (using the Either type) and to control the execution of an async request.

You can read more about Task and Future here.

Therefore, inside the repository folder we change Future to TaskEither:

auth_repository.dart
abstract class AuthRepository {
  TaskEither<LoginFailure, String> signInEmailAndPassword(
    String email,
    String password,
  );
 
  TaskEither<LoginFailure, String> signUpEmailAndPassword(
    String email,
    String password,
  );
 
  TaskEither<SignOutFailure, Unit> signOut();
}
user_database_repository.dart
abstract class UserDatabaseRepository {
  TaskEither<GetUserInformationFailure, UserModel> getUserInformation(
    String userId,
  );
 
  TaskEither<UpdateUserInformationFailure, UserModel> updateUserInformation(
    UserModel userModel,
  );
}

Define possible errors for each request

TaskEither requires us to provide an error type.

We define the error as an abstract class:

login_failure.dart
abstract class LoginFailure {
  const LoginFailure();
}

We then define the concrete errors that extends the source error LoginFailure:

login_failure.dart
abstract class LoginFailure {
  const LoginFailure();
}
 
class AuthErrorLoginFailure extends LoginFailure {
  final String message;
  final String? statusCode;
  const AuthErrorLoginFailure(this.message, this.statusCode);
}
 
class ExecutionErrorLoginFailure extends LoginFailure {
  final Object error;
  final StackTrace stackTrace;
  const ExecutionErrorLoginFailure(this.error, this.stackTrace);
}
 
class MissingUserIdLoginFailure extends LoginFailure {
  const MissingUserIdLoginFailure();
}

Finally, we implement a mapToErrorMessage method inside LoginFailure that defines the messages to display to the user for each possible error:

login_failure.dart
abstract class LoginFailure {
  const LoginFailure();
 
  String get mapToErrorMessage {
    final failure = this;
    if (failure is AuthErrorLoginFailure) {
      return failure.message;
    } else if (failure is ExecutionErrorLoginFailure) {
      return 'Error when making login request';
    } else if (failure is MissingUserIdLoginFailure) {
      return 'Missing user information';
    }
 
    return 'Unexpected error, please try again';
  }
}
 
class AuthErrorLoginFailure extends LoginFailure {
  final String message;
  final String? statusCode;
  const AuthErrorLoginFailure(this.message, this.statusCode);
}
 
class ExecutionErrorLoginFailure extends LoginFailure {
  final Object error;
  final StackTrace stackTrace;
  const ExecutionErrorLoginFailure(this.error, this.stackTrace);
}
 
class MissingUserIdLoginFailure extends LoginFailure {
  const MissingUserIdLoginFailure();
}

The errors definition is the same for all other requests:

  1. Define source failure type as an abstract class
  2. Define all possible errors that extends the source failure
  3. Implement a mapToErrorMessage method inside the source class to map each failure to a readable message to display to the user

You can view the other errors inside the failure folder in the repository on Github.

By doing this, we defined all the possible errors that can happen for each request. We are then able to map each error to an actionable message for the user using the mapToErrorMessage.

There is more.

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.

Refactor repository to use TaskEither

We now need to convert the concrete repository implementation based on the new type signature: from Future to TaskEither.

TaskEither by definition is an async request (just like Future, which uses async/await). TaskEither also defines the error type in its signature, and returns either a successful response or an error (Either type in functional programming).

In practice, the steps to convert from Future to TaskEither can be quite simple:

  1. Update the type signature: from Future<A> to TaskEither<E, A> (where E is the failure type we defined above). As you can see, the response type remains the same (A), TaskEither then also requires to define the error
  2. Wrap response inside TaskEither: TaskEither is a wrapper around an async function. Therefore, in the most simple of cases, all you need to do is to copy the existing code (which uses Future) and paste it inside TaskEither.tryCatch

We are now going to see how to refactor some of the methods inside the repositories.

signOut: Unit type instead of void

The most simple refactoring is the signOut method:

Future<void> signOut() async {
  await _supabase.client.auth.signOut();
  return;
}

In functional programming is preferred to use the type Unit instead of void. The Unit types makes the return value explicit.

void allows any value or no value to be returned. This can cause silent issues that the Unit type allows us to avoid.

The type signature therefore change to TaskEither<SignOutFailure, Unit>:

TaskEither<SignOutFailure, Unit> signOut()

I report below the definition of SignOutFailure:

sign_out_failure.dart
abstract class SignOutFailure {
  const SignOutFailure();
 
  String get mapToErrorMessage {
    if (this is ExecutionErrorSignOutFailure) {
      return 'Error when making sign out request';
    }
 
    return 'Unexpected error, please try again';
  }
}
 
class ExecutionErrorSignOutFailure extends SignOutFailure {
  final Object error;
  final StackTrace stackTrace;
  const ExecutionErrorSignOutFailure(this.error, this.stackTrace);
}

Now we just need to copy the current implementation and paste it inside TaskEither.tryCatch.

TaskEither.tryCatch accepts a function that returns a Future as first argument (just like the current implementation of signOut). It then also requires a second parameter that defines the error value to return in case the first function throws:

TaskEither<SignOutFailure, Unit> signOut() => TaskEither.tryCatch(
      () async {
        await _supabase.client.auth.signOut();
        return unit;
      },
      ExecutionErrorSignOutFailure.new,
    );
  • Explicit the return value as unit
  • Define the error as ExecutionErrorSignOutFailure (using constructor tear-off, available since dart 2.15)

We can see side by side the original implementation and the new refactoring:

/// Old implementation using `Future`
Future<void> signOut() async {
  await _supabase.client.auth.signOut();
  return;
}
 
/// Refactoring using `TaskEither`
TaskEither<SignOutFailure, Unit> signOut() => TaskEither.tryCatch(
      () async {
        await _supabase.client.auth.signOut();
        return unit;
      },
      ExecutionErrorSignOutFailure.new,
    );

As you can see, this was mostly a matter of defining the error value; the original implementation of signOut using _supabase remained the same 💁🏼‍♂️

signIn and signUp: check for missing user id

Refactoring signIn and signUp is similar to signOut initially, but it also requires one more step: check that the user id is found.

As before, we convert the function from Future to TaskEither, and then copy-paste the original implementation inside TaskEither.tryCatch:

TaskEither<LoginFailure, String> signInEmailAndPassword(
  String email,
  String password,
) =>
    TaskEither<LoginFailure, AuthResponse>.tryCatch(
      () => _supabase.client.auth.signInWithPassword(
        email: email,
        password: password,
      ),
      ExecutionErrorLoginFailure.new,
    )

The second step is extracting the user id inside AuthResponse returned from signInWithPassword.

The problem is that the user id may be missing, therefore we need to account for that case and return another error (MissingUserIdLoginFailure).

The first step is extracting the user id from AuthResponse: we do that by using the map method from TaskEither. map allows us to access AuthResponse and convert it to another value:

TaskEither<LoginFailure, String> signInEmailAndPassword(
  String email,
  String password,
) =>
    TaskEither<LoginFailure, AuthResponse>.tryCatch(
      () => _supabase.client.auth.signInWithPassword(
        email: email,
        password: password,
      ),
      ExecutionErrorLoginFailure.new,
    )
    .map((response) => response.user?.id)

After this we have a TaskEither<LoginFailure, String?>, but the function requires a not-nullable user id String. Because of that, we convert the String? to String, and return an error in case the value is null.

We can achieve this by using the flatMap method of TaskEither.

flatMap allows us to access the current value inside TaskEither (String?) and return another TaskEither, which defines another possible error (MissingUserIdLoginFailure)

Specifically, we use fromNullable of the Either type. fromNullable returns the specified error (second parameter) if the given value is null.

Finally we convert Either from sync to async using toTaskEither:

TaskEither<LoginFailure, String> signInEmailAndPassword(
  String email,
  String password,
) =>
    TaskEither<LoginFailure, AuthResponse>.tryCatch(
      () => _supabase.client.auth.signInWithPassword(
        email: email,
        password: password,
      ),
      ExecutionErrorLoginFailure.new,
    )
    .map((response) => response.user?.id)
    .flatMap(
      (id) => Either.fromNullable(
        id,
        (_) => const MissingUserIdSignInFailure(),
      ).toTaskEither(),
    );

We can also be even more specific in the return error when calling signInWithPassword. In fact, signInWithPassword may throw a AuthException with a message (not registered email, wrong email or password, and so on).

We account for this inside tryCatch and return AuthErrorLoginFailure in such case:

TaskEither<LoginFailure, String> signInEmailAndPassword(
  String email,
  String password,
) =>
    TaskEither<LoginFailure, AuthResponse>.tryCatch(
      () => _supabase.client.auth.signInWithPassword(
        email: email,
        password: password,
      ),
      (error, stackTrace) {
        if (error is AuthException) {
          return AuthErrorLoginFailure(error.message, error.statusCode);
        }
 
        return ExecutionErrorLoginFailure(error, stackTrace);
      },
    )
    .map((response) => response.user?.id)
    .flatMap(
      (id) => Either.fromNullable(
        id,
        (_) => const MissingUserIdSignInFailure(),
      ).toTaskEither(),
    );

There is more.

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.

getUserInformation: decode Supabase response from JSON

We need to refactor also the getUserInformation and updateUserInformation methods inside SupabaseDatabaseRepository.

getUserInformation makes a select() request using Supabase and returns a UserModel. We need to convert the response from Supabase to JSON (typed as dynamic) to a UserModel.

Just like before, the first step is making the request using TaskEither.tryCatch:

TaskEither<GetUserInformationFailure, UserModel> getUserInformation(
        String userId) =>
    TaskEither<GetUserInformationFailure, dynamic>.tryCatch(
      () => _supabase.client
          .from(_userSupabaseTable.tableName)
          .select()
          .eq(_userSupabaseTable.idColumn, userId)
          .single(),
      RequestGetUserInformationFailure.new,
    )
        .flatMap(
          (response) => Either.tryCatch(
            () => response as Map<String, dynamic>,
            (_, __) => ResponseFormatErrorGetUserInformationFailure(response),
          ).toTaskEither(),
        )
        .flatMap(
          (map) => Either.tryCatch(
            () => UserModel.fromJson(map),
            (_, __) => JsonDecodeGetUserInformationFailure(map),
          ).toTaskEither(),
        );

We type the response from Supabase as dynamic. The second step therefore is to cast the response to Map<String, dynamic> (JSON).

The as operator in dart can throw, therefore we use flatMap to get the response, and then Either.tryCatch to cast it to Map<String, dynamic>. We then convert the result back to a TaskEither using toTaskEither:

TaskEither<GetUserInformationFailure, UserModel> getUserInformation(
        String userId) =>
    TaskEither<GetUserInformationFailure, dynamic>.tryCatch(
      () => _supabase.client
          .from(_userSupabaseTable.tableName)
          .select()
          .eq(_userSupabaseTable.idColumn, userId)
          .single(),
      RequestGetUserInformationFailure.new,
    )
        .flatMap(
          (response) => Either.tryCatch(
            () => response as Map<String, dynamic>,
            (_, __) => ResponseFormatErrorGetUserInformationFailure(response),
          ).toTaskEither(),
        )
        .flatMap(
          (map) => Either.tryCatch(
            () => UserModel.fromJson(map),
            (_, __) => JsonDecodeGetUserInformationFailure(map),
          ).toTaskEither(),
        );

Finally, we use the UserModel.fromJson method to try to convert the data from JSON to a UserModel. Also in this case the conversion may fail, so we need to use tryCatch as well:

TaskEither<GetUserInformationFailure, UserModel> getUserInformation(
        String userId) =>
    TaskEither<GetUserInformationFailure, dynamic>.tryCatch(
      () => _supabase.client
          .from(_userSupabaseTable.tableName)
          .select()
          .eq(_userSupabaseTable.idColumn, userId)
          .single(),
      RequestGetUserInformationFailure.new,
    )
        .flatMap(
          (response) => Either.tryCatch(
            () => response as Map<String, dynamic>,
            (_, __) => ResponseFormatErrorGetUserInformationFailure(response),
          ).toTaskEither(),
        )
        .flatMap(
          (map) => Either.tryCatch(
            () => UserModel.fromJson(map),
            (_, __) => JsonDecodeGetUserInformationFailure(map),
          ).toTaskEither(),
        );

updateUserInformation: map to UserModel

The updateUserInformation refactoring is easier.

We first perform the update request using Supabase, using tryCatch just like before:

TaskEither<UpdateUserInformationFailure, UserModel> updateUserInformation(
  UserModel userModel,
) =>
    TaskEither<UpdateUserInformationFailure, dynamic>.tryCatch(
      () => _supabase.client
          .from(_userSupabaseTable.tableName)
          .update(userModel.toJson()),
      RequestUpdateUserInformationFailure.new,
    )
    .map(
      (_) => userModel,
    );

If the request is successful, we ignore the result and we use map to return the same userModel that we received as input to the function:

TaskEither<UpdateUserInformationFailure, UserModel> updateUserInformation(
  UserModel userModel,
) =>
    TaskEither<UpdateUserInformationFailure, dynamic>.tryCatch(
      () => _supabase.client
          .from(_userSupabaseTable.tableName)
          .update(userModel.toJson()),
      RequestUpdateUserInformationFailure.new,
    )
    .map(
      (_) => userModel,
    );

Perform the request using TaskEither

The very last step is to perform the actual request when the user performs the action (sign up, sign in, sign out, update information).

As an example, let us see how to implement the sign in request inside sign_in_page.dart.

Previously we were using try/catch to perform the request and catch any error:

/// Original implementation before current refactoring
Future<void> _onClickSignIn(BuildContext context) async {
  try {
    await getIt<AuthRepository>().signInEmailAndPassword(email, password);
  } catch (e) {
    // TODO: Show proper error to users
    print("Sign in error");
    print(e);
  }
}

With this new refactoring, we do not need try/catch at all.

In fact, TaskEither provides us with an LoginFailure value when the request fails, which we can use to provide a more actionable error to the user.

We use a SnackBar to show the message to the user. The code looks as follows:

Future<void> _onClickSignIn(BuildContext context) async =>
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          await getIt<AuthRepository>()
              .signInEmailAndPassword(email, password)
              .match(
                (loginFailure) => loginFailure.mapToErrorMessage,
                (_) => "Sign in successful",
              )
              .run(),
        ),
      ),
    );
  • We use ScaffoldMessenger.of(context).showSnackBar to display a SnackBar to the user, with either a success message or an error
  • We use the match method of TaskEither to extract the message in case of an error (loginFailure.mapToErrorMessage) or, in case of success, we display "Sign in successful"
  • Finally, we need to call run(), which executes TaskEither, retuning a Future that we await to get the result

The implementation is similar for the other requests (sign up, sign out, update information). Take a look at the source code to see how they are implemented.


Review: fpdart and Supabase 🤝

That's it! As you can see, using both fpdart and Supabase made the code looks concise and organized.

Supabase allows us to add authentication and database requests in a few lines of code. In this article we have seen:

  • signInWithPassword: Just pass email and password, and Supabase will automatically handle the sign in for you
  • signUp: same as sign in, just pass email and password and you are done
  • signOut: even easier, just call signOut()
  • select(): perform any request to your database, with powerful filters
  • update(): pass the new data and Supabase does the rest

fpdart allows us to easily handle errors and display actionable messages to the user with little effort. In this article we used the following types and methods:

  • TaskEither: perform async request (Task) that may fail (Either)
  • Either: contains the response or an error
  • tryCatch: perform an async request and convert an error (throw) to a value
  • map: change the value inside TaskEither
  • flatMap: chain another async request from the current value inside TaskEither
  • fromNullable: convert a possibly null value to an Either
  • toTaskEither: convert an Either (sync) to a TaskEither (async)
  • match: extract the value (error or success) from an Either
  • run(): execute the TaskEither, returning a Future

This is the starting point for your next application. Supabase offers also other features like Storage and Edge Functions, which we are going to integrate next 🔜


Meanwhile, you can follow @SandroMaglione on Twitter (me 👋) to stay up to date with the latest news and releases.

You can also subscribe to my newsletter here below for tips and updates about dart, flutter, Supabase, and functional programming 👇

👋・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.