How do you solve the problemt to clear fields in copyWith methods

One drawback with dealing with immutable data objects is that I haven’t found a good solution to distinguish not changing a value in a copyWidth vs resettting a field to null.
The only approach I have seen so far is to pass a Map<String,object> to the copyWith instead of the standard pattern that passes optional parameters.
IMHO instead of introducing a required keywords Dart should have gotten an explicit optional parameters.

Immutable objects are not the same use case than form.

For the later case the code gen would be different with a wrapper around setter that update the map for you.

But using this for every object would consume a lot of memory and produce more to garbage collect.

What’s your use case? can you convert the immutable object to a real edition object?

Indeed in our current code base we have some forms that use copyWidth instead of directly modifying the object which now is biting me.

Remi has to deal with this in freezed. I think he uses distinguished values that aren’t null, since you’re allowed to have nullable fields (at least that was true the last time I checked).

When I need something quick and dirty and don’t want to pull in Freezed:

class Wrap<T> {
  final T value;
  Wrap(this.value);
}

class Name {
  final String name;
  final String? nickName;
  
  const Name({required this.name, this.nickName});
  
  copyWith({String? name, Wrap<String?>? nickName}) =>
    Name(name: name ?? this.name,
               nickName: nickName == null ? this.nickName : nickName.value);
  
    String toString() => '($name, $nickName)';
  }

void main() {
  var myName = Name(name: 'Fred', nickName: 'Freddy');
  // prints '(Fred, Freddy)'
  print(myName.toString());

  myName = myName.copyWith(name: 'Jessica', nickName: Wrap('Jessie'));
  // prints '(Jessica, Jessie)'
  print(myName.toString());
  
  myName = myName.copyWith(nickName: Wrap(null));
  // prints '(Jessica, null)'
  print(myName.toString());
}
1 Like

This is how I solved it with records:

ActivityState copyWith({
  SimpleDate? day,
  ({Activity? value})? selectedActivity, // can be set to null via record
}) {
  return ActivityState(
    day: day ?? this.day,
    selectedActivity: selectedActivity != null ? selectedActivity.value : this.selectedActivity,
  );
}

And then usage:

void selectActivity(Activity activity) {
  emit(
    state.copyWith(
      selectedActivity: (value: activity),
    ),
  );
}

void deselectActivity() {
  emit(
    state.copyWith(
      selectedActivity: (value: null),
    ),
  );
}
4 Likes

Interesting approach with records. I was thinking of using some wrapper class but this is quite elegant

I’ve used List as a wrapping:

update(foo: [3], bar: ['Hello'], bletch: [])

On unpacking, if the value is null, I leave it alone. Otherwise, if it’s an empty list, I set it to null, otherwise I set it to .first. Pretty simple.

What I like with the approach with the lists is that is stays type safe compared to using a map.

Probably the version with the Records is today the simplest one and should be used in future examples.

Records can only have a fixed number of items though (1, 3, 22, whatever). The List wrapper strategy can change 4 or 5 things at once. You can even destructure the incoming list with a nice pattern match that stays strongly typed.

I’ve used two approaches in the past.

One is to define a new method, copyWithout, which has a series of Boolean arguments, which for a given field if true will specify null (or some default value if the property is not a nullable type), otherwise retaining the original objects value for that property

The other is rather than having a nullable argument of type T in copyWith, have a function that returns T (or T? if the property is null). This allows you to distinguish between the case where you haven’t specified a value and the case where you want to reset the property to some value

dart_mappable also has null-aware copyWith support

1 Like

Can you share a code example of the second approach?

void main() {
  final initialUser = ForumUser(lastPostAt: null);
  assert(initialUser.lastPostAt == null);

  // User makes a post
  final userAfterFirstPost = initialUser.copyWith(
    lastPostAt: () => DateTime.now(),
  );
  assert(userAfterFirstPost.lastPostAt != null);

  // User deletes their one and only post
  final userDeletesOnlyPost = userAfterFirstPost.copyWith(
    lastPostAt: () => null
  );
  assert(userDeletesOnlyPost.lastPostAt == null);
  
  print('Done');
}

class ForumUser {
  const ForumUser({
    required this.lastPostAt,
  });
  
  final DateTime? lastPostAt;
  
  ForumUser copyWith({
    DateTime? Function()? lastPostAt,
  }) {
    return ForumUser(
      lastPostAt: lastPostAt != null ? 
        lastPostAt() : this.lastPostAt,
    );
  }
}

I used the copyWithout approach before until it bit me. When new optional properties were added, I forgot to add it to the method and it didn’t copy those.

Now I just use a boolean flag clearX in the copyWith

1 Like

We have solved this for our models in Serverpod by using two classes. One abstract class with a typed interface and an implementation class which extends the abstract class. It works well, and you can reset a field by passing null. The only drawback is that it doesn’t work with inheritance if you want to preserve the type safety (plus the verbosity of it, which may be less of an issue with code generation).

Here is an example.

This feels like an unsolved issue in Dart. Personally, I’d like to see something like and UndefinedOr, similar to FutureOr.

2 Likes

I like this one! Never thought about that.

I usually use a function approach, similar to what is done inside freezed.

person.dart

class Person {
  final String name;
  final int? age;

  Person({required this.name, this.age});

  Person copyWith({
    String? name,
    int? Function()? age,
  }) {
    return Person(
      name: name ?? this.name,
      age: age == null ? this.age : age(),
    );
  }
}

person_test.dart

void main() {
  group('copyWith', () {
    test('updates the age', () {
      final person = Person(name: 'John', age: 30);
      final updatedPerson = person.copyWith(age: () => 31);
      expect(updatedPerson.age, 31);
    });
    test('sets age to null', () {
      final person = Person(name: 'John', age: 30);
      final updatedPerson = person.copyWith(age: () => null);
      expect(updatedPerson.age, null);
    });

    test('ignores the age', () {
      final person = Person(name: 'John', age: 30);
      final updatedPerson = person.copyWith(name: 'Doe');
      expect(updatedPerson.age, 30);
    });
  });
}

You can also create a typedef so you don’t need to remember the syntax

typedef CopyWithBuilder<T> = T? Function()?;

Person copyWith({
  String? name,
  CopyWithBuilder<int> age,
}) {
  return Person(
    name: name ?? this.name,
    age: age == null ? this.age : age(),
  );
}

1 Like

I don’t have a new example, just wanted to thank all for sharing their knowledge and ideas. I :heart: this forum!
For now, I like @dominik 's solution with records best, but all other suggestions are also quite clever.

1 Like

How about a setNull?

setNull({bool nickName = false}) {
   return Name(
     name: this.name,
     nickName: nickName ? null : this.nickName,
   );
}

usage:

myName = myName.copyWith(name: 'John').setNull(nickName: true);