Flutter app development tutorials by Andrea Bizzotto

Dart Extensions: Full Introduction and Practical Use Cases

Level: beginner

This tutorial is a complete introduction to Dart extensions, a language feature that was added in Dart 2.6.

As part of this, we will see:

  • How to enable extensions inside an existing Flutter project
  • What extensions are, and where they can be used to improve our code
  • Where they should not be used

As they say, with great power comes great responsibility. And Dart extensions are one language feature that should be used sensibly.

Alright, so let's get started, and see how we can enable extensions in our Flutter projects.

How to enable extensions

Open the pubspec.yaml file & update the environment section to use the SDK version 2.6.0 or greater:

environment:
  sdk: ">=2.6.0 <3.0.0"

That's it. We can now use extensions in our Flutter projects.

Getting started with extensions

You can use Dartpad to experiment with extensions.

Suppose that we're writing a program to convert temperature values from Celsius to Farhenheit.

We could use a double to represent a temperature value.

First off, we can define an extension on double, along some methods to convert degrees from celsius to Farhenheit, and viceversa:

extension on double {
  // note: `this` refers to the current value
  double celsiusToFarhenheit() => this * 1.8 + 32;
  double farhenheitToCelsius() => (this - 32) / 1.8;
}

Once we have this, we can define a double value, representing a temperature of 10.0 degrees Celsius, and convert it to Farhenheit:

double tempCelsius = 10.0;
double tempFarhenheit = tempCelsius.celsiusToFarhenheit();
print('${tempCelsius}C = ${tempFarhenheit}F');

If we run this code, it will print:

10C = 50F

Takeaway: we can use extensions to add new functionality to existing types.

Alongside methods, we can also add getters and setters inside our extensions.

But we cannot add instance variables.

In other words, we can use extensions to extend the functionality of our types, but not the underlying data.


By the way, at the beginning of this tutorial I said that we should be mindful when we use extensions, and there are cases where it's best to not use them.

And this temperature conversion example, is one such case.

In fact, nothing stops us from writing code like this:

tempCelsius.celsiusToFarhenheit().celsiusToFarhenheit();

This converts the temperature to Farhenheit more than once, which doesn't make sense.

Takeaway: when designing an API to work with temperatures, extensions are not a good fit, and we should define a completely different type instead.

Here's a much better approach, which creates a new Temperature type:

class Temperature {  
  final double celsius;
  double get farhenheit => celsius * 1.8 + 32;
}

This always represents the temperature in degrees Celsius internally.

And we can get the Farenheit value with the provided getter.

Then, we can add some factory constructors to make our API nicer:

class Temperature {  
  Temperature._({this.celsius});
  factory Temperature.celsius(double degrees) => Temperature._(celsius: degrees);
  factory Temperature.farhenheit(double degrees) => Temperature._(celsius: (degrees - 32) / 1.8);

  final double celsius;
  double get farhenheit => celsius * 1.8 + 32;
}

And in our main file, we can create temperature values like this:

final tempA = Temperature.celsius(10);
final tempB = Temperature.farhenheit(50);

In this example, using a bespoke type for Temperature:

  • leverages the type system
  • makes our code much easier to undestand
  • avoids overloading generic numeric types such as double, with domain specific logic for temperature conversion.

Let's get back on track and learn more about extensions.

Generics

Dart extensions can be used with generic types.

So our next goal is to write an extension that would allow us to write code like this:

final total1 = [1, 2, 3].sum(); // 6
final total2 = [1.5, 2.5, 3.5].sum(); // 7.5

To do this, we can create a named extension on Iterable<T>, called IterableNumX:

extension IterableNumX<T extends num> on Iterable<T> {}

This extension operates on any type T that extends num (num is a type that represents a number in Dart). So this extension will work both with collections of type int and double.

And then, we can define a sum() method with a return type of T, and implement it like so:

extension IterableNumX<T extends num> on Iterable<T> {
  T sum() {
    // 1. initialize sum
    var sum = (T == int ? 0 : 0.0) as T;
    // 2. calculate sum
    for (var current in this) {
      if (current != null) { // only add non-null values
        sum += current;
      }
    }
    return sum;
  }
}

This method initializes sum to either interger 0 or double 0 depending on the type of T.

Then it calculates the sum by iterating through the values of this (our iterable object), and returns the result at the end.

With this extension, we can express the sum of all elements in a numeric collection like this:

final total1 = [1, 2, 3].sum(); // 6
final total2 = [1.5, 2.5, 3.5].sum(); // 7.5

Takeaway: Extensions can be named, and they can work with generics.

Let's see in which other ways we could use them.

Padding with less boilerplate

We can use extensions to extend existing Flutter types, and reduce boilerplate code for common layout tasks.

Consider this code:

class ColumnLayout extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Text('One Line'),
          Text('Another line'),
          customRaisedButton(
            context,
            onPressed: () {},
            child: Text('OK'),
            borderRadius: 8,
          ),
        ],
      ),
    );
  }
}

This represents a Column layout with two Text widgets and a custom button.

Used like this, it stretches the content edge-to-edge within the parent container.

So it is desirable to add some Padding to each widget inside this column:

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: <Widget>[
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: Text('One Line'),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: Text('Another line'),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: customRaisedButton(
        context,
        onPressed: () {},
        child: Text('OK'),
        borderRadius: 8,
      ),
    ),
  ],
)

This works but the Padding widgets add a lot of noise and make our code less readable.

So to overcome that, we could define an extension on the Widget class:

extension WidgetPaddingX on Widget {
  Widget paddingAll(double padding) => Padding(
        padding: EdgeInsets.all(padding),
        child: this,
      );
}

This takes the current widget (this), add a Padding as a parent and return it.

The benefit of this is that we can rewrite the code above as:

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: <Widget>[
    Text('One Line').paddingAll(8),
    Text('Another line').paddingAll(8),
    customRaisedButton(
      context,
      onPressed: () {},
      child: Text('OK'),
      borderRadius: 8,
    ).paddingAll(8),
  ],
)

The result is the same, but the code is much more readable.

Credit: I borrowed this specific example from this blog post by Quick Bird Studios.

Note on named extensions

Using the example above, we could remove the extension name, and import it in a separate file:

// widget_padding_x.dart
import 'package:flutter/material.dart';

extension on Widget {
  Widget paddingAll(double padding) => Padding(
        padding: EdgeInsets.all(padding),
        child: this,
      );
}

// text_with_padding.dart
import 'package:flutter/material.dart';
import 'widget_padding_x.dart';

class TextWithPadding extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // this doesn't compile
    return Text('test').paddingAll(8);
  }
}

However, this code will not compile, because the extension does not have a name and the paddingAll() method cannot be resolved by the compiler.

Takeaway: Always name extensions if you want to import and use them from different files.

Extensions with static methods: extending ShapeBorder

There is one Flutter API that I always forget how to use correctly, and that is the ShapeBorder class that we can use to define various shapes.

Here's an example of a custom button class that uses a RoundedRectangeBorder to define the shape that we want.

import 'package:flutter/material.dart';

class CustomRaisedButton extends StatelessWidget {
  CustomRaisedButton({
    Key key,
    this.child,
    this.color,
    this.borderRadius: 2.0,
    this.onPressed,
  }): super(key: key);
  final Widget child;
  final Color color;
  final double borderRadius;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      child: child,
      color: color,
      disabledColor: color,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.all(
          Radius.circular(borderRadius),
        ),
      ),
      onPressed: onPressed,
    );
  }
}

We can extract the code to created the RoundedRectangleBorder into an extension on ShapeBorder:

extension ShapeBorderX on ShapeBorder {
  static ShapeBorder roundedRectangle(double radius) {
    return RoundedRectangleBorder(
      borderRadius: BorderRadius.all(
        Radius.circular(radius),
      ),
    );
  }
}

With this, every time we need a rounded rectangle shape, we can just write:

shape: ShapeBorderX.roundedRectangle(borderRadius),

This is easier to remember.

Creating widgets with static extension methods

In my code, I often need to create a widget class that has a parent Bloc or ChangeNotifier.

Here's some code using a ChangeNotifierProvider along with a ValueNotifier, as a way to implement the Flutter counter example, without using StatefulWidget with setState().

class CounterPage extends StatelessWidget {
  static Widget create(BuildContext context) {
    return ChangeNotifierProvider<ValueNotifier<int>>(
      builder: (_) => ValueNotifier<int>(0),
      child: CounterPage(),
    );
  }
  // build method here
}

Here we use a static create() method, which can be invoked as CounterPage.create(context) at the calling site.

However, this is an additive helper method, and the CounterPage class is already complete without it.

If we want better separation of concerns, we can move this into an extension called CounterPageX:

extension CounterPageX on CounterPage {
  static Widget create(BuildContext context) {
    return ChangeNotifierProvider<ValueNotifier<int>>(
      builder: (_) => ValueNotifier<int>(0),
      child: CounterPage(),
    );
  }
}

And then we could update the call site to use CounterPageX.create(context).


In general, I feel that extensions may be a good place to hold static helper methods for existing classes.

Because they let us more clearly separate all the main functionality inside our classes, from any class-specific helpers that we may need.

Word of caution

Now that we have seen all these examples, you may think that exceptions are the next big thing, and want to use them everywhere.

Extensions all the things

Not so fast.

Just because we can use extensions, it doesn't mean that we should use them everywhere.

Ultimately, we want our code to be understandable and easy to navigate.

If we overuse extensions, they can quickly become a dumping ground for helper methods that we don't know where else to put.

So I encourage you to only use them where it makes sense.

A good criteria when designing extension-based APIs based is to ask yourself if your changes will lead to a better API.

And a desirable trait of a good API is that it should be hard to use incorrectly.

Our first temperature conversion example illustrates this quite well:

// not recommended
extension on double {
  double celsiusToFarhenheit() => this * 1.8 + 32;
  double farhenheitToCelsius() => (this - 32) / 1.8;
}

// much better
class Temperature {  
  Temperature._({this.celsius});
  factory Temperature.celsius(double degrees) => Temperature._(celsius: degrees);
  factory Temperature.farhenheit(double degrees) => Temperature._(celsius: (degrees - 32) / 1.8);

  final double celsius;
  double get farhenheit => celsius * 1.8 + 32;
}

void main() {
  double tempCelsius = 10.0;
  // API **can** used incorrectly
  tempCelsius.celsiusToFarhenheit().celsiusToFarhenheit();
  
  final tempA = Temperature.celsius(10);
  // API **can't** be used incorrectly
  tempA.farhenheit;
}

What extensions can't do

The things that extensions can't do include:

  • Adding constructors to extensions (whether they are factory constuctors or not)
  • Extending interfaces or methods from base classes (with @override)

If you try the latter, you'll get an error like this:

Extensions can't declare members with the same name as a member declared by 'Object'.

DartX

DartX is a package which adds a lot of useful extension methods to types in the Dart Language.

And this includes additional functionality to work with Iterable, strings, time utilities and much more.

So I encourage you to get familiar with this package, and include it in your own projects.

styled_widget

styled_widget is another package that started as an experiment, showing how widget styling APIs could be improved, using a syntax that is similar to the SwiftUI framework by Apple.

The README we shows how to style Text and Icon widgets, in a way that is more readable than the "standard" Flutter APIs. Example:

@override
Widget build(BuildContext context) => FlutterLogo()
  .padding(all: 20)
  .backgroundColor(Colors.blue)
  .constraints(width: 100, height: 100)
  .borderRadius(all: 10)
  .elevation(10)
  .alignment(Alignment.center);

Under the hood, this uses extensions together with copyWith, as a way to modify an existing widget, and replace it with a new copy that has some different properties.

Note: this package is still an experiment which is subject to breaking changes, and is not ready for production-use. This may or may not change in the future.

In any case, it would be interesting to see if the Flutter team will lead the way and improve some widget APIs to use extensions in the future.

Conclusion

Thanks for reading!

If you find some cool new ways of using extensions in your own projects, let me know on Twitter.

Want more? Get my Flutter & Firebase Udemy Course: View Course