Clone / Copy objects in Dart

Especially when working with BLoCs in Flutter, it becomes a regular habit to create a new instance of a state based on a previous state. At least when working with Equatable which is used to reduce the number of state emissions and thus the number of UI repaints when nothing has actually changed.

But independent of the BLoC pattern or the bloc library there can be several other situations in which you want to clone an object, giving the clone other properties than the original object.

Let’s analyze what possibilities we get from Dart and then evaluate what we can use.

There have been discussions about giving Dart the functionality of cloning (or rather shallow copying) natively. However, it seemed like the consensus was that this should not be possible from the outside but rather be provided by the object itself.

It means that every class whose object instances should be copyable or clonable at runtime, need to have respective methods with custom logic that is to be defined by you.

But what should such a method look like? What requirements should it fulfill? Let’s list the requirements and write tests based on them. Then we test different approaches with respect to these requirements:

  • When comparing the original object and the clone via “==”, it should evaluate to false. Otherwise we would have just copied the reference rather than the value
  • This is an implicit consequence of the above but I list it here separately: changing a property in the clone does not in any way affect the original’s property of that kind
  • Given a null value as an argument of the clone method, the cloned object should be null. This seems clear but you will see while I listed this
  • Omitting a property as an argument of the clone method results in this property having the same value as the original’s property of that kind

Let’s define our test object like this:

class SampleModel {
  SampleModel({
    this.id,
    this.header,
    this.body,
  });

  int id;
  String header;
  String body;
}

Okay now that we have a structure we can run tests on, let’s define the test cases we will apply to different copy algorithms:

  • “Values are copied instead of instances”: we use the copy algorithm to clone an instance of our model. We then check equality via “==” and assume it’s false”
  • “Null values override”: when setting a value to null during copy it should be null afterwards
  • “Omitted values don’t change”: when omitting a value during copy, it should have the same value as the original object

Testing different approaches

Our aim is to find a method to clone objects that fulfills the above requirements. Let’ test different approaches regarding these requirements.

Simple assignment

Now we transform the test cases to actual unit tests and test the first copy algorithm – a simple assignment:

group('Assignment', () {
    test('Instances are different', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA;
      modelB.header = "Model B";

      _assertNonEquality(modelA, modelB);
    });

    test('Null values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA;
      modelB.header = null;

      _assertThatNullValuesOverride(modelA, modelB);
    });

    test('Omitted values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA;
      modelB.id = 3;
      modelB.body = 'Testbody';

      _assertThatOmittedValuesHaveOriginalValues(modelA, modelB);
    });
  });
}

SampleModel _createSampleInstance() {
  SampleModel modelA = SampleModel(
    id: 1,
    header: 'Model A',
    body: 'This is a body'
  );
  return modelA;
}

void _assertNonEquality(SampleModel modelA, SampleModel modelB) {
  expect(modelA == modelB, false);
}

void _assertThatNullValuesOverride(SampleModel modelA, SampleModel modelB) {
  expect(modelB.header == null, true);
}

void _assertThatOmittedValuesHaveOriginalValues(SampleModel modelA, SampleModel modelB) {
  expect(modelA.header == modelB.header, true);
}

These are the test results:

Test results of assignment
The rest results

The first tests fails because by assigning modelA to modelB only copies the reference, not the values.

The value we set to null is null in modelB and the other values we did not set have the original value. However, we don’t want the original object to change, when we change a property.

By the way: if we work with classes that extend Equatable, we would have another problem: since it’s annotated @immutable, the above code would not even compile.

Creating a new instance

If assigning the original object to a new variable just copies the reference, then let’s improve it by creating a completely new object and assigning all properties we want to change compared to the original object:

  group('Manually creating a new instance', () {
    test('Instances are different', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = SampleModel(
        id: modelA.id,
        header: modelA.header,
        body: modelA.body
      );

      _assertNonEquality(modelA, modelB);
    });

    test('Null values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = SampleModel(
        header: null
      );

      _assertThatNullValuesOverride(modelA, modelB);
    });

    test('Omitted values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = SampleModel(
        id: 3, body: 'Testbody'
      );

      _assertThatOmittedValuesHaveOriginalValues(modelA, modelB);
    });
  });

These are the results of this approach:

Test results of manual object creation
The test results

We fixed an issue of the previous approach: we end up with a different instance of the same class.

The problem we have now: all the properties we don’t provide are set to null. We would have to manually set every single property we want to apply from the original instance. If we had an object with 5 or more properties, this can be annoying already. We need to find a way to let the algorithm be aware of the original properties while not using reference copy.

Simple “copy” method

Let’s extend the model with a copyWith method. This is actually a common approach when working with TextThemes.

SampleModel copyWith({
  int id, String header, String body
}) => SampleModel(
  id: id ?? this.id,
  header: header ?? this.header,
  body: body ?? this.body
);

Okay, let’s test it:

  group('Simple copyWith', () {
    test('Instances are different', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA.copyWith();

      _assertNonEquality(modelA, modelB);
    });

    test('Null values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA.copyWith(header: null);

      _assertThatNullValuesOverride(modelA, modelB);
    });

    test('Omitted values override existing values', () {
      SampleModel modelA = _createSampleInstance();

      SampleModel modelB = modelA.copyWith(
        id: 3, body: 'Testbody'
      );

      _assertThatOmittedValuesHaveOriginalValues(modelA, modelB);
    });
  });

The test results:

Test results of simple copyWith()
The test results

Our issue here is that Dart is unable to differentiate between omitted arguments and actual null values.

Copy method using Nullables

In order to let the method be aware of the given values being null, we create a wrapper around a given type. This gives us the ability to intentionally set a value to null even if it’s a primitive type.

class Nullable<T> {
  T _value;

  Nullable(this._value);

  T get value {
    return _value;
  }
}

Our new method now expects Nullables instead of the actual types:

SampleModel copyWithImproved({
  Nullable<int> id, Nullable<String> header, Nullable<String> body
}) => SampleModel(
  id: id == null ? this.id : id.value == null ? null : id.value,
  header: header == null ? this.header : header.value == null ? null : header.value,
  body: body == null ? this.body : body.value == null ? null : body.value,
);

Let’s adapt the tests to test our new method:

group('copyWith using Nullable', () {
  test('Instances are different', () {
    SampleModel modelA = _createSampleInstance();

    SampleModel modelB = modelA.copyWithImproved();

    _assertNonEquality(modelA, modelB);
  });

  test('Null values override existing values', () {
    SampleModel modelA = _createSampleInstance();

    SampleModel modelB = modelA.copyWithImproved(
      header: Nullable(null)
    );

    _assertThatNullValuesOverride(modelA, modelB);
  });

  test('Omitted values override existing values', () {
    SampleModel modelA = _createSampleInstance();

    SampleModel modelB = modelA.copyWithImproved(
      id: Nullable(3), body: Nullable('Tesbody')
    );

    _assertThatOmittedValuesHaveOriginalValues(modelA, modelB);
  });
});

When the given arguments are null, we take the values of the instance. If not, we take the value of the Nullable.

Test results of copyWith() expecting Nullables
The test results

Conclusion

If we want a method that copies a given object without changing the original object and only want to provide those arguments that should be changed compared to the original, we can make use of a wrapper around primitive types. There is no native generic way to copy objects so it’s necessary to implement a custom method every time new.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

2 thoughts on “Clone / Copy objects in Dart”

  1. Is it really necessary to complicate things just to pass null? Is it really useful to pass null values to your models?

    Reply
    • How would you deal with resetting parts of your model? Let’s say you have an image view and enable the user to do some editing. If you want to reset the image (to an empty value), how would you do it?

      Reply

Leave a Comment