β€’

tech

How to use fpdart Functional Programming in your Dart and Flutter app

Learn how to use fpdart and Functional Programming in your Dart and Flutter app. Example of how to use fpdart in a real flutter package.


Sandro Maglione

Sandro Maglione

Software development

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!

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.