Fix ChangeNotifier generic typing issue (#76)

Fix ChangeNotifier generic typing issue - ChangeRecord.ANY and ChangeRecord.None is not typesafe for any subclasses of ChangeNotifier that subclass the generic.

Proposed solution is to output a `ChangeRecords extends List<ChangeRecord>` with additional metadata to indicate the change is ANY or NONE.

Advantage of this change is that it is backwards compatible with existing code while fixing type exceptions for future code.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0aac1a..50607cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.22.1+5
+
+Fix generic type error that occurs when using ChangeNotifier with a subclass of ChangeRecord.
+Previously, calling `notifyChanges()` on `class Foo with ChangeNotifier<CustomChangeRecord> {}`
+would throw a type error. Now, the `changes` stream emits a custom `ChangeRecords` class that
+implements the `List` interface. This change is backwards compatible.
+
 ## 0.22.1+4
 
 * Support Dart 2 stable.
diff --git a/lib/observable.dart b/lib/observable.dart
index 516ff70..ec037dc 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -6,9 +6,14 @@
 
 export 'src/change_notifier.dart' show ChangeNotifier, PropertyChangeNotifier;
 export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer, MapDiffer;
-export 'src/records.dart'
-    show ChangeRecord, ListChangeRecord, MapChangeRecord, PropertyChangeRecord;
 export 'src/observable.dart';
 export 'src/observable_list.dart';
 export 'src/observable_map.dart';
+export 'src/records.dart'
+    show
+        ChangeRecord,
+        ChangeRecords,
+        ListChangeRecord,
+        MapChangeRecord,
+        PropertyChangeRecord;
 export 'src/to_observable.dart';
diff --git a/lib/src/change_notifier.dart b/lib/src/change_notifier.dart
index 9464740..2aeaf63 100644
--- a/lib/src/change_notifier.dart
+++ b/lib/src/change_notifier.dart
@@ -51,18 +51,16 @@
   @override
   @mustCallSuper
   bool deliverChanges() {
-    List<ChangeRecord> changes;
     if (_scheduled && hasObservers) {
-      if (_queue != null) {
-        changes = freezeInDevMode(_queue);
-        _queue = null;
-      } else {
-        changes = ChangeRecord.ANY;
-      }
+      final changes = _queue == null
+          ? ChangeRecords<C>.any()
+          : ChangeRecords.wrap(freezeInDevMode(_queue));
+      _queue = null;
       _scheduled = false;
       _changes.add(changes);
+      return true;
     }
-    return changes != null;
+    return false;
   }
 
   /// Whether [changes] has at least one active listener.
diff --git a/lib/src/records.dart b/lib/src/records.dart
index 87d7d4f..0ef770d 100644
--- a/lib/src/records.dart
+++ b/lib/src/records.dart
@@ -19,10 +19,71 @@
   ///
   /// May be used to produce lower-GC-pressure records where more verbose change
   /// records will not be used directly.
-  static const List<ChangeRecord> ANY = const [const ChangeRecord()];
+  static const ANY = ChangeRecords<ChangeRecord>.any();
 
   /// Signifies no changes occurred.
-  static const List<ChangeRecord> NONE = const [];
+  static const NONE = ChangeRecords<ChangeRecord>.none();
 
   const ChangeRecord();
 }
+
+/// Represents a list of change records.
+///
+/// The motivation for implementing the list interface is to fix a typing
+/// issue with ChangeRecord.ANY while maintaining backwards compatibility with
+/// existing code.
+class ChangeRecords<RecordType extends ChangeRecord>
+    extends DelegatingList<RecordType> {
+  // This is a covariant unfortunately because generics cannot be used in a
+  // const constructor. Should be sound however since the equality check does
+  // not do any mutations.
+  static const _listEquals = ListEquality<ChangeRecord>();
+
+  final bool _isAny;
+
+  final List<RecordType> _delegate;
+
+  /// Represents any change where the list of changes is irrelevant.
+  const ChangeRecords.any() : this._(const [], true);
+
+  /// Represents a null change where nothing happened.
+  const ChangeRecords.none() : this._(const [], false);
+
+  /// Wraps around a list of records.
+  ///
+  /// Note: this wraps around a shallow copy of [list]. If [list] is modified,
+  /// then it is modified within this change record as well. This is provide a
+  /// const constructor for [ChangeRecords].
+  const ChangeRecords.wrap(List<RecordType> list) : this._(list, false);
+
+  /// Creates a change record list from a deep copy of [it].
+  ChangeRecords.fromIterable(Iterable<RecordType> it)
+      : this._(List.unmodifiable(it), false);
+
+  const ChangeRecords._(this._delegate, this._isAny) : super(_delegate);
+
+  @override
+  int get hashCode => hash2(_delegate, _isAny);
+
+  /// Equal if this and [other] have the same generic type and either both are
+  /// any records or both are not any records and have the same list of entries.
+  ///
+  /// E.g.
+  ///   ChangeRecords<CR1>.any() == ChangeRecords<CR1>.any()
+  ///   ChangeRecords<CR1>.any() != ChangeRecords<CR2>.any()
+  ///
+  /// List of records checked with deep comparison.
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is ChangeRecords &&
+          runtimeType == other.runtimeType &&
+          ((_isAny && other._isAny) ||
+              (!_isAny &&
+                  !other._isAny &&
+                  _listEquals.equals(_delegate, other._delegate)));
+
+  @override
+  String toString() =>
+      _isAny ? 'ChangeRecords.any' : 'ChangeRecords($_delegate)';
+}
diff --git a/test/change_notifier_test.dart b/test/change_notifier_test.dart
new file mode 100644
index 0000000..c2a3698
--- /dev/null
+++ b/test/change_notifier_test.dart
@@ -0,0 +1,100 @@
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+import 'observable_test_utils.dart';
+
+void main() {
+  group(ChangeRecords, () {
+    test('any changes', () {
+      expectChanges(const ChangeRecords<A>.any(), const ChangeRecords<A>.any());
+      expectChanges(ChangeRecords<A>.any(), ChangeRecords<A>.any());
+      expectNotChanges(ChangeRecords<A>.any(), ChangeRecords<A>.wrap([]));
+      expectNotChanges(ChangeRecords<A>.any(), ChangeRecords<B>.any());
+      expectNotChanges(ChangeRecords<B>.any(), ChangeRecords<C>.any());
+    });
+
+    test('some changes', () {
+      expectChanges(ChangeRecords<A>.fromIterable([A()]),
+          ChangeRecords<A>.fromIterable([A()]));
+      expectChanges(ChangeRecords<A>.fromIterable([B(1), B(2)]),
+          ChangeRecords<A>.fromIterable([B(1), B(2)]));
+      expectNotChanges(ChangeRecords<A>.fromIterable([A()]),
+          ChangeRecords<A>.fromIterable([A(), A()]));
+      expectNotChanges(ChangeRecords<B>.fromIterable([B(1)]),
+          ChangeRecords<A>.fromIterable([B(2)]));
+      expectNotChanges(ChangeRecords<B>.fromIterable([B(1)]),
+          ChangeRecords<A>.fromIterable([C()]));
+    });
+  });
+
+  group(ChangeNotifier, () {
+    Future<void> runTest<T extends ChangeRecord>(
+        FutureOr<void> runFn(ChangeNotifier<T> cn),
+        FutureOr<void> testFn(ChangeRecords<T> cr)) async {
+      final cn = ChangeNotifier<T>();
+
+      cn.changes.listen((value) {
+        expect(value, TypeMatcher<ChangeRecords<T>>());
+        testFn(value);
+      });
+
+      await runFn(cn);
+
+      return Future(() {});
+    }
+
+    test(
+        'delivers any record when no change notified',
+        () => runTest<A>((cn) {
+              cn.notifyChange();
+            }, (cr) {
+              expectChanges(cr, ChangeRecords<A>.any());
+            }));
+
+    test(
+        'delivers expectChangesed changes',
+        () => runTest<B>((cn) {
+              cn..notifyChange(B(1))..notifyChange(B(2))..notifyChange(B(3));
+            }, (cr) {
+              expectChanges(cr, ChangeRecords<B>.wrap([B(1), B(2), B(3)]));
+            }));
+  });
+}
+
+class A extends ChangeRecord {
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) || other is A && runtimeType == other.runtimeType;
+
+  @override
+  int get hashCode => 0;
+}
+
+class B extends A {
+  final int value;
+
+  B(this.value);
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      super == other &&
+          other is B &&
+          runtimeType == other.runtimeType &&
+          this.value == other.value;
+
+  @override
+  int get hashCode => value.hashCode;
+}
+
+class C extends A {
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      super == other && other is C && runtimeType == other.runtimeType;
+
+  @override
+  int get hashCode => 2;
+}
diff --git a/test/list_change_test.dart b/test/list_change_test.dart
index 858b27b..4d47a72 100644
--- a/test/list_change_test.dart
+++ b/test/list_change_test.dart
@@ -15,7 +15,7 @@
 main() => listChangeTests();
 
 // TODO(jmesserly): port or write array fuzzer tests
-listChangeTests() {
+void listChangeTests() {
   StreamSubscription sub;
   var model;
 
@@ -24,13 +24,15 @@
     model = null;
   });
 
-  _delta(i, r, a) => new ListChangeRecord(model, i, removed: r, addedCount: a);
+  ListChangeRecord<E> _delta<E>(int i, List<E> r, int a,
+          {ObservableList<E> typedModel}) =>
+      ListChangeRecord(typedModel ?? model, i, removed: r, addedCount: a);
 
   test('sequential adds', () {
-    model = toObservable([]);
+    final model = ObservableList();
     model.add(0);
 
-    var summary;
+    List<ListChangeRecord> summary;
     sub = model.listChanges.listen((r) => summary = r);
 
     model.add(1);
@@ -38,29 +40,29 @@
 
     expect(summary, null);
     return new Future(() {
-      expectChanges(summary, [_delta(1, [], 2)]);
+      expect(summary, [_delta(1, [], 2, typedModel: model)]);
       expect(summary[0].added, [1, 2]);
       expect(summary[0].removed, []);
     });
   });
 
   test('List Splice Truncate And Expand With Length', () {
-    model = toObservable(['a', 'b', 'c', 'd', 'e']);
+    final model = ObservableList<String>.from(['a', 'b', 'c', 'd', 'e']);
 
-    var summary;
+    List<ListChangeRecord<String>> summary;
     sub = model.listChanges.listen((r) => summary = r);
 
     model.length = 2;
     return new Future(() {
-      expectChanges(summary, [
-        _delta(2, ['c', 'd', 'e'], 0)
+      expect(summary, [
+        _delta(2, ['c', 'd', 'e'], 0, typedModel: model)
       ]);
       expect(summary[0].added, []);
       expect(summary[0].removed, ['c', 'd', 'e']);
       summary = null;
       model.length = 5;
     }).then(newMicrotask).then((_) {
-      expectChanges(summary, [_delta(2, [], 3)]);
+      expect(summary, [_delta(2, [], 3, typedModel: model)]);
       expect(summary[0].added, [null, null, null]);
       expect(summary[0].removed, []);
     });
diff --git a/test/observable_list_test.dart b/test/observable_list_test.dart
index b999cfd..634d9e8 100644
--- a/test/observable_list_test.dart
+++ b/test/observable_list_test.dart
@@ -43,7 +43,7 @@
       list.add(4);
       expect(list, [1, 2, 3, 4]);
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 4)]);
+        expect(changes, changeMatchers([_lengthChange(3, 4)]));
       });
     });
 
@@ -52,7 +52,7 @@
       expect(list, orderedEquals([1, 3]));
 
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 2)]);
+        expect(changes, changeMatchers([_lengthChange(3, 2)]));
       });
     });
 
@@ -61,7 +61,8 @@
       list.removeRange(1, 3);
       expect(list, [1, 4]);
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 4), _lengthChange(4, 2)]);
+        expect(changes,
+            changeMatchers([_lengthChange(3, 4), _lengthChange(4, 2)]));
       });
     });
 
@@ -70,7 +71,8 @@
       list.removeWhere((e) => e == 2);
       expect(list, [1, 3]);
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 4), _lengthChange(4, 2)]);
+        expect(changes,
+            changeMatchers([_lengthChange(3, 4), _lengthChange(4, 2)]));
       });
     });
 
@@ -78,7 +80,7 @@
       list.length = 5;
       expect(list, [1, 2, 3, null, null]);
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 5)]);
+        expect(changes, changeMatchers([_lengthChange(3, 5)]));
       });
     });
 
@@ -86,7 +88,7 @@
       list[2] = 9000;
       expect(list, [1, 2, 9000]);
       return new Future(() {
-        expectChanges(changes, null);
+        expect(changes, null);
       });
     });
 
@@ -94,7 +96,7 @@
       list.clear();
       expect(list, []);
       return new Future(() {
-        expectChanges(changes, [_lengthChange(3, 0)]);
+        expect(changes, changeMatchers([_lengthChange(3, 0)]));
       });
     });
   });
@@ -116,7 +118,7 @@
       list.add(4);
       expect(list, [1, 2, 3, 4]);
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -124,7 +126,7 @@
       list[1] = 777;
       expect(list, [1, 777, 3]);
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _change(1, addedCount: 1, removed: [2])
         ]);
       });
@@ -134,7 +136,7 @@
       list[2] = 9000;
       expect(list, [1, 2, 9000]);
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -143,7 +145,7 @@
       list[1] = 42;
       expect(list, [1, 42, 3]);
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _change(1, addedCount: 1, removed: [2]),
         ]);
       });
@@ -153,7 +155,7 @@
       list.length = 2;
       expect(list, [1, 2]);
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -161,7 +163,7 @@
       list.length = 1;
       expect(list, [1]);
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _change(1, removed: [2, 3])
         ]);
       });
@@ -172,7 +174,7 @@
       list.add(42);
       expect(list, [1, 42]);
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _change(1, removed: [2, 3], addedCount: 1)
         ]);
       });
@@ -183,7 +185,7 @@
       list.add(2);
       expect(list, [1, 2]);
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
   });
@@ -220,8 +222,8 @@
       expect(copy, orderedEquals([1, 2, 3, 1, 3, 4]));
       return new Future(() {
         // no change from read-only operators
-        expectChanges(propRecords, null);
-        expectChanges(listRecords, null);
+        expect(propRecords, null);
+        expect(listRecords, null);
       });
     });
 
@@ -231,11 +233,13 @@
       expect(list, orderedEquals([1, 2, 3, 1, 3, 4, 5, 6]));
 
       return new Future(() {
-        expectChanges(propRecords, [
-          _lengthChange(6, 7),
-          _lengthChange(7, 8),
-        ]);
-        expectChanges(listRecords, [_change(6, addedCount: 2)]);
+        expect(
+            propRecords,
+            changeMatchers([
+              _lengthChange(6, 7),
+              _lengthChange(7, 8),
+            ]));
+        expect(listRecords, [_change(6, addedCount: 2)]);
       });
     });
 
@@ -244,8 +248,8 @@
       expect(list, orderedEquals([1, 4, 3, 1, 3, 4]));
 
       return new Future(() {
-        expectChanges(propRecords, null);
-        expectChanges(listRecords, [
+        expect(propRecords, null);
+        expect(listRecords, [
           _change(1, addedCount: 1, removed: [2])
         ]);
       });
@@ -256,8 +260,8 @@
       expect(list, orderedEquals([1, 2, 3, 1, 3]));
 
       return new Future(() {
-        expectChanges(propRecords, [_lengthChange(6, 5)]);
-        expectChanges(listRecords, [
+        expect(propRecords, changeMatchers([_lengthChange(6, 5)]));
+        expect(listRecords, [
           _change(5, removed: [4])
         ]);
       });
@@ -268,8 +272,8 @@
       expect(list, orderedEquals([1, 3, 4]));
 
       return new Future(() {
-        expectChanges(propRecords, [_lengthChange(6, 3)]);
-        expectChanges(listRecords, [
+        expect(propRecords, changeMatchers([_lengthChange(6, 3)]));
+        expect(listRecords, [
           _change(1, removed: [2, 3, 1])
         ]);
       });
@@ -280,8 +284,8 @@
       expect(list, orderedEquals([1, 2, 1, 4]));
 
       return new Future(() {
-        expectChanges(propRecords, [_lengthChange(6, 4)]);
-        expectChanges(listRecords, [
+        expect(propRecords, changeMatchers([_lengthChange(6, 4)]));
+        expect(listRecords, [
           _change(2, removed: [3]),
           _change(3, removed: [3])
         ]);
@@ -293,8 +297,8 @@
       expect(list, orderedEquals([1, 1, 2, 3, 3, 4]));
 
       return new Future(() {
-        expectChanges(propRecords, null);
-        expectChanges(listRecords, [
+        expect(propRecords, null);
+        expect(listRecords, [
           _change(1, addedCount: 1),
           _change(4, removed: [1])
         ]);
@@ -320,12 +324,14 @@
       expect(list, []);
 
       return new Future(() {
-        expectChanges(propRecords, [
-          _lengthChange(6, 0),
-          new PropertyChangeRecord(list, #isEmpty, false, true),
-          new PropertyChangeRecord(list, #isNotEmpty, true, false),
-        ]);
-        expectChanges(listRecords, [
+        expect(
+            propRecords,
+            changeMatchers([
+              _lengthChange(6, 0),
+              PropertyChangeRecord<bool>(list, #isEmpty, false, true),
+              PropertyChangeRecord<bool>(list, #isNotEmpty, true, false),
+            ]));
+        expect(listRecords, [
           _change(0, removed: [1, 2, 3, 1, 3, 4])
         ]);
       });
@@ -335,8 +341,8 @@
 
 ObservableList<int> list;
 
-PropertyChangeRecord _lengthChange(int oldValue, int newValue) =>
-    new PropertyChangeRecord(list, #length, oldValue, newValue);
+PropertyChangeRecord<int> _lengthChange(int oldValue, int newValue) =>
+    new PropertyChangeRecord<int>(list, #length, oldValue, newValue);
 
 _change(int index, {List removed: const [], int addedCount: 0}) =>
     new ListChangeRecord(list, index, removed: removed, addedCount: addedCount);
diff --git a/test/observable_map_test.dart b/test/observable_map_test.dart
index 04aeccc..1bc0062 100644
--- a/test/observable_map_test.dart
+++ b/test/observable_map_test.dart
@@ -41,7 +41,7 @@
       map['d'] = 4;
       expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
       return new Future(() {
-        expectChanges(changes, [_lengthChange(map, 3, 4)]);
+        expect(changes, changeMatchers([_lengthChange(map, 3, 4)]));
       });
     });
 
@@ -49,7 +49,7 @@
       map.putIfAbsent('d', () => 4);
       expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
       return new Future(() {
-        expectChanges(changes, [_lengthChange(map, 3, 4)]);
+        expect(changes, changeMatchers([_lengthChange(map, 3, 4)]));
       });
     });
 
@@ -58,10 +58,12 @@
       map.remove('a');
       expect(map, {'b': 2});
       return new Future(() {
-        expectChanges(changes, [
-          _lengthChange(map, 3, 2),
-          _lengthChange(map, 2, 1),
-        ]);
+        expect(
+            changes,
+            changeMatchers([
+              _lengthChange(map, 3, 2),
+              _lengthChange(map, 2, 1),
+            ]));
       });
     });
 
@@ -69,7 +71,7 @@
       map.remove('d');
       expect(map, {'a': 1, 'b': 2, 'c': 3});
       return new Future(() {
-        expectChanges(changes, null);
+        expect(changes, null);
       });
     });
 
@@ -77,7 +79,7 @@
       map['c'] = 9000;
       expect(map, {'a': 1, 'b': 2, 'c': 9000});
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -85,7 +87,7 @@
       map.clear();
       expect(map, {});
       return new Future(() {
-        expectChanges(changes, [_lengthChange(map, 3, 0)]);
+        expect(changes, changeMatchers([_lengthChange(map, 3, 0)]));
       });
     });
   });
@@ -109,7 +111,7 @@
       map.putIfAbsent('d', () => 4);
       expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -117,7 +119,7 @@
       map['b'] = null;
       expect(map, {'a': 1, 'b': null, 'c': 3});
       return new Future(() {
-        expectChanges(changes, [_changeKey('b', 2, null)]);
+        expect(changes, [_changeKey('b', 2, null)]);
       });
     });
 
@@ -125,7 +127,7 @@
       map['b'] = 777;
       expect(map, {'a': 1, 'b': 777, 'c': 3});
       return new Future(() {
-        expectChanges(changes, [_changeKey('b', 2, 777)]);
+        expect(changes, [_changeKey('b', 2, 777)]);
       });
     });
 
@@ -133,7 +135,7 @@
       map.putIfAbsent('b', () => 1234);
       expect(map, {'a': 1, 'b': 2, 'c': 3});
       return new Future(() {
-        expectChanges(changes, null);
+        expect(changes, null);
       });
     });
 
@@ -141,7 +143,7 @@
       map['c'] = 9000;
       expect(map, {'a': 1, 'b': 2, 'c': 9000});
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -150,7 +152,7 @@
       map['b'] = 42;
       expect(map, {'a': 1, 'b': 42, 'c': 3});
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _changeKey('b', 2, 9001),
           _changeKey('b', 9001, 42),
         ]);
@@ -161,7 +163,7 @@
       map.remove('a');
       expect(map, {'b': 2, 'c': 3});
       return new Future(() {
-        expectChanges(changes, []);
+        expect(changes, []);
       });
     });
 
@@ -169,7 +171,7 @@
       map.remove('b');
       expect(map, {'a': 1, 'c': 3});
       return new Future(() {
-        expectChanges(changes, [_removeKey('b', 2)]);
+        expect(changes, [_removeKey('b', 2)]);
       });
     });
 
@@ -178,7 +180,7 @@
       map['b'] = 2;
       expect(map, {'a': 1, 'b': 2, 'c': 3});
       return new Future(() {
-        expectChanges(changes, [
+        expect(changes, [
           _removeKey('b', 2),
           _insertKey('b', 2),
         ]);
@@ -305,12 +307,14 @@
       expect(map, {'a': 1, 'b': 2, 'c': 3});
 
       return new Future(() {
-        expectChanges(records, [
-          _lengthChange(map, 2, 3),
-          _insertKey('c', 3),
-          _propChange(map, #keys),
-          _propChange(map, #values),
-        ]);
+        expect(
+            records,
+            changeMatchers([
+              _lengthChange(map, 2, 3),
+              _insertKey('c', 3),
+              _propChange(map, #keys),
+              _propChange(map, #values),
+            ]));
       });
     });
 
@@ -322,14 +326,16 @@
       expect(map, {'a': 42, 'b': 2, 'c': 3});
 
       return new Future(() {
-        expectChanges(records, [
-          _changeKey('a', 1, 42),
-          _propChange(map, #values),
-          _lengthChange(map, 2, 3),
-          _insertKey('c', 3),
-          _propChange(map, #keys),
-          _propChange(map, #values),
-        ]);
+        expect(
+            records,
+            changeMatchers([
+              _changeKey('a', 1, 42),
+              _propChange(map, #values),
+              _lengthChange(map, 2, 3),
+              _insertKey('c', 3),
+              _propChange(map, #keys),
+              _propChange(map, #values),
+            ]));
       });
     });
 
@@ -338,12 +344,14 @@
       expect(map, {'a': 1});
 
       return new Future(() {
-        expectChanges(records, [
-          _removeKey('b', 2),
-          _lengthChange(map, 2, 1),
-          _propChange(map, #keys),
-          _propChange(map, #values),
-        ]);
+        expect(
+            records,
+            changeMatchers([
+              _removeKey('b', 2),
+              _lengthChange(map, 2, 1),
+              _propChange(map, #keys),
+              _propChange(map, #values),
+            ]));
       });
     });
 
@@ -352,13 +360,15 @@
       expect(map, {});
 
       return new Future(() {
-        expectChanges(records, [
-          _removeKey('a', 1),
-          _removeKey('b', 2),
-          _lengthChange(map, 2, 0),
-          _propChange(map, #keys),
-          _propChange(map, #values),
-        ]);
+        expect(
+            records,
+            changeMatchers([
+              _removeKey('a', 1),
+              _removeKey('b', 2),
+              _lengthChange(map, 2, 0),
+              _propChange(map, #keys),
+              _propChange(map, #values),
+            ]));
       });
     });
   });
@@ -379,13 +389,15 @@
   });
 }
 
-_lengthChange(map, int oldValue, int newValue) =>
-    new PropertyChangeRecord(map, #length, oldValue, newValue);
+PropertyChangeRecord<int> _lengthChange(map, int oldValue, int newValue) =>
+    PropertyChangeRecord<int>(map, #length, oldValue, newValue);
 
-_changeKey(key, old, newValue) => new MapChangeRecord(key, old, newValue);
+MapChangeRecord _changeKey(key, old, newValue) =>
+    MapChangeRecord(key, old, newValue);
 
 _insertKey(key, newValue) => new MapChangeRecord.insert(key, newValue);
 
 _removeKey(key, oldValue) => new MapChangeRecord.remove(key, oldValue);
 
-_propChange(map, prop) => new PropertyChangeRecord(map, prop, null, null);
+PropertyChangeRecord<Null> _propChange(map, prop) =>
+    new PropertyChangeRecord<Null>(map, prop, null, null);
diff --git a/test/observable_test.dart b/test/observable_test.dart
index e88495d..094c718 100644
--- a/test/observable_test.dart
+++ b/test/observable_test.dart
@@ -24,7 +24,7 @@
   });
 
   test('handle future result', () {
-    var callback = expectAsync0(() {});
+    var callback = expectAsync(() {});
     return new Future(callback);
   });
 
@@ -48,7 +48,7 @@
     var t = createModel(123);
     int called = 0;
 
-    subs.add(t.changes.listen(expectAsync1((records) {
+    subs.add(t.changes.listen(expectAsync((records) {
       called++;
       expectPropertyChanges(records, 2);
     })));
@@ -62,7 +62,7 @@
     var t = createModel(123);
     int called = 0;
 
-    subs.add(t.changes.listen(expectAsync1((records) {
+    subs.add(t.changes.listen(expectAsync((records) {
       called++;
       expectPropertyChanges(records, 1);
       if (called == 1) {
@@ -81,8 +81,8 @@
       expectPropertyChanges(records, 2);
     }
 
-    subs.add(t.changes.listen(expectAsync1(verifyRecords)));
-    subs.add(t.changes.listen(expectAsync1(verifyRecords)));
+    subs.add(t.changes.listen(expectAsync(verifyRecords)));
+    subs.add(t.changes.listen(expectAsync(verifyRecords)));
 
     t.value = 41;
     t.value = 42;
@@ -96,14 +96,14 @@
     }));
     t.value = 41;
     t.value = 42;
-    expectChanges(records, [], reason: 'changes delived async');
+    expect(records, [], reason: 'changes delived async');
 
     return new Future(() {
       expectPropertyChanges(records, 2);
       records.clear();
 
       t.value = 777;
-      expectChanges(records, [], reason: 'changes delived async');
+      expect(records, [], reason: 'changes delived async');
     }).then(newMicrotask).then((_) {
       expectPropertyChanges(records, 1);
     });
@@ -112,7 +112,7 @@
   test('cancel listening', () {
     var t = createModel(123);
     var sub;
-    sub = t.changes.listen(expectAsync1((records) {
+    sub = t.changes.listen(expectAsync((records) {
       expectPropertyChanges(records, 1);
       sub.cancel();
       t.value = 777;
@@ -123,12 +123,12 @@
   test('cancel and reobserve', () {
     var t = createModel(123);
     var sub;
-    sub = t.changes.listen(expectAsync1((records) {
+    sub = t.changes.listen(expectAsync((records) {
       expectPropertyChanges(records, 1);
       sub.cancel();
 
       scheduleMicrotask(() {
-        subs.add(t.changes.listen(expectAsync1((records) {
+        subs.add(t.changes.listen(expectAsync((records) {
           expectPropertyChanges(records, 1);
         })));
         t.value = 777;
diff --git a/test/observable_test_utils.dart b/test/observable_test_utils.dart
index 990cc86..bbb5b70 100644
--- a/test/observable_test_utils.dart
+++ b/test/observable_test_utils.dart
@@ -15,10 +15,15 @@
 ///     future.then(newMicrotask).then(...)
 newMicrotask(_) => new Future.value();
 
-// TODO(jmesserly): use matchers when we have a way to compare ChangeRecords.
-// For now just use the toString.
-void expectChanges(actual, expected, {String reason}) =>
-    expect('$actual', '$expected', reason: reason);
+void expectChanges(List<ChangeRecord> actual, List<ChangeRecord> expected,
+    {String reason}) {
+  expect(actual, _EqualsMatcher(expected), reason: reason);
+}
+
+void expectNotChanges(List<ChangeRecord> actual, ChangeRecords expectedNot,
+    {String reason}) {
+  expect(actual, isNot(_EqualsMatcher(expectedNot)), reason: reason);
+}
 
 List<ListChangeRecord> getListChangeRecords(
         List<ListChangeRecord> changes, int index) =>
@@ -28,3 +33,43 @@
         List<ChangeRecord> changes, Symbol property) =>
     new List.from(changes.where(
         (ChangeRecord c) => c is PropertyChangeRecord && c.name == property));
+
+List<Matcher> changeMatchers(List<ChangeRecord> changes) => changes
+    .map((r) =>
+        r is PropertyChangeRecord ? _PropertyChangeMatcher(r) : equals(r))
+    .toList();
+
+// Custom equality matcher is required, otherwise expect() infers ChangeRecords
+// to be an iterable and does a deep comparison rather than use the == operator.
+class _EqualsMatcher<ValueType> extends Matcher {
+  final ValueType _expected;
+
+  _EqualsMatcher(this._expected);
+
+  @override
+  Description describe(Description description) =>
+      description.addDescriptionOf(_expected);
+
+  @override
+  bool matches(dynamic item, Map matchState) =>
+      item is ChangeRecords && _expected == item;
+}
+
+class _PropertyChangeMatcher<ValueType> extends Matcher {
+  final PropertyChangeRecord<ValueType> _expected;
+
+  _PropertyChangeMatcher(this._expected);
+
+  @override
+  Description describe(Description description) =>
+      description.addDescriptionOf(_expected);
+
+  @override
+  bool matches(dynamic other, Map matchState) =>
+      identical(_expected, other) ||
+      other is PropertyChangeRecord &&
+          _expected.runtimeType == other.runtimeType &&
+          _expected.name == other.name &&
+          _expected.oldValue == other.oldValue &&
+          _expected.newValue == other.newValue;
+}