You've successfully subscribed to developer.school
Great! Next, complete checkout for full access to developer.school
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

Immutability and Equality in Dart (and Flutter)

In this article we're going to investigate ways that we can make robust classes with Dart. We'll start by creating a basic Product class with a few properties and slowly build it up to where we're implementing best practices.

Paul Halliday
Paul Halliday

In this article we're going to investigate ways that we can make robust classes with Dart. We'll start by creating a basic Product class with a few properties and slowly build it up to where we're implementing best practices.

You can find the code for this article here:

https://github.com/PaulHalliday/ds_immutability

Let's jump in!

Project Setup

We'll be creating a new Flutter project for our example, by running the following in the terminal:

$ flutter create ds_immutability
$ cd ds_immutability
$ code . (or your favourite editor)

We can create a basic StatefulWidget named ExamplePage that can be set the home of our MaterialApp:

class ExamplePage extends StatefulWidget {
  @override
  _ExamplePageState createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Immutability and Equality"),),
      body: Container(),
    );
  }
}

// main.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Immutability and Equality',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: ExamplePage(),
    );
  }
}

Value Assignment

Imagine we had a Product class with the following properties:

import 'package:flutter/material.dart';

class Product {
  String id;
  String name;
  Color color;

  Product({
    this.id,
    this.name,
    this.color,
  });
}

We could initialise it and show it on our ExamplePage like so:

class ExamplePage extends StatefulWidget {
  @override
  _ExamplePageState createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage> {
  Random _random;
  Product _product;

  @override
  void initState() {
    super.initState();
    _product = Product(id: "1", name: "iPhone 12", color: Colors.grey);
    _random = Random();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Immutability and Equality"),
      ),
      body: ListTile(
        leading: CircleAvatar(
          backgroundColor: _product.color,
        ),
        title: Text(
          _product.name,
        ),
        subtitle: Text(
          _product.id,
        ),
      ),
    );
  }
}

This then looks like:

As none of the Product properties are final, we can easily change them by assigning a new value:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text("Immutability and Equality"),
    ),
    floatingActionButton: FloatingActionButton(
      child: Icon(Icons.refresh),
      onPressed: _randomProductData,
    ),
    body: ListTile(
      leading: CircleAvatar(
        backgroundColor: _product.color,
      ),
      title: Text(
        _product.name,
      ),
      subtitle: Text(
        _product.id,
      ),
    ),
  );
}

void _randomProductData() {
  final randomNumber = _random.nextInt(13);
  setState(() {
    _product.id = randomNumber.toString();
    _product.name = "iPhone $randomNumber";
  });
}

Clicking the FloatingActionButton will update the UI with new Product information:

final, const and @immutable

What if we made these properties final?

class Product {
  final String id;
  final String name;
  final Color color;

  const Product({
    this.id,
    this.name,
    this.color,
  }) ;
}

As expected, adding final means that we can no longer assign values to id, name  and color in the way we did before.

We're also able to add the const keyword here because the constructor is initialising the Product class with our final fields, making this a compile-time constant.

We can also add the @immutable annotation to our class which shows an error if any of the fields are not final:

@immutable
class Product {
  String id;
  final String name;
  final Color color;

  Product({
    this.id,
    this.name,
    this.color,
  });
}

If that happens, we get the following error:

This class (or a class that this class inherits from) is marked as '@immutable', but one or more of its instance fields aren't final: Product.id

Let's add that back:

@immutable
class Product {
  final String id;
  final String name;
  final Color color;
  
  ...
 }

Updating the Product

In order to update our Product, we now have to assign the Product to a new Product instance:

setState(() {
  _product = Product(
    id: randomNumber.toString(),
    name: "iPhone $randomNumber",
    color: Colors.red,
  );
});

If we didn't want to change the color we would still have to specify it like so:

setState(() {
  _product = Product(
    id: randomNumber.toString(),
    name: "iPhone $randomNumber",
    color: _product.color,
  );
});

copyWith

One way to make life easier for us is to add a copyWith method so that any new assignments will either overwrite the value if specified, otherwise return the current value:

@immutable
class Product {
  final String id;
  final String name;
  final Color color;

  const Product({
    this.id,
    this.name,
    this.color,
  });

  Product copyWith({
    String id,
    String name,
    Color color,
  }) =>
      Product(
          id: id ?? this.id,
          name: name ?? this.name,
          color: color ?? this.color);
}

I wrote about copyWith in another article if you'd like to see other examples:

Dart/Flutter: What does copyWith() do?
Although the notion of copyWith() isn’t specifically related to Dart or Flutter,the pattern is used throughout quite often and I see this question pop up allthe time. I’d give this the following definition: > copyWith allows us to copy a T and pass in arguments that overwrite settablevalues.L…

Let's update our Product assignment with the new copyWith functionality:

setState(
  () => _product = _product.copyWith(
    name: "iPhone $randomNumber",
  ),
);

If we test our application, everything works as expected:

Value Assertions

When we create a new instance of this class, we may want to ensure that every (or just some) values are not null. We can do that with an assert after the constructor:

const Product({
  this.id,
  this.name,
  this.color,
})  : assert(id != null),
      assert(name != null);

If we've made a new Product but haven't specified a name or id, it'll throw an exception like so:

We can catch this before running the application and provide a warning by adding the @required annotation:

const Product({
  @required this.id,
  @required this.name,
  this.color,
})  : assert(id != null),
      assert(name != null);

This allows us to both have named parameters and enforce that properties are set when creating instances of a class:

If we don't want a property to be required, we can also add a reasonable default like so:

const Product({
  @required this.id,
  @required this.name,
  this.color = Colors.red,
})  : assert(id != null),
      assert(name != null);

If we create a product without color it'll now have Colors.red as the default color:

toString

If we were to print out an instance of our Product at this stage, we'd get the following:

_product = Product(id: "1", name: "iPhone 12");
print(product);

// flutter: Instance of 'Product'

We can make this better by overriding toString to return the values of our Product in a readable format:

@immutable
class Product {
  // Rest of the Product class

  @override
  String toString() {
    return "Product: ID = $id, Name = $name";
  }
}

If we run our print method again, we now get:

flutter: Product: ID = 1, Name = iPhone 12
I've omitted the color property from the toString, but you don't have to.

You may be thinking, how can we @override a method if we aren't extending anything? That's because class implicitly extends from Object which contains methods such as toString and the equality operator, which we'll be looking at next.

Removing Setters

Another thing we can do to ensure that the only way properties are updated with Product are via the copyWith method is to remove setters for each property. Update the Product class to have private members that are set within the constructor:

@immutable
class Product {
  final String _id;
  String get id => _id;

  final String _name;
  String get name => _name;

  final Color _color;
  Color get color => _color;

  const Product({
    @required String id,
    @required String name,
    Color color = Colors.red,
  })  : _id = id,
        _name = name,
        _color = color,
        assert(id != null),
        assert(name != null);
   
   // Rest of the Product class
 }

If we now attempt to set a value such as id, we get the following error:

No setter in sight!

This means that the properties of our class are entirely public facing by get only.

Equality

The final thing I'd like to cover before jumping into freezed (in our next article) is the aspect of equality. Dart will return true if the compared values are the same instance, but otherwise will return false.

With this in mind, we get some fun results:

_productOne = Product(id: "1", name: "iPhone 12");
print(_productOne == Product(id: "1", name: "iPhone 12"));

// Result: false

print(_productOne == _productOne);

// Result: true

Ideally, we'd like to be able to compare on value equality rather than instance equality. Remember Object that I was talking about? It registers the == operator and the above is the default implementation.

As our class extends from Object by default, we can override the equality operator like so:

@immutable
class Product {
  // Rest of the Product class
  
  @override
  bool operator ==(Object other) =>
      other is Product &&
      other.id == id &&
      other.name == name &&
      other.color == color;
}

Here we're making direct comparisons. We're ensuring that the value we're comparing to is both a Product and every property matches for it to be considered equal.

Every Object (or class) has a hashCode which is also used for equality and is represented as an integer. The initial value of the hashCode is the identity of the instance, but we've now told the == operator to use internal class state.

If we comment out the override to the == operator for now (and just use the default equality checkers), we can see that the hashcodes are different for two products with the same values:

_productOne = Product(id: "1", name: "iPhone 12");
_productTwo = Product(id: "1", name: "iPhone 12");

print(_productOne.hashCode);
print(_productTwo.hashCode);

// Result
flutter: 231335118
flutter: 415874183

These two items therefore, are not equal as we'd like them to be.

As a result, we need to also override the hashCode to truly enforce equality comparison:

@immutable
class Product {
  // Rest of the Product class
  
  @override
  bool operator ==(Object other) =>
      other is Product &&
      other.id == id &&
      other.name == name &&
      other.color == color;

  @override
  int get hashCode => hashValues(id, name, color);
}

You don't have to worry too much about the inner workings of hashValues as this comes from Flutter, but essentially, it takes our state values and gives us a consistent integer hash value.

Let's try our equality comparisons again:

_productOne = Product(id: "1", name: "iPhone 12");
_productTwo = Product(id: "1", name: "iPhone 12");

print("Product 1 Hash Code: ${_productOne.hashCode}");
print("Product 2 Hash Code: ${_productOne.hashCode}");
print(
    "Does Product 1 == Product 2? ${_productOne == _productTwo ? "Yes" : "No"}");
    
// Result:

flutter: Product 1 Hash Code: 121398706
flutter: Product 2 Hash Code: 121398706
flutter: Does Product 1 == Product 2? Yes

Our Product instances are now equal from a hashCode and == point of view. This is required for equality to adequately work and we can't have one without the other.

Here's the final Product class for you to review:

import 'package:flutter/material.dart';

@immutable
class Product {
  final String _id;
  String get id => _id;

  final String _name;
  String get name => _name;

  final Color _color;
  Color get color => _color;

  const Product({
    @required String id,
    @required String name,
    Color color = Colors.red,
  })  : _id = id,
        _name = name,
        _color = color,
        assert(id != null),
        assert(name != null);

  Product copyWith({
    String id,
    String name,
    Color color,
  }) =>
      Product(id: id ?? _id, name: name ?? _name, color: color ?? _color);

  @override
  String toString() {
    return "Product: ID = $id, Name = $name";
  }

  @override
  bool operator ==(Object other) =>
      other is Product &&
      other._id == _id &&
      other._name == _name &&
      other._color == _color;

  @override
  int get hashCode => hashValues(_id, _name, _color);
}

Summary

In this article we investigated different ways we can improve on our basic Product class to prevent us making mistakes down the line. By being more defensive in the way we build our classes, we make ourselves less susceptible to bugs.

In the next article in this series, we'll be looking at how to use the Freezed library to automatically generate all of what we did above. When you come to use this yourself, you'll be patting yourself on the back as you know how it works!

How to use Freezed with Flutter
If you’re new to Flutter or haven’t used immutable classes before, you may notsee an immediate value in using freezed. This article is here to show you whyyou should consider thinking about this topic and then how to implement thiswith freezed. If you haven’t read Part 1 of this series where we …
Flutter

Paul Halliday

👋 Want to see more content? Head over to the YouTube channel: https://youtube.com/c/paulhalliday!