Today you are going to learn how to practically use fpdart Functional Programming in your Dart and Flutter app.
Many Functional Programming tutorials are impossible to understand because they don't show you practically how can you use Functional Programming in your code. Not this one! In this article, we are going to see a concrete and fully explained example of Functional code inspired by a real dart package.
My goal is to show you (not simply tell) why you should consider Functional Programming and fpdart for your next (and current) application. We will walk through the main classes provided by fpdart and how to use them.
Don't you know what fpdart is? Check out Part 1 of this series in which you will learn Functional Programming in Dart and Flutter using fpdart!
From Imperative to Functional code
The example code of this article is taken from the logger package. I choose a popular dart and flutter package to show you that Functional Programming can be applied to real applications and packages.
We are going to focus on a single function called log. The source code is this:
class Logger {
static Level level = Level.verbose;
bool _active = true;
final LogFilter _filter;
final LogPrinter _printer;
final LogOutput _output;
Logger(this._filter, this._printer, this._output);
/// Imperative (not-functional) code
///
/// From https://github.com/leisim/logger/blob/6832ee0f5c430321f6a74dce99338b242861161d/lib/src/logger.dart#L104
void log(
Level level,
dynamic message, [
dynamic error,
StackTrace? stackTrace,
]) {
if (!_active) {
throw ArgumentError('Logger has already been closed.');
} else if (error != null && error is StackTrace) {
throw ArgumentError('Error parameter cannot take a StackTrace!');
} else if (level == Level.nothing) {
throw ArgumentError('Log events cannot have Level.nothing');
}
var logEvent = LogEvent(level, message, error, stackTrace);
if (_filter.shouldLog(logEvent)) {
var output = _printer.log(logEvent);
if (output.isNotEmpty) {
var outputEvent = OutputEvent(level, output);
try {
_output.output(outputEvent);
} catch (e, s) {
print(e);
print(s);
}
}
}
}
}
We don't really care what this function does. We are going to see how to convert this simple function from Imperative to Functional.
1. Functional Programming is about Pure Functions
When trying to understand the function in isolation you may find some issues. What is _active
? Where does it come from? What about _printer
and _output
?
That is the problem with Impure Functions! The function is using some external variables, which may change unpredictably. We say that impure functions have side effects that change or access something outside of their scope. This makes the code less testable, less readable, and a real pain for every developer!
Functional Programming instead is all about Pure Functions. All the variables that we are going to use must be passed as input to the function. In this way, we are sure that the function will have the same output when given the same input!
void logFunctional({
required Level level,
required dynamic message,
required dynamic error,
StackTrace? stackTrace,
/// Add all external dependencies as input to make the function pure π₯Ό
required bool active,
required LogFilter filter,
required LogPrinter printer,
required LogOutput output,
}) {
/// ...
2. Forget using void
, Functional Programming uses Unit
instead
A function that returns void
is by definition impure. If the function returns void
(nothing), and it does not have side effects (pure), then the function does nothing and there is no reason to call it!
void pureButVoid() {
/// Erm, I cannot return and I cannot access anything outside myself...
/// I am useless! π«
}
Functional Programming uses the Unit
type instead! Unit
is basically a singleton. It has only one instance which is always the same.
/// Use [Unit] instead of `void` to represent a function that returns nothing π
Unit logFunctional({
/// ...
3. Please, do not throw errors. Functional Programming uses Either
instead
If we look at the function, we see that it throws some ArgumentError
. Throwing errors means breaking your code in unexpected ways at runtime!
You must be always in control over errors and how to display them to the user (instead of crashing the app). Functional Programming achieve this using Either
.
Either
can be Left
or Right
(not both!). When Either
is Left
, it means that the function returned an error. When Either
is Right
, it means that the function was successful.
/// [Left] contains a [String] which tells us more information about the error π₯
/// [Right] contains [Unit] π
Either<String, Unit> logFunctional({
/// ...
4. Always in control with the IO
type!
When you normally call a function, it executes and returns the result. In Functional Programming, we often want complete control to decide when the function is executed. We are going to use the IO
type to achieve that!
The IO
type is simply a type that pauses the actual execution of the function until you call the run()
method. Since we want to also keep Either
, we use the IOEither
type of fpdart that easily combines IO
and Either
together:
/// Functional approach πͺ
/// ----------------------------------------------------------------
/// Use [IOEither] to handle errors and avoid throwing exceptions π¨
///
/// Use [Unit] instead of `void` to represent a function that returns nothing π
IOEither<String, Unit> logFunctional({
/// ...
5. How to use Either
instead of throw
Instead of throwing errors, we return a Left
containing the error message. It is a simple as that! We construct a Left
by using the IOEither.left
constructor:
/// Original (not-functional) code
if (!_active) {
throw ArgumentError('Logger has already been closed.');
} else if (error != null && error is StackTrace) {
throw ArgumentError('Error parameter cannot take a StackTrace!');
} else if (level == Level.nothing) {
throw ArgumentError('Log events cannot have Level.nothing');
}
/// FUNCTIONAL CODE
/// Handle errors using [Either] instead of throwing errors π₯
if (!active) {
return IOEither.left('Logger has already been closed.');
} else if (error != null && error is StackTrace) {
return IOEither.left('Error parameter cannot take a StackTrace!');
} else if (level == Level.nothing) {
return IOEither.left('Log events cannot have Level.nothing');
}
6. Immutability, const
and final
instead of var
Something that is mutable is unpredictable. Immutability means that every variable cannot be reassigned or changed. If you need to compute a new variable, you create a new instance that leaves the previous instance unmodified:
/// Mutable
/// How do you know what the current value of `a` is if it can change everywhere?
var a = 0;
a = a + 10;
a = a + 100;
/// Immutable
/// You always know the value of a variable πͺ
const a = 10;
const b = a + 10;
const c = b + 100;
That's way we are going to change var
with const
and final
:
/// Original (not-functional) code
var logEvent = LogEvent(level, message, error, stackTrace);
/// FUNCTIONAL CODE
/// Declare all the variables as `const` or `final` π§±
final logEvent = LogEvent(level, message, error, stackTrace);
7. If
? What happens when if
is false? Handling all cases using Option
Using if
has an intrinsic issue: what happen when the if
check does not pass and you forgot to add an else
case?
We want to be reminded by the type system to handle all possible cases, to handle exceptions and edge cases. We use the Option
type for that! Option
is similar to Either
. Instead of having Left
and Right
, it has None
and Some
. In the None
case, we don't specify any type (differently from Left
of Either
).
We build an instance of Option
from the shouldLog
function by using Option.fromPredicate
:
/// Make sure to handle all the cases using [Option] π
///
/// Use the `identity` function to return the input parameter as it is
final shouldLogOption = Option.fromPredicate(
filter.shouldLog(logEvent),
identity,
);
Now we are required to handle both the true
and false
case. We use the match
function of Option
to do that:
/// Using [Option], you must specify both `true` and `false` cases π
return shouldLogOption.match(
(_) {
/// Do something when the if check is `true`
},
/// Simply return a [Unit] in the else case π
/// `IOEither.of` builds an instance of `IOEither` containing a `unit`
() => IOEither.of(unit),
);
We do the same for the second if check for isNotEmpty
:
/// Using [Option], you must specify both `true` and `false` cases π
return shouldLogOption.match(
/// Use another [Option] to evaluate `printer.log`
(_) => Option<List<String>>.fromPredicate(
printer.log(logEvent),
(v) => v.isNotEmpty,
).match(
(lines) {
/// Do something when the if check is `true`
},
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
),
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
);
}
8. Try catch? No thanks, Either
will save us!
We said no throw allowed. Therefore, try/catch statements are useless!
We use a special constructor of Either
(IOEither
) which automatically catches and handles errors: IOEither.tryCatch
! You specify the function to execute and what to do in case of errors. The method constructs an IOEither
that we return:
/// Using [Option], you must specify both `true` and `false` cases π
return shouldLogOption.match(
/// Use another [Option] to evaluate `printer.log`
(_) => Option<List<String>>.fromPredicate(
printer.log(logEvent),
(v) => v.isNotEmpty,
).match(
(lines) {
/// All variables are `final` π§±
final outputEvent = OutputEvent(level, lines);
return IOEither<String, Unit>.tryCatch(
() {
output.output(outputEvent);
/// Return [Unit] π
return unit;
},
(e, s) {
/// Return an error message π¨
///
/// Do not `print`, it would make the function impure! π€―
return 'An error occurred: $e';
},
);
},
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
),
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
);
9. Running the function using IOEither
Finally, we can call the function and get back our IOEither
!
As I told you before, IO
will not execute the function yet. You are in control! It means that you must explicitly call the run()
method when you want to execute the function and access the Either
inside it:
final ioEither = logFunctional(/* Inputs */); /// IOEither, not executed yet β
final either = ioEither.run(); /// Execute the function and return Either πβ
Comparison
And that's it! We successfully converted imperative code used in a real package to functional programming!
You can compare the two final result here below:
/// Imperative (not-functional) code
///
/// From https://github.com/leisim/logger/blob/6832ee0f5c430321f6a74dce99338b242861161d/lib/src/logger.dart#L104
void log(
Level level,
dynamic message, [
dynamic error,
StackTrace? stackTrace,
]) {
if (!_active) {
throw ArgumentError('Logger has already been closed.');
} else if (error != null && error is StackTrace) {
throw ArgumentError('Error parameter cannot take a StackTrace!');
} else if (level == Level.nothing) {
throw ArgumentError('Log events cannot have Level.nothing');
}
var logEvent = LogEvent(level, message, error, stackTrace);
if (_filter.shouldLog(logEvent)) {
var output = _printer.log(logEvent);
if (output.isNotEmpty) {
var outputEvent = OutputEvent(level, output);
try {
_output.output(outputEvent);
} catch (e, s) {
print(e);
print(s);
}
}
}
}
/// Functional approach πͺ
/// ----------------------------------------------------------------
/// Use [IOEither] to handle errors and avoid throwing exceptions π¨
///
/// Use [Unit] instead of `void` to represent a function that returns nothing π
IOEither<String, Unit> logFunctional({
required Level level,
required dynamic message,
required dynamic error,
StackTrace? stackTrace,
/// Add all external dependencies as input to make the function pure π₯Ό
required bool active,
required LogFilter filter,
required LogPrinter printer,
required LogOutput output,
}) {
/// Handle errors using [Either] instead of throwing errors π₯
if (!active) {
return IOEither.left('Logger has already been closed.');
} else if (error != null && error is StackTrace) {
return IOEither.left('Error parameter cannot take a StackTrace!');
} else if (level == Level.nothing) {
return IOEither.left('Log events cannot have Level.nothing');
}
/// Declare all the variables as `const` or `final` π§±
final logEvent = LogEvent(level, message, error, stackTrace);
/// Make sure to handle all the cases using [Option] π
///
/// Use the `identity` function to return the input parameter as it is
final shouldLogOption = Option.fromPredicate(
filter.shouldLog(logEvent),
identity,
);
/// Using [Option], you must specify both `true` and `false` cases π
return shouldLogOption.match(
/// Use another [Option] to evaluate `printer.log`
(_) => Option<List<String>>.fromPredicate(
printer.log(logEvent),
(v) => v.isNotEmpty,
).match(
(lines) {
/// All variables are `final` π§±
final outputEvent = OutputEvent(level, lines);
return IOEither<String, Unit>.tryCatch(
() {
output.output(outputEvent);
/// Return [Unit] π
return unit;
},
(e, s) {
/// Return an error message π¨
///
/// Do not `print`, it would make the function impure! π€―
return 'An error occurred: $e';
},
);
},
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
),
/// Simply return a [Unit] in the else case π
() => IOEither.of(unit),
);
}
I know it is a lot of new concepts to grasp. I hope I did a good job in introducing you to the world of Functional Programming and fpdart.
Don't worry to much on the details, everything will become clear as you get more used to this new paradigm.
If you are interested to learn more, follow me on Twitter at @SandroMaglione and subscribe to my newsletter here below. Thanks for reading!