Flutter code uses Dart generics all over the place to ensure object types are what we expect them to be. Simply put, generics are a way to code a class or function so that it works with a range of data types instead of just one, while remaining type safe. The type the code will work with is specified by the caller, and in this way, type safety is maintained. You could write code to explicitly work with Dart's dynamic
type, and that would be generic, but not safe.
The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.
Generics in the core libraries
Collections, streams, and futures are the core library features you'll use with generics most often as you write Flutter apps with Dart. It's a good habit to take advantage of generics wherever they're available. Collection generics can help you be certain every element within a collection is of the expected type. It's also important to be able to trust that data that coming out of futures or streams has the right structure, and Dart's generics feature allows you to specify what that structure should be.
Generics with collections
You can declare collection variables without generics like this:
List myList;
Map myMap;
That code is equivalent to the following:
List<dynamic> myList;
Map<dynamic, dynamic> myMap;
This should only be done when you really need a collection containing many different types. If you know the intended type of a list's elements, you should specify that type within the angle brackets, which will allow the Dart analyzer to help you avoid errors:
List<String> myList;
Similarly, if you intend for a map to contain keys and values of a particular type, include them in the declaration:
Map<String, dynamic> jsonData;
Map<int, String> myMap;
With maps, the first type within the angle brackets constrains the map's keys while the second does the same for the map's values. It should be noted that Dart allows you to use any type for map keys, whereas in some languages only strings are allowed.
Serialization: When serializing data to JSON format, the most commonly used compatible type is a map with String keys and dynamic values:Map<String, dynamic>
. JSON encoders often expect this or a plain List (List<dynamic>
) as the base structure.
Next, we'll look at how generics can increase type safety when retrieving data asynchronously.
Generics with asynchronous code
We use asynchronous operations to allow an app to remain responsive while waiting for relatively lengthy operations to complete. Examples of operations that take time in this way might be fetching data over a network, working with the file system, or accessing a database. Dart's primary constructs supporting asynchronous programming are the Future and the Stream.
It's a best practice to include types when dealing with futures and streams. This keeps them from returning data of the wrong type. As in other situations, if you fail to include a specific type, dynamic
is assumed, and any type will be allowed.
Futures
Futures (called promises in some other languages) represent the result of an asynchronous operation. When initially created, a future is uncompleted. Once the operation is complete, the future is completed either with a value or an error. Using generics, we can specify the expected type of the value that is produced.
This function returns a Future, but a bool
is eventually produced when the future completes:
Future<bool> doSomething() {
return Future.delayed(const Duration(seconds: 1), () => true);
}
Streams
Streams are like an asynchronous list, or a data pipe, delivering an asynchronous sequence of data. As values become available, they are inserted into the stream. Listeners on the stream receive the values in the same order they were inserted.
A common way to allow a class to communicate with outside code is to use a StreamController combined with a Stream. Adding generic type designations to these is a good way to make sure they don't deliver unexpected results:
final _onMessage = StreamController<Message>.broadcast();
Stream<Message> get onMessage => _onMessage.stream;
This code creates a StreamController that can be used to send Message objects out on a Stream asynchronously.
Coming up, we'll look at how the Flutter framework uses generics to document expectations and avoid errors.
Generics in Flutter
The most common places you'll make direct use of generics in Flutter are in collections and stateful widgets. This example shows both:
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
const Text("Hello, "),
const Text("World"),
],
);
}
}
Stateful widgets have both a StatefulWidget class and an accompanying State class. The State class uses generics to make sure it deals only with the StatefulWidget it belongs to, using the syntax State<MyWidget>
. A State instance is generic, written to work with any StatefulWidget, and in this case we're creating a state specifically for our MyWidget class. If you were to leave off the angle brackets and the MyWidget symbol, the analyzer wouldn't complain, but you would have created a State tied to the default dynamic
type. Not only is this not type safe, but it could create problems for the framework as it tries to match state instances to the correct widgets.
The List literal passed to children
for the Row widget is similarly typed, this time as a list of Widget objects: <Widget>[]
. This designation helps Dart catch problems at design time. If you try to place a non-widget into that collection, you will get warnings. A Row doesn't know what to do with objects that aren't widgets, so catching this kind of problem before your code runs is very useful. The generics also serve to create code that is more self-documenting, making it clear what's expected within the collection.
Next, we'll look at ways you can use generics in methods and functions that you write.
Creating generic methods and functions
Dart supports the use of generics on methods and functions. This can come in handy when you have an operation to perform and you don't want to write several different versions of that operation to support multiple types.
As an example, suppose you want to create a generic function that can convert a string value into an enum
. Generics can help you avoid using dynamic
, keeping your return type safe:
enum Size {
small,
medium,
large
}
T stringToEnum<T>(String str, Iterable<T> values) {
return values.firstWhere(
(value) => value.toString().split('.')[1] == str,
orElse: () => null,
);
}
Size size = stringToEnum<Size>("medium", Size.values);
In this code, T
represents the type to be provided by the caller of stringToEnum()
. That type will be used as the function's return type, so when we call the function on the last line, size will be safely typed. Incidentally, T
will be passed along to the values
parameter type, ensuring that only the correct types will be accepted in the Iterable collection. The stringToEnum()
function will operate in a type-safe way for any enum
. Of course, if the provided string doesn't match any of the enum
values, null
will be returned.
Without generics, we might have to write stringToSize()
, stringToBorderType()
, stringToTimespan()
, and so on, to cover all the different enum
types we may need to deserialize from string data.
Generics can also be used with classes you create, and we'll explore the possibilities in the next section.
Creating generic classes
You will also want to avoid creating separate classes for the sole purpose of handling different data types. Perhaps you need to create a specialized collection class, and you want to write it just once, allow it to handle any type, but still maintain type safety. Let's use a simple stack as an example:
class Stack<T> {
List<T> _stack = [];
void push(T item) => _stack.add(item);
T pop() => _stack.removeLast();
}
This class provides you with a collection that will do nothing but push items onto a stack and pop them off. It's impossible to access the stack's values directly from outside an instance, as the _stack
property is private. Every operation is type safe through the use of generics, and the collection is guaranteed to be homogeneous (all elements will have the same type), as long as a type is provided when the stack is instantiated:
final stack = Stack<String>();
stack.push("A string."); // works
stack.push(5); // error
This stack will not allow a value of the wrong type to be added. Additionally, the pop()
method will produce a value with a matching return type.
Conclusion
Now you can say you've learned about using generics to increase type safety within your apps, which will lead to fewer type-related errors and fewer unpleasant surprises for your users. So get out there and write some generic code!