'Dart: Custom "copyWith" method with nullable properties

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.



Solution 1:[1]

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
}

Solution 2:[2]

Another solution is to use a function to set the value. This way, you can determine between a function that isn't provided (null), a function that is provided and returns () => null, and a function that returns the name () => '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.

Solution 3:[3]

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.

Solution 4:[4]

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());

Solution 5:[5]

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.

Solution 6:[6]

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());

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Daniel Arndt
Solution 3
Solution 4 James Allen
Solution 5 Mat
Solution 6 maRci002