30

I'm trying to create a "copyWith" method for my class, and it works with most scenarios.

The problem is when I try to set a nullable property to null, because my function cannot recognize whether it's intentional or not.

Ex.:

class Person {
  final String? name;
  
  Person(this.name);
  
  Person copyWith({String? name}) => Person(name ?? this.name);
}

void main() {
  final person = Person("Gustavo");
  print(person.name); // prints Gustavo
  
  // I want the name property to be nul
  final person2 = person.copyWith(name: null);
  print(person2.name); // Prints Gustavo
}

Does anyone knows some workaround for this situation? This is really bothering me and I don't know how to avoid this situation.

Gustavo Ifanger
  • 403
  • 4
  • 6
  • What is the expected output? you want `person2.name` to be `null` ? – Rohan Thacker Jun 16 '21 at 20:21
  • Exactly. I want to set de name of person2 to null. But when I pass null as parameter, the "copyWith" method assumes the current value (as expected). I've tried to do something like `Person copyWith({String? name = this.name})` but Dart accepts only constants as default value. – Gustavo Ifanger Jun 16 '21 at 20:44

9 Answers9

38

One solution is to use a function to set the value. This gives you more options.

  • A function that isn't provided: null
    • This will not change the value
  • A function that is provided and returns null: () => null
    • This will set the value to null
  • A function that returns the name: () => 'Gustavo'
    • This will set the value to Gustavo
class Person {
  final String? name;

  Person(this.name);

  Person copyWith({String? Function()? name}) =>
      Person(name != null ? name() : this.name);
}

void main() {
  final person = Person('Gustavo');
  print(person.name); // prints Gustavo

  // I want the name property to be nul
  final person2 = person.copyWith(name: () => null);
  print(person2.name); // Prints null

  final person3 = person.copyWith(name: () => 'new name');
  print(person3.name); // Prints new name

  final person4 = person.copyWith();
  print(person4.name); // Prints Gustavo
}

It makes setting the name slightly more cumbersome, but on the bright side the compiler will tell you that you've provided the wrong type if you try to pass a string directly, so you will be reminded to add the () => to it.

Daniel Arndt
  • 2,268
  • 2
  • 17
  • 22
  • 1
    Thanks for your contribution. In my point of view this approach is more clearer to understand and does not require any additional classes/packages. Nice one :) – Gustavo Ifanger Mar 24 '22 at 01:01
  • 1
    Agreed this is also the easiest and clearest solution IMO, however I would just rename the `copyWith` method to something like `nullableCopyWith` to make very clear to everybody using your method that it won't be the standard `copyWith` that one might expect. – Pom12 Nov 22 '22 at 10:31
19

Inspired by @jamesdlin answer:

All you need to do is provide a wrapper around. Take this example in consideration:

class Person {
  final String? name;

  Person(this.name);

  Person copyWith({Wrapped<String?>? name}) =>
      Person(name != null ? name.value : this.name);
}

// This is all you need:
class Wrapped<T> {
  final T value;
  const Wrapped.value(this.value);
}

void main() {
  final person = Person('John');
  print(person.name); // Prints John

  final person2 = person.copyWith();
  print(person2.name); // Prints John

  final person3 = person.copyWith(name: Wrapped.value('Cena'));
  print(person3.name); // Prints Cena

  final person4 = person.copyWith(name: Wrapped.value(null));
  print(person4.name); // Prints null
}
iDecode
  • 22,623
  • 19
  • 99
  • 186
  • 1
    This seems like a good approach (and is probably better than what I suggested). However, I think that `Optional` is not a good class name in this case since it doesn't itself represent an optional value; it represents the *desired* value, and the optional nature is implemented by using `Optional?` instead of `Optional`. (Maybe just name it something generic such as `Boxed`/`Wrapped` /`Value`, and that could also be used to mimic a pass-by-reference system.) – jamesdlin Apr 04 '22 at 15:18
16

There are multiple options:

1. ValueGetter

class B {
  const B();
}

class A {
  const A({
    this.nonNullable = const B(),
    this.nullable,
  });

  final B nonNullable;
  final B? nullable;

  A copyWith({
    B? nonNullable,
    ValueGetter<B?>? nullable,
  }) {
    return A(
      nonNullable: nonNullable ?? this.nonNullable,
      nullable: nullable != null ? nullable() : this.nullable,
    );
  }
}

const A().copyWith(nullable: () => null);
const A().copyWith(nullable: () => const B());

2. Optional from Quiver package

class B {
  const B();
}

class A {
  const A({
    this.nonNullable = const B(),
    this.nullable,
  });

  final B nonNullable;
  final B? nullable;

  A copyWith({
    B? nonNullable,
    Optional<B>? nullable,
  }) {
    return A(
      nonNullable: nonNullable ?? this.nonNullable,
      nullable: nullable != null ? nullable.value : this.nullable,
    );
  }
}

const A().copyWith(nullable: const Optional.fromNullable(null));
const A().copyWith(nullable: const Optional.fromNullable(B()));

3. copyWith as field

class _Undefined {}

class B {
  const B();
}

class A {
  A({
    this.nonNullable = const B(),
    this.nullable,
  });

  final B nonNullable;
  final B? nullable;

  // const constructor no more avaible
  late A Function({
    B? nonNullable,
    B? nullable,
  }) copyWith = _copyWith;

  A _copyWith({
    B? nonNullable,
    Object? nullable = _Undefined,
  }) {
    return A(
      nonNullable: nonNullable ?? this.nonNullable,
      nullable: nullable == _Undefined ? this.nullable : nullable as B?,
    );
  }
}

A().copyWith(nullable: null);
A().copyWith(nullable: const B());

4. copyWith redirected constructor

class _Undefined {}

class B {
  const B();
}

abstract class A {
  const factory A({
    B nonNullable,
    B? nullable,
  }) = _A;

  const A._({
    required this.nonNullable,
    this.nullable,
  });

  final B nonNullable;
  final B? nullable;

  A copyWith({B? nonNullable, B? nullable});
}

class _A extends A {
  const _A({
    B nonNullable = const B(),
    B? nullable,
  }) : super._(nonNullable: nonNullable, nullable: nullable);

  @override
  A copyWith({B? nonNullable, Object? nullable = _Undefined}) {
    return _A(
      nonNullable: nonNullable ?? this.nonNullable,
      nullable: nullable == _Undefined ? this.nullable : nullable as B?,
    );
  }
}

const A().copyWith(nullable: null);
const A().copyWith(nullable: const B());

5. copyWith redirected constructor 2

class _Undefined {}

class B {
  const B();
}

abstract class A {
  const factory A({
    B nonNullable,
    B? nullable,
  }) = _A;

  const A._();

  B get nonNullable;
  B? get nullable;

  A copyWith({B? nonNullable, B? nullable});
}

class _A extends A {
  const _A({
    this.nonNullable = const B(),
    this.nullable,
  }) : super._();

  @override
  final B nonNullable;
  @override
  final B? nullable;

  @override
  A copyWith({B? nonNullable, Object? nullable = _Undefined}) {
    return _A(
      nonNullable: nonNullable ?? this.nonNullable,
      nullable: nullable == _Undefined ? this.nullable : nullable as B?,
    );
  }
}

const A().copyWith(nullable: null);
const A().copyWith(nullable: const B());
maRci002
  • 341
  • 3
  • 7
  • `Optional.fromNullable` will result in the same as `.absent()` if the value is `null`, which is not what we want. We want the an `Optional` with `isPresent` being `true` but with `value` being `null`. – Anakhand Mar 28 '23 at 16:50
8

Person.name is declared to be non-nullable, so it is impossible for copyWith to assign a null value to it. If you want Person.name to be nullable, you should ask yourself if you really want a distinction between null and an empty string. Usually you don't.

If you actually do want to allow both null and empty strings, then you either will need to use some other sentinel value:

class Person {
  static const _invalid_name = '_invalid_name_';

  final String? name;
  
  Person(this.name);
  
  Person copyWith({String? name = _invalid_name}) =>
    Person(name != _invalid_name ? name : this.name);
}

or you will need to wrap it in another class, e.g.:

class Optional<T> {
  final bool isValid;
  final T? _value;

  // Cast away nullability if T is non-nullable.
  T get value => _value as T;

  const Optional()
      : isValid = false,
        _value = null;
  const Optional.value(this._value) : isValid = true;
}

class Person {
  final String? name;

  Person(this.name);

  Person copyWith({Optional<String?> name = const Optional()}) =>
      Person(name.isValid ? name.value : this.name);
}

void main() {
  final person = Person("Gustavo");
  print(person.name);

  final person2 = person.copyWith(name: Optional.value(null));
  print(person2.name);
}

There are existing packages that implement Optional-like classes that probably can help you.

jamesdlin
  • 81,374
  • 13
  • 159
  • 204
  • Thanks, I've forgot to set `name` as `nullable`, but it's. – Gustavo Ifanger Jun 16 '21 at 20:59
  • 1
    Wrap with the `Optional` class seems to work better in my case. Thanks everyone for the help! – Gustavo Ifanger Jun 16 '21 at 21:00
  • Sir, I think you can simply use `T? get value => _value` instead of `T get value => _value as T;`, because `optional.value` can still return `null` – iDecode Apr 03 '22 at 01:07
  • @iDecode `optional.value` should only be null if `T` is a nullable type. The point of `T get value => _value as T;` is to return a *non*-nullable type if `T` is non-nullable (the caller is expected to check `isValid` first). (We can't use `_value!` in case `T` is a nullable type and the `Optional` legitimately stores `null`.) – jamesdlin Apr 03 '22 at 01:31
  • @jamesdlin I wrote an [answer](https://stackoverflow.com/a/71732563/12483095) using your approach, and it works in all three possible cases. – iDecode Apr 04 '22 at 05:51
2

I'm using the Optional package to work around the problem, so the code looks something like this:

final TZDateTime dateTime;
final double value;
final Duration? duration;

...

DataPoint _copyWith({
    TZDateTime? dateTime,
    double? value,
    Optional<Duration?>? duration})  {

    return DataPoint(
      dateTime ?? this.dateTime,
      value ?? this.value,
      duration: duration != null ?
        duration.orElseNull :
        this.duration,
    );
  }

In this example, duration is a nullable field, and the copyWith pattern works as normal. The only thing you have to do differently is if you are setting duration, wrap it in Optional like this:

Duration? newDuration = Duration(minutes: 60);
_copyWith(duration: Optional.ofNullable(newDuration));

Or if you want to set duration to null:

_copyWith(duration: Optional.empty());
James Allen
  • 6,406
  • 8
  • 50
  • 83
2

At the expense of making the implementation of copyWith twice as big, you can actually use flags to allow null-ing the fields without any use of a "default empty object" or Options class:

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

  Person(this.name, this.age);

  Person copyWith({
    String? name,
    bool noName = false,
    int? age,
    bool noAge = false,
    // ...
  }) =>
      Person(
        name ?? (noName ? null : this.name),
        age ?? (noAge ? null : this.age),
        // ...
      );
}

void main() {
  final person = Person('Gustavo', 42);
  print(person.name); // prints Gustavo
  print(person.age); // Prints 42

  final person2 = person.copyWith(noName: true, age: 84);
  print(person2.name); // Prints null
  print(person2.age); // Prints 84

  final person3 = person2.copyWith(age: 21);
  print(person3.name); // Prints null
  print(person3.age); // Prints 21

  final person4 = person3.copyWith(name: 'Bob', noAge: true);
  print(person4.name); // Prints Bob
  print(person4.age); // Prints null

  runApp(MyApp());
}

It does have the pointless case of:

final person = otherPerson.copyWith(name: 'John', noName: true);

but you can make asserts for that if you really want to disallow it I suppose.

Mat
  • 1,440
  • 1
  • 18
  • 38
1

Sometimes you have also implemented toMap() and fromMap() (or any other kind of "serialization") so:

 class Person {
  final String? name;
  
  Person(this.name);
  
  Person copyWith({String? name}) => Person(name ?? this.name);
  
  Map<String,dynamic> toMap(){
    return {'name':name};
  }

  Person fromMap(Map<String,dynamic> map){
    return Person(map['name']);
  }


}

void main() {
  final person = Person("Gustavo");
  print(person.name); // prints Gustavo
  
  // I want the name property to be null
  final personMap = person.toMap();
  personMap['name']=null;
  final person2 = person.fromMap(personMap);
  print(person2.name); // Prints null
}
cladelpino
  • 337
  • 3
  • 14
0

You could define type based const values and use them as default value for the copyWith parameters and then check if it the value of the parameter is equal to the const value. The problem is that you have to change the defines, if they will be needed in the future. Also it could be awkward to define constant values for some classes.

const String copyWithString = "null";
const double copyWithDouble = -9999999;

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

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

  Person copyWith({
    String? name = copyWithString,
    double? age = copyWithDouble,
  }) {
    return Person(
      name: name == copyWithString ? this.name : name,
      age: age == copyWithDouble ? this.age : age,
    );
  }
}

void main() {
  final person = Person(name: 'John', age: 30);
  print(person.name); // Prints John
  print(person.age); // Prints 30

  final person2 = person.copyWith(age: 31);
  print(person2.name); // Prints John
  print(person2.age); // Prints 31

  final person3 = person.copyWith(name: 'Cena');
  print(person3.name); // Prints Cena
  print(person3.age); // Prints 31

  final person4 = person.copyWith(name: null, age: null);
  print(person4.name); // Prints null
  print(person4.age); // Pritns null
}

here is a working dartpad

Anneress
  • 171
  • 8
-1

Just change your copyWith like below:

Person copyWith({String? name}) => Person(name);

Gustavo
null

Since name is declared String?, what is the point of checking again before assigning it like below?

Person copyWith({String? name}) => Person(name ?? this.name);
Polymath
  • 630
  • 7
  • 11
  • The check is for copyWith instead of copy. How would you copyWith if you have mutliple arguments, which you want to copy with the change of only one argument? – Anneress Sep 07 '22 at 13:29