Extensions in Dart allow you to add functionality to an already closed piece of logic, such a class that has already been written whether that class is written by you or by a third party as part of a library or a package! Extensions are not limited to classes; you can extend enums, mixins and more! In this article, we will see how extensions work and have a look at a lot of examples to get a better understanding of extensions in Dart.
Anatomy of an Extension
An extension in Dart starts with the word extension
, followed by the name of the extension in PascalCase
, then the on
keyword followed by the name of the datatype that the extension is being applied on and finally opening and closing parenthesis. Here is an example of an empty extension on int
datatype:
extension MyExtension on int { // empty for now }
This extension, though technically conformant to the extension specifications in Dart, is of very little value. It has nothing additional to add to the int
datatype but we will remedy that in the coming sections as we see more examples of extensions.
Extension names are unique in any scope. That means you cannot write the same name for an extension even if that extension is defined on two different types. The following code won’t compile because of this:
extension MyExtension on int { // empty for now } // 🚨 this is a compile time error // because "MyExtension" is already defined // in the current scope on "int" and cannot // be re-created, even if it's on a new datatype extension MyExtension on String { // empty for now }
When an extension is written on a type T, inside the extension, the keyword this
refers to the instance of T
on which the extension is created. Keep that in mind, it’s very important!
Access Control
Extensions have access to pretty much all of their super-type’s data, even if that data is marked with an underscore to make them private. For instance, the following code compiles and works fine although it ideally shouldn’t:
import 'dart:developer' as devtools show log; extension Log on Object { void log() => devtools.log(toString()); } // a simple immutable Person class @immutable class Person { // we attempt to store a string value // secretly and mark the variable as // private by prefixing it with an underscore final _secret = 'Secret'; // then we also have a "secret" function // that does something very-secret (not really) // and we mark it as private by // prefixing it with an underscore void _somethingSecret() {} const Person(); } // then we create an extension on Person extension DoSomethingSecret on Person { // and expose this function that internally // calls the _somethingSecret() function // and even accesses the _secret variable void doSomethingSecret() { _somethingSecret(); _secret.log(); } } void testIt() { const p = Person(); // now at the call-site we can call the // extension's function p.doSomethingSecret(); }
In Python, if you want to make a property or a function private you would prefix it with double underscores and that prevents others from calling that function inside your class. Some other languages do have proper access controls when it comes to private properties and functions but at the time of writing this post Dart is yet to implement proper private properties and functions. Until then, we need to live with the idea that an extension can potentially access private properties or functions of our class. But it’s best that as extension writers you keep a professional facade and only access the public APIs of a class or an enumeration or mixin.
Generic Extensions
Extensions can include generic constraints into their implementation. Let us say that we want to create a new extension on Iterable
that exposes the iterable’s first element. This code won’t compile:
// this is for the purpose of this example // only. Iterable already has a `first` getter // so we don't need a new getter // 🚨 compile-time error // a new extension on Iterable that // attempts to expose a new variable called // `firstItem` that delegates its task simply // to the existing `first` variable. extension GetFirstItem on Iterable { // 🚨 compile-time error // `E` generic constraint is not known E get firstItem => first; }
Even though Iterable
has a generic parameter called E
as in Element, as shown in the code below, the extension we are writing has no idea about E
:
abstract class Iterable<E> { // TODO(lrn): When we allow forwarding const constructors through // mixin applications, make this class implement [IterableMixin]. const Iterable();
For this to work, you’ll have to specify a generic type on your extension as well and that generic type doesn’t have to be named E
at all. You can name it anything you want, all of these extensions are valid:
// gets the first element inside the iterable // using a generic type called Element extension GetFirstItem<Element> on Iterable<Element> { Element get firstItem => first; } // gets the last element inside the iterable // using a generic type called T extension GetLastItem<T> on Iterable<T> { T get lastItem => last; } // ensures the iterable has only one element // and gets it using a generic type called E extension GetSingleItem<E> on Iterable<E> { E get singleItem => single; }
Generic Constraints
Let’s say that you want to write an extension on an Iterable
instance that contains integers of type int
and you want to expose a new property on this iterable called maxValue
which returns the biggest value in the iterable, if any, or 0 if no values are there. You could implement this extension like so:
import 'package:collection/collection.dart' show maxBy; import 'dart:developer' as devtools show log; extension Log on Object { void log() => devtools.log(toString()); } extension MaxInt on Iterable<int> { // use the maxBy function to extract the maximum value // from our Iterable<int> int get maxValue => maxBy(this, (i) => i) ?? 0; } void testIt() { // put it to test [1, 2, 3].maxValue.log(); // 3 (<int>[]).maxValue.log(); // 0 }
This works great and all but then you realize you’ve implemented this extension only on Iterable<int>
so if you have an Iterable<double>
you will have to reimplement it for double
too:
// now we have a duplicate extension // just because we want to have the `maxValue` // property available on `Iterable<double>` too extension MaxDouble on Iterable<double> { // use the maxBy function to extract the maximum value // from our Iterable<double> double get maxValue => maxBy(this, (i) => i) ?? 0.0; }
The better way of going about doing this is with generic constraints on extensions as shown here:
extension MaxNum<E extends num> on Iterable<E> { // use the maxBy function to extract the maximum value // from our Iterable<int> E get maxValue => (maxBy(this, (i) => i) ?? 0) as E; } void testIt() { // put it to test [1, 2, 3].maxValue.log(); // 3 (<int>[]).maxValue.log(); // 0 // now our extension works on Iterable<double> too [1.1, 2.2, 3.3].maxValue.log(); // 3.3 }
The most important part of this code is the E extends num
in the definition of the MaxNum
extension itself. This way, we are defining a generic parameter that should inherit from num
(whether it is double
or int
or something else) and use that generic type in Iterable<E>
. From that point on, inside that extension, the generic type E
will refer to any type inside the current iterable (this
) that comes either directly or indirectly from num
!
Example: Recursive Flattening of Lists
The best way to learn about extensions in Dart is to practice them. In this example we will write an extension function on Iterable
that uses synchronous generators in order to recursively flatten an iterable meaning that if that iterable contains other iterable instances inside it, they will all be flattened and the result will be just one giant iterable:
import 'dart:developer' as devtools show log; extension Log on Object { void log() => devtools.log(toString()); } // this extension will help us flatten an iterable extension Flatten<T extends Object> on Iterable<T> { Iterable<T> flatten() { // inside the `flatten()` method we will have a private // function that recursively flattens // the iterable so that we can pass the iterable to // flatten to the function. The `flatten()` function // itself operates solely on `this` instance so we cannot // specify `this` as a parameter to the function Iterable<T> _flatten(Iterable<T> list) sync* { for (final value in list) { if (value is Iterable<T>) { // if we stumble upon an `Iterable` inside // the current `Iterable` we will recursively // flatten it yield* _flatten(value); } else { yield value; } } } return _flatten(this); } } // and we can then put this to use like so void testIt() { final flat = [ [[1, 2, 3], 4, 5], [6, [7, [8, 9]], 10], 11,12 ].flatten().log(); // (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) }
Example: Where Clause on Map
The Map
class in Dart allows you to store keys and values. It does come with a lot of functions but it lacks support for the where()
function that is available on Iterable
which allows you to filter items from the iterable. Let’s implement something similar on Map
using generic extensions:
import 'dart:developer' as devtools show log; extension Log on Object { void log() => devtools.log(toString()); } extension DetailedWhere<K, V> on Map<K, V> { // this function allows us get a new Map from // the `this` instance where the keys and values // go through the `f` function and if the result // to this function is `true` then the key and value // will be included in the result Map<K, V> where(bool Function(K key, V value) f) => Map<K, V>.fromEntries( entries.where((entry) => f(entry.key, entry.value)), ); // we then use our `where()` function to create // another `where` function which solely operates // on the keys inside our map, completely ignoring // the values Map<K, V> whereKey(bool Function(K key) f) => {...where((key, value) => f(key))}; // similar to the above, we write another function // that solely operates on the values, and not // the keys Map<K, V> whereValue(bool Function(V value) f) => {...where((key, value) => f(value))}; } typedef Age = int; typedef Name = String; const Map<Name, Age> people = { 'John': 20, 'Mary': 21, 'Peter': 22, }; void testIt() { // {Peter: 22} people.where((key, value) => key.length > 4 && value > 20).log(); // {John: 20, Mary: 21} people.whereKey((key) => key.length < 5).log(); // {John: 20, Peter: 22} people.whereValue((value) => value.isEven).log(); }
Example: Key and Value Mapping
If you have a Map
in Dart, by default you can map every key and value combination to create a new Map
. But what if you want to either map the keys or the values but not both. There is no functionality built for that in Map
itself but you can create an extension which does exactly that:
import 'dart:developer' as devtools show log; extension Log on Object { void log() => devtools.log(toString()); } const names = { 'foo': 22, 'bar': 30, 'baz': 40, }; extension DetailedMap<K, V> on Map<K, V> { // an extension function on Map which allows // you to map only the keys of the map // and reuse each key's value into the // new map Map<R, V> mappedKeys<R>(R Function(K) f) => map((key, value) => MapEntry(f(key), value)); // same as the above function but this time // we map only the values of the map Map<K, R> mappedValues<R>(R Function(V) f) => map((key, value) => MapEntry(key, f(value))); } void testIt() { // {foofoo: 22, barbar: 30, bazbaz: 40} names.mappedKeys((k) => k * 2).log(); // {foo: 44, bar: 60, baz: 80} names.mappedValues((v) => v * 2).log(); }
Where to Go From Here
Have a look at my Flutter Tips and Tricks repository on GitHub for hundreds of examples on how to use extensions in Dart to make better public APIs. Then it’s just practicing that will help you understand extensions better.