Generics are a topic in Dart, and in all programming languages that support generics, that continues to confuse a lot of developers. In this article we will go through all aspects of generics and see how they work in Dart with lots of examples.
What are Generics?
Generics are a way for a programming language such as Dart to not directly specify the data-type of a value in a class, or a mixin, extension, type-alias or a function. They usually are written as letters such as E or T or G but can also be written as full words such as Element or Type or Value or Key.
For instance, Dart’s own Map
class has two generic types, the key and the value:
abstract class Map<K, V> { /// Creates an empty [LinkedHashMap]. /// /// This constructor is equivalent to the non-const map literal `<K,V>{}`. /// /// A `LinkedHashMap` requires the keys to implement compatible /// `operator==` and `hashCode`. /// It iterates in key insertion order. external factory Map(); ...
This simply means that the Map
class can take any key or value combination and the compiler will resolve it to the correct type as the data stored in the map itself. Let’s have a look at an example:
// Map<String, String> final address = { 'street': '123 Main Street', 'city': 'Anytown', 'state': 'California' }; // Map<String, Object> final myDetails = { 'name': 'Vandad', 'address': address, 'age': 38 }; // Map<String, int> final ages = { 'Vandad': 38, 'John': 30, 'Jane': 27 };
In all these 3 blocks of code, the object that ends up being created is a Map
but the keys and values are of different types, the keys of the first Map
are String
and so are the values. In the second Map
the keys are String
and the values are Object
and last but not least, the third map has keys of String
and values of type int
. The combination of String + String, String + Object, and String + Int is represented by the generic parameters of K
and V
in the Map
implementation itself as we saw the code for Map
earlier. So instead of the Dart team creating 3 different classes for Map
where the keys and values are locked to a specific data-type, the team chose generic types to represent any combination of keys and values!
That’s really where the power of generics come to play. For me, any time I noticed that a function, mixin, class, type-alias or extension does not necessarily have to be locked into an exact data-type, that’s when I know I can turn that code into a generic code.
Constrained Generic Functions
A function can be made generic when its inputs and/or the result do not necessarily have to be tied to an exact data-type. Let’s say that you are tasked with writing a function that has no parameters, and it returns either an int
or a double
depending on what the caller expects it to do. How could you write such a function? Let’s see a very naive implementation of such a function:
// this is just for the purpose of demonstration // ideally you should never write a function // like this in your code num eitherIntOrDouble(bool isInt) { // detect if the requested value is an int if (isInt) { // if yes, return 1 return 1; } else { // if no, assume it's a double // and return 1.1 return 1.1; } }
At the call-site then when you call this function, you have to pass a parameter to it stating whether you want an int
or not, and if you pass false
to the isInt
parameter, you will get a double
. But note that the data-type of the returned value of this function is num
and cannot be both a double
and an int
at the same time, hence we are using a super data-type that wraps both int
and double
:
// take the result and manually convert // it to an `int` and print it out to console final age = eitherIntOrDouble(true) as int; age.log(); // prints 1 // do the same thing but this type convert // the result to a `double` and print it out final height = eitherIntOrDouble(false) as double; height.log(); // prints 1.1
As you can see both the function implementation and the call-site look quite strange in that you have to make decisions on the type of value to return based on a parameter (and if you remember we were supposed to not have any parameters to the function at all). But if you ignore that fact for a while and try to clean up this code, you could go ahead and change the input parameter to an enum type so you don’t have to use a boolean:
// an enumeration that dictates // what type of data the caller expects enum TypeOfData { integer, double } // then expect that enum as the parameter num eitherIntOrDouble(TypeOfData typeOfData) { switch (typeOfData) { case TypeOfData.integer: return 1; case TypeOfData.double: return 1.0; } } void testIt() { final age = eitherIntOrDouble(TypeOfData.integer) as int; age.log(); // prints 1 final height = eitherIntOrDouble(TypeOfData.double) as double; height.log(); // prints 1.1 }
This didn’t make our code so much nicer after all. We need a better way of writing this function and this is the exact time where you need to think about generics. If your function has to return either X or Y and you have no idea which one you need to return while writing the code, it’s time to use generics!
Let’s introduce a generic type to our function and re-write the code:
// a function that returns either an `int` // or a `double` depending on the data-type // that the caller expects from this // function. This function's return value // should conform to the `num` class which only // `int` and `double` are allowed to conform to E eitherIntOrDouble<E extends num>() { switch (E) { case int: return 1 as E; case double: return 1.0 as E; default: // this happens when the call-site forgets // to specify the generic type argument return 0 as E; } }
Pay close attention to the way the function is defined. It specifies not only a generic data-type called E
(as in element), but it specifies that the generic type has to be conformant to the num
class, meaning that it can only be an int
or a double
. If you read the documentation for the num
class in Dart, you can see this fact:
/// An integer or floating-point number. /// /// It is a compile-time error for any type other than [int] or [double] /// to attempt to extend or implement `num`. /// /// **See also:** /// * [int]: An integer number. /// * [double]: A double-precision floating point number. /// * [Numbers](https://dart.dev/guides/language/numbers) in /// [A tour of the Dart language](https://dart.dev/guides/language/language-tour). abstract class num implements Comparable<num> {
Inside the function itself, if you go back to our code, you can see that we are checking the data-type to return using a switch statement. How do you then call this function, well, you can call it in two ways. Either you specify the data-type for the function to return or you don’t. If you specify the data-type, you will get that data-type (int
or double
) back, but if you don’t, you will get a num
back where our code just returns the value of 0 in the default
case:
// the call-site specifies the expected return type // by specifying a data-type for its variables final int age = eitherIntOrDouble(); age.log(); // prints 1 // specify `double` and get a `double` back final double height = eitherIntOrDouble(); height.log(); // prints 1.1 // in this case the call-site forgets to specify // the generic type argument, so the compiler // infers the type to be `num` final forgetDataType = eitherIntOrDouble(); forgetDataType.log();
If you were to call this function and expect a data-type that is not a sub-type of num
, you will get a run-time error (as opposed to a compile-time error), meaning that you app compiles fun but when it runs, it will crash and burn:
// 🚨 this causes a run-time error // type 'int' is not a subtype of type 'Never' // in type cast final String invalid = eitherIntOrDouble(); invalid.log();
In other languages such as Swift, if you were to write the same type of code, you would get a compile-time error (rather than run-time) because the compiler understands that a String
cannot be a numeric type:
// the same function implementation // as we had in Dart, but this time in Swift so the // syntax might look a bit strange to you func eitherIntOrDouble<E>() -> E where E: Numeric { switch E.self { case is Int.Type: return 1 as! E; case is Double.Type: return 1.0 as! E; default: return 0 as E; } } func testIt() { let integer: Int = eitherIntOrDouble() let double: Double = eitherIntOrDouble() // 🚨 compile-time error let invalid: String = eitherIntOrDouble() }
While Swift and Rust act a bit more appropriately when asked to return generic data-types that do not conform to the constrains, Dart also does quite a good job in at least throwing a run-time error (rather than compile-time), it’s better than nothing and we just have to learn to live with it until perhaps newer versions of Dart get more advanced and detect these problems at compile-time instead of run-time.
Unconstrained Generic Functions
A constrained generic function as we saw earlier is a function that has a constraint on its generic type where the generic type has to be of a specific data-type. An unconstrained generic function is a function whose generic type is not constrained to any other type. Here is a very simple example:
// a function that takes in any value of any // data-type and returns the same // value with the same data-type T iReturnWhatYouGiveMe<T>(T value) => value; void testIt() { // integer is of type `int` here because // the value of `1` is an integer of type `int` final integer = iReturnWhatYouGiveMe(1); // float is of type `double` here because // the value of `1.0` is a floating point // value of type `double` final float = iReturnWhatYouGiveMe(1.1); // str is of type `String` here because // the value of `'hello'` is of type `String` final str = iReturnWhatYouGiveMe('hello'); }
This function simply returns anything it gets. Anything here is specified by the T
generic type but since it can be anything, T
is not constrained unlike the previous section where we talked about constrained generic functions.
Unconstrained generic functions are functions that do not put a constraint on their generic types. Remember that a function doesn’t necessarily have to be limited to 1 generic type. A function can have as many generic types as it requires and none of them have to be constrained!
Here is an example of a function that has two unconstrained generic parameters:
// a function that checks whether two values // which are passed as parameters have the same // datatype or not bool doTypesMatch<T, E>(T value1, E value2) => T == E; // we can then put the function to test void testIt() { doTypesMatch(1, 2).log(); // true doTypesMatch(1, 2.2).log(); // false doTypesMatch('Hello', 1).log(); // false }
Multiple unconstrained generic types can also be returned from a function, rather than just being accepted as parameters. Dart doesn’t (yet) have support for tuples unlike Rust or Swift for instance, so we cannot use generic tuples to return multiple generic types but you can create a simple tuple data type yourself, as you will see later when we talk about generic classes, and use that generic tuple with multiple unconstrained generic types. Continue reading the article to see more examples of this.
Constrained Generic Type-Aliases
Just like constrained generic functions which we saw examples of earlier, you can also create constrained generic type-alias. Let’s have a look at an example where we have a mixin and two classes that implement this mixin:
// a normal mixin that expects // any type that implements it to // have an `activate()` function mixin CanBecomeActive { void activate(); } // an immutable `Person` class that // implements the `CanBecomeActive` mixin @immutable class Person with CanBecomeActive { const Person(); @override void activate() { //empty for now } } // same as above but this time under a different // name of `Animal @immutable class Animal with CanBecomeActive { const Animal(); @override void activate() { //empty for now } }
Now if you want to define a type-alias to a Map
that contains keys that can be activated (read, conform to the CanBecomeActive
mixin) you could write your code as shown here:
// a type-alias whose keys are constrained // to have to conform to the `CanBecomeActive` mixin typedef PersonsAndNames<C extends CanBecomeActive> = Map<C, String>; void testIt() { const PersonsAndNames personsAndNames = { Person(): 'Vandad', Animal(): 'Kitty', }; // {Instance of 'Person': Vandad, Instance of 'Animal': Kitty} personsAndNames.log(); }
This type-alias ensures that all keys of the underlying Map
conform to the CanBecomeActive
mixin, so both Person
and Animal
classes, or any other classes written in the future that conform to this mixin, will be able to get stored in the Map
.
Unconstrained Generic Type-Aliases
A generic type-alias is a generic shortcut to another generic data type. For instance, Dart has a generic class called MapEntry
that can contain a key and a value of generic types. In some other languages, this same data structure might be called KeyValue
so let’s create a generic type-alias to it:
// a generic type-alias in Dart is created // using the `typedef` keyword. This one // is used to pretty much give a new name to the // existing MapEntry class but this time we call // it KeyValue typedef KeyValue<K, V> = MapEntry<K, V>; void testIt() { const KeyValue<String, int> keyValue = MapEntry('key', 1); keyValue.log(); // MapEntry(key: 1) }
You might be asking yourself: couldn’t we just remove the K
and the V
generic types in KeyValue
and have the KeyValue
be a simple type-alias to MapEntry
? You would be right, we could do that but the results are not going to be pretty:
// ⚠️ failing to mention the generic types in a type-alias // results in `dynamic` to always be picked // as the data-type regardless of the actual // type being used typedef KeyValue = MapEntry; void testIt() { const KeyValue keyValue = MapEntry('key', 1); keyValue.runtimeType.log(); // ⚠️ MapEntry<dynamic, dynamic> }
In this code you will notice that the compiler substitutes the data-type of dynamic
for all missed generic types in our type alias and that’s not good, hence the reason why it’s best to specify the generic data-types in a type-alias and pass them to the type (in this case MapEntry
) that resolves those generic types.
Specializing Generic Type-Aliases
When using type-aliases in Dart, the compiler usually does a good job of determining the data-type of generic types. However, sometimes you might want to be more specific about the data-types. Let’s see an example:
// a generic type-alias that resolves to a // Map<String, T> where T can only be a number // that is derived from the `num` class: meaning // that it can either be an integer or a double typedef NamesAndHeights<T extends num> = Map<String, T>; void testIt() { // This data-type is inferred to be Map<String, num> // eventhough the values passed to the map are // both of type `int` final NamesAndHeights namesAndHeights = { 'Vandad': 180, 'Joe': 170, }; }
In this case, by simply specifying that our namesAndHeights
variable is of type NamesAndHeights
which itself is a generic type-alias, the compiler resolves the underlying type as Map<String, num>
and doesn’t understand that the underlying num
instances are both of type int
so it should ideally resolve the type to be Map<String, int>
.
If that’s your intention and the compiler couldn’t really figure out your intentions, you can go ahead and specialize your type-alias by specifying the generic data-types at call-site as shown here:
// we are specializing the generic constraint // on `NamesAndHeights` to be specifically // of type `int` final NamesAndHeights<int> namesAndHeights = { 'Vandad': 180, 'Joe': 170, };
In this code we are explicitly asking the compiler to se the data-type of all the keys in the resulting Map
to int
since we are sure all values in the Map
are of that type. If you then break your own rules, you will get a compile-time error, as expected:
final NamesAndHeights<int> namesAndHeights = { 'Vandad': 180, 'Joe': 170.2, // 🚨 compile-time error // a `double` value is not an `int` };
Generic Mixins
Just like generic functions and type-aliases, mixins in Dart can also be generic. A generic mixin is used in places where you cannot make an exact decision as to the data-type to be used for a variable or a function’s return type or parameter inside a mixin.
Let’s say that you have a mixin called HasHeight
where the height is programmed to be of type double
. You then go ahead and implement this mixin inside a Person
class as shown here:
// a mixin that exposes a `height` property mixin HasHeight { double get height; } // a class that mixes this mixin @immutable class Person with HasHeight { // we then define the height exactly // as specified by the mixin @override final double height; const Person(this.height); }
What happens then if you want to create another class called Dog
that also has a height but the height has to be of type int
?
@immutable class Dog with HasHeight { @override // 🚨 this code won't compile because // HasHeight expects the height variable // to be of type `double` but in our implementation // we need it to be an integer of type `int` final int height; // 🚨 compile-time error const Dog(this.height); }
This code won’t compile since HasAge
needs the height variable to be of type double
. Wouldn’t it be better if this mixin left it to the class to decide whether the height
variable is an integer or a double? That’s why you need generic mixins. Let’s change the implementation of HasHeight
to become generic:
// a mixin that exposes a generic `height` property mixin HasHeight<H extends num> { H get height; } // a class that mixes this mixin // where the height is a `double` @immutable class Person with HasHeight { @override final double height; const Person(this.height); } // and in this class we mix the same mixin // but instead have a `height` property that is an `int` @immutable class Dog with HasHeight { @override final int height; const Dog(this.height); }
This time our generic mixin is constrained to have a generic type as H
(as in height) and expects this generic type to extend num
, meaning that the generic value is either of type int
or double
. After making this change to our mixin, both the Person
and the Dog
classes will happily compile and Dart will be happy too.
If you have a generic mixin, you can always determine the generic type stored in it by a simple if-statement as shown here:
// a function that takes in any object that conforms // to the `HasHeight` mixin and logs the height // to the console void describe(HasHeight hasHeight) { if (hasHeight.height is int) { 'The height is an int'.log(); } else if (hasHeight.height is double) { 'The height is a double'.log(); } else { 'The height is neither an int nor a double'.log(); } } void testIt() { describe(const Person(180.0)); // prints: The height is a double describe(const Dog(40)); // prints: The height is an int }
Specializing Generic Mixins
You can also specialize generic functions that take in generic mixins as parameters. Let’s say that you have to write a function that takes in a parameter of type HasHeight
but only operates on the parameter if it has a height of type int
. You should then change your Dog
implementation to ensure that its height
property is indeed marked as int
also in the class definition:
// ensure that your dog class specializes // the `HasHeight` mixin and ensure that the // height value is specified as a `int` @immutable class Dog with HasHeight<int> { @override final int height; const Dog(this.height); } void describe<H extends int>(HasHeight<H> hasHeight) { 'The height is an int'.log(); } void testIt() { // 🚨 compile-time error, Person // does not have a `height` property of // type `int` describe(const Person(180.0)); // 🚨 compile-time error describe(const Dog(40)); // prints: The height is an int }
Sometimes you might need to fidget with the type-system a little bit in Dart to get things to work. For instance, in the example above, if you left out Dog
to use HasHeight
instead of specializing it as HasHeight<int>
, the Dart compiler won’t understand that the implementation of Dog
indeed is specializing the height
property as int
. In some other languages such as Rust, the compiler can infer these generics automatically, but in Dart, you might need to help the compiler sometimes.
Generic Classes
All we’ve learnt about generics so far culminate into creating generic classes. A generic class is a class who has variables, or functions, or both, who are generic. A generic class usually is a container class since a non-generic class itself can contain generic functions. But if you are to declare generic variables on a class, then your class for sure has to be a generic class.
Let’s see an example of a non-generic class in Dart that has a generic function:
// a very simple example of a non-generic // class that has a generic function class Person { // a generic function that takes in another // function as a parameter, calls the // function-parameter and returns its result T get<T>(T Function() getter) => getter(); }
This class is a non-generic class in that it has no generic types in its definition but it has a generic function as part of its implementation.
When we talk about generic classes, we usually mean a class that has at least one generic data type. Let’s implement a simple Tuple2
class that contains two values which can be returned from a function inside another class:
// a simple Tuple class that can contain // 2 values, and each of the values can be // of any data type @immutable class Tuple2<V1, V2> { final V1 first; final V2 second; const Tuple2(this.first, this.second); }
Now that we have this generic class, we can use it inside another class whose implementation itself isn’t generic:
// a `Person` class that has a first and last name // properties and can export those properties // in form of a `Tuple2<String, String>` class Person { final String firstName; final String lastName; const Person(this.firstName, this.lastName); // here we get our tuple of first and last name Tuple2<String, String> get fullName => Tuple2(firstName, lastName); }
Generic Extensions
After implementing our Tuple2
class before, we can create generic extensions on our class. For instance, if we have a Tuple<int, int>
, we can write an extension that can sum up the values and return the sum as a property:
// our extension only works on tuples whose // both values are of type `int` extension Tuple2Sum on Tuple2<int, int> { int get sum => first + second; } void testIt() { // the first two lines work const Tuple2(1, 2).sum.log(); // 3 const Tuple2(10, 20).sum.log(); // 30 // this line won't compile const Tuple2(1, 2.2).sum.log(); // 🚨 compile-time error }
You might be asking yourself: but why can’t we let an integer be added to a double and return the double as the result? Well, there is no reason for not allowing that. All you have to do is to put a constrain on your generic extension and instead of extending Tuple2<int, int>
, extend Tuple2<T, T>
where T
is a num
as shown here:
// our extension works now on any combination of // values as long as they extend `num` extension Tuple2Sum<T extends num> on Tuple2<T, T> { T get sum => first + second as T; } void testIt() { // all 3 compile fine now const Tuple2(1, 2).sum.log(); // 3 const Tuple2(10, 20).sum.log(); // 30 const Tuple2(1, 2.2).sum.log(); // 3.2 }
Where to Go From Here
Generics are an area of Dart that you will learn more about only when you get to them and need them and practice them. I suggest that you go through the code of Map
in Dart’s foundation code which is open-source and you can actually get to simply by going to the definition of Map
in Visual Studio Code for instance, and look at the implementation of Map
which is generic and has generic functions and other neat pieces of generic code. After that, start writing some generic code yourself and experiment with various generic ideas that you might come across.
Hi Vandad awesome job, I’m really proud supporting your YouTube channel , you’re doing an huge work, for me and other ones, thanks to share your ultra wide acknowledgement. Do you think to publish an Dart Full Stack Course ?
Thanks ahead for watching
Paolo Sgro’
Hi Paolo and thank you very much for your support of my YouTube channel and for your kind words.
I might actually work on a Dart Full Stack Course. Right now I’m working on the Free Full-stack Course on my YouTube channel with Django/Python and Rust plus Flutter. After that I might get into Dart Full-stack course