Supabase is an open source Firebase alternative. Supabase makes super easy adding Authentication, Database, Storage, and more in your app:
- In the first article we learned how to setup a Flutter app with a complete Authentication system using Supabase
- Then, we learned how to sign up new user and enable Row Level Security in our database
- Once the user is logged in, we learned how to use Supabase to access our database and perform any request
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 likeFuture
).Unlike
Future
,TaskEither
also allows us to provide an error (using theEither
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
:
abstract class AuthRepository {
TaskEither<LoginFailure, String> signInEmailAndPassword(
String email,
String password,
);
TaskEither<LoginFailure, String> signUpEmailAndPassword(
String email,
String password,
);
TaskEither<SignOutFailure, Unit> signOut();
}
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
:
abstract class LoginFailure {
const LoginFailure();
}
We then define the concrete errors that extends
the source error LoginFailure
:
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:
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:
- Define source failure type as an
abstract class
- Define all possible errors that
extends
the source failure - 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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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:
- Update the type signature: from
Future<A>
toTaskEither<E, A>
(whereE
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 - 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 usesFuture
) and paste it insideTaskEither.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 theUnit
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
:
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 insideTaskEither
(String?
) and return anotherTaskEither
, 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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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 aSnackBar
to the user, with either a success message or an error - We use the
match
method ofTaskEither
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 executesTaskEither
, retuning aFuture
that weawait
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 passemail
andpassword
, and Supabase will automatically handle the sign in for yousignUp
: same as sign in, just passemail
andpassword
and you are donesignOut
: even easier, just callsignOut()
select()
: perform any request to your database, with powerful filtersupdate()
: 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 errortryCatch
: perform an async request and convert an error (throw
) to a valuemap
: change the value insideTaskEither
flatMap
: chain another async request from the current value insideTaskEither
fromNullable
: convert a possiblynull
value to anEither
toTaskEither
: convert anEither
(sync) to aTaskEither
(async)match
: extract the value (error or success) from anEither
run()
: execute theTaskEither
, returning aFuture
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 π