patch

A wrapper for copyWith arguments, so that a nullable field can be cleared as well as replaced.

Dart's optional parameters cannot tell copyWith() apart from copyWith(nickname: null). Both arrive as null, so the usual copyWith idiom can replace a field or leave it alone, but it can never set the field back to null. Patch<T> separates those cases.

Getting started

dart pub add patch

Or add it to your pubspec.yaml directly:

dependencies:
  patch: ^1.0.0

To work on the package alongside a consumer, depend on a local checkout instead:

dependencies:
  patch:
    path: ../patch

It requires Dart 3.7 or later.

Usage

Declare each copyWith parameter as a Patch that defaults to Unchanged, and resolve it against the field's current value:

import 'package:patch/patch.dart';

class UserProfile {
  UserProfile(this.name, this.nickname);

  final String name;
  final String? nickname;
}

extension on UserProfile {
  UserProfile copyWith({
    Patch<String?> nickname = const Unchanged(),
  }) => UserProfile(name, nickname.resolve(this.nickname));
}

All three outcomes are reachable:

final profile = UserProfile('Ada Lovelace', 'Ada');

profile.copyWith().nickname;                                // 'Ada'
profile.copyWith(nickname: Value('The Countess')).nickname; // 'The Countess'
profile.copyWith(nickname: const Clear()).nickname;         // null

For a runnable version, see example/patch_example.dart.

resolve is the whole of the common case, but nothing is hidden behind it — switch over the patch directly when a branch needs to do more than pick a value:

switch (nickname) {
  Value(value: final v) => v,
  Unchanged() => this.nickname,
}

The variants

Variant The caller The field becomes
Value(v) passed a value v
Unchanged() passed nothing unchanged
Clear() asked to remove the value null

Clear is not a third case to handle. It is a Value<Null> — the constant Value(null), under a name that says what the call site means — so the Value(value: final v) pattern already matches it and binds null. Patch<T> is sealed with exactly two direct subtypes, so a switch over Value and Unchanged is exhaustive without a default clause, and a missed case is a compile error rather than a silent fallthrough.

Non-nullable fields

A non-nullable parameter uses the same API:

name.resolve(this.name)

Clearing such a field does not type-check, because Clear is a Patch<Null> and Null is not a subtype of String:

profile.copyWith(name: const Clear());
//                     ^ The argument type 'Clear' can't be assigned to the
//                       parameter type 'Patch<String>'.

The same applies to spelling it out as Value(null). Nothing has to be folded into Unchanged, and there is no unreachable branch to write.

The type argument is load-bearing. A parameter annotated as a bare Patch is a Patch<dynamic>, which accepts Clear no matter what the field's type is; the mismatch then surfaces as a runtime type 'Null' is not a subtype of type 'String' rather than as the compile error above. Enable strict-raw-types in your analysis_options.yaml so a raw Patch is reported:

analyzer:
  language:
    strict-raw-types: true

Why resolve is an extension

Clear is a Value<Null>, so a resolve inherited from Patch<T> would take current as Null and Dart's covariance check would throw the moment a populated field was cleared — the package's whole reason for existing. Extension methods are dispatched statically, so current is typed by the call site.

Three consequences. resolve is invisible through a dynamic receiver; an importing library that declares its own resolve on Patch shadows this one; and the receiver has to be typed Patch<T?> rather than Clear, because static dispatch infers T from the receiver's static type:

const Clear().resolve('Ada');            // does not compile — T infers as Null

final Patch<String?> patch = const Clear();
patch.resolve('Ada');                    // null

In practice a copyWith parameter is already declared Patch<T?>, so the widening happens at the call site for free. All three are cheap next to a runtime failure the analyzer cannot see.

Two things to know

Value(null) and Clear() are the same thing, deliberately. The distinction that earns its keep is between those and Unchanged(), and neither can be confused with an omitted argument. If a particular copyWith does need to tell an explicit clear apart from an incidental null, match Clear() before Value(...) — the reverse order makes the Clear case unreachable, which the analyzer reports.

The variants are value types. Two separately constructed Value('a') instances are equal, all Unchanged()s are equal, and neither is equal to the other, so patches compare, hash, and work as map keys and set members as you would expect:

Value('a') == Value('a');             // true
Value<Object>('a') == Value('a');     // true — the type argument is not part of it
const Unchanged<String>() == const Unchanged<int>(); // true
const Unchanged<String?>() == Value<String?>(null);  // false

Equality follows the paragraph above: since Clear() is Value(null), the two are equal.

const Clear() == Value<String?>(null); // true

Use is Clear when you need to tell an explicit clear apart from an incidental null — equality will not do it for you.

Libraries

patch