ts-data-forge 14.9.0 → 14.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +5 -5
  2. package/dist/array/impl/array-utils-element-access.d.mts.map +1 -1
  3. package/dist/array/impl/array-utils-element-access.mjs +4 -4
  4. package/dist/array/impl/array-utils-element-access.mjs.map +1 -1
  5. package/dist/array/impl/array-utils-length-bounded-array-cast.d.mts +8 -8
  6. package/dist/array/impl/array-utils-length-bounded-array-cast.d.mts.map +1 -1
  7. package/dist/array/impl/array-utils-length-bounded-array-cast.mjs +28 -28
  8. package/dist/array/impl/array-utils-length-bounded-array-cast.mjs.map +1 -1
  9. package/dist/array/impl/array-utils-length-bounded-array-guard.d.mts.map +1 -1
  10. package/dist/array/impl/array-utils-length-bounded-array-guard.mjs +4 -4
  11. package/dist/array/impl/array-utils-length-bounded-array-guard.mjs.map +1 -1
  12. package/dist/array/impl/array-utils-length-bounded-tuple-cast.d.mts +3 -4
  13. package/dist/array/impl/array-utils-length-bounded-tuple-cast.d.mts.map +1 -1
  14. package/dist/array/impl/array-utils-length-bounded-tuple-cast.mjs +28 -29
  15. package/dist/array/impl/array-utils-length-bounded-tuple-cast.mjs.map +1 -1
  16. package/dist/array/impl/array-utils-length-bounded-tuple-guard.d.mts.map +1 -1
  17. package/dist/array/impl/array-utils-length-bounded-tuple-guard.mjs +3 -3
  18. package/dist/array/impl/array-utils-length-bounded-tuple-guard.mjs.map +1 -1
  19. package/dist/array/impl/array-utils-modification.d.mts +1 -1
  20. package/dist/array/impl/array-utils-modification.d.mts.map +1 -1
  21. package/dist/array/impl/array-utils-modification.mjs +15 -15
  22. package/dist/array/impl/array-utils-modification.mjs.map +1 -1
  23. package/dist/array/impl/array-utils-reducing-value.d.mts.map +1 -1
  24. package/dist/array/impl/array-utils-reducing-value.mjs +12 -12
  25. package/dist/array/impl/array-utils-reducing-value.mjs.map +1 -1
  26. package/dist/array/impl/array-utils-search.d.mts.map +1 -1
  27. package/dist/array/impl/array-utils-search.mjs +56 -56
  28. package/dist/array/impl/array-utils-search.mjs.map +1 -1
  29. package/dist/array/impl/array-utils-slicing.d.mts.map +1 -1
  30. package/dist/array/impl/array-utils-slicing.mjs +3 -3
  31. package/dist/array/impl/array-utils-slicing.mjs.map +1 -1
  32. package/dist/array/impl/array-utils-transformation.d.mts +25 -25
  33. package/dist/array/impl/array-utils-transformation.d.mts.map +1 -1
  34. package/dist/array/impl/array-utils-transformation.mjs +22 -22
  35. package/dist/array/impl/array-utils-transformation.mjs.map +1 -1
  36. package/dist/array/impl/array-utils-validation.d.mts.map +1 -1
  37. package/dist/array/impl/array-utils-validation.mjs +2 -2
  38. package/dist/array/impl/array-utils-validation.mjs.map +1 -1
  39. package/dist/collections/imap-mapped.d.mts +2 -2
  40. package/dist/collections/imap.d.mts +3 -4
  41. package/dist/collections/imap.d.mts.map +1 -1
  42. package/dist/collections/imap.mjs +0 -1
  43. package/dist/collections/imap.mjs.map +1 -1
  44. package/dist/collections/iset-mapped.d.mts +1 -2
  45. package/dist/collections/iset-mapped.d.mts.map +1 -1
  46. package/dist/collections/iset-mapped.mjs.map +1 -1
  47. package/dist/collections/iset.d.mts +4 -5
  48. package/dist/collections/iset.d.mts.map +1 -1
  49. package/dist/collections/iset.mjs +0 -1
  50. package/dist/collections/iset.mjs.map +1 -1
  51. package/dist/number/num.d.mts +2 -2
  52. package/dist/number/num.mjs.map +1 -1
  53. package/dist/object/object.d.mts +2 -2
  54. package/dist/others/fast-deep-equal.d.mts.map +1 -1
  55. package/dist/others/fast-deep-equal.mjs +10 -7
  56. package/dist/others/fast-deep-equal.mjs.map +1 -1
  57. package/dist/others/index.d.mts +1 -0
  58. package/dist/others/index.d.mts.map +1 -1
  59. package/dist/others/index.mjs +1 -0
  60. package/dist/others/index.mjs.map +1 -1
  61. package/dist/others/replace-equal-deep.d.mts +95 -0
  62. package/dist/others/replace-equal-deep.d.mts.map +1 -0
  63. package/dist/others/replace-equal-deep.mjs +156 -0
  64. package/dist/others/replace-equal-deep.mjs.map +1 -0
  65. package/dist/string/str.d.mts +2 -2
  66. package/package.json +12 -12
  67. package/src/array/impl/array-utils-element-access.mts +11 -11
  68. package/src/array/impl/array-utils-length-bounded-array-cast.mts +69 -69
  69. package/src/array/impl/array-utils-length-bounded-array-guard.mts +21 -21
  70. package/src/array/impl/array-utils-length-bounded-array-guard.test.mts +2 -2
  71. package/src/array/impl/array-utils-length-bounded-tuple-cast.mts +64 -65
  72. package/src/array/impl/array-utils-length-bounded-tuple-guard.mts +15 -15
  73. package/src/array/impl/array-utils-modification.mts +54 -54
  74. package/src/array/impl/array-utils-reducing-value.mts +55 -55
  75. package/src/array/impl/array-utils-search.mts +114 -114
  76. package/src/array/impl/array-utils-slicing.mts +16 -16
  77. package/src/array/impl/array-utils-transformation.mts +96 -96
  78. package/src/array/impl/array-utils-transformation.test.mts +2 -2
  79. package/src/array/impl/array-utils-validation.mts +19 -19
  80. package/src/array/impl/array-utils-validation.test.mts +1 -1
  81. package/src/collections/imap-mapped.mts +2 -2
  82. package/src/collections/imap.mts +3 -4
  83. package/src/collections/imap.test.mts +1 -1
  84. package/src/collections/iset-mapped.mts +1 -2
  85. package/src/collections/iset.mts +4 -5
  86. package/src/collections/iset.test.mts +1 -1
  87. package/src/number/num.mts +10 -10
  88. package/src/object/object.mts +2 -2
  89. package/src/others/fast-deep-equal.mts +17 -10
  90. package/src/others/fast-deep-equal.test.mts +141 -29
  91. package/src/others/index.mts +1 -0
  92. package/src/others/replace-equal-deep.mts +225 -0
  93. package/src/others/replace-equal-deep.test.mts +348 -0
  94. package/src/string/str.mts +2 -2
@@ -0,0 +1,225 @@
1
+ import type { MutableRecord, UnknownRecord } from 'ts-type-forge';
2
+ import { hasKey, isRecord } from '../guard/index.mjs';
3
+ import { fastDeepEqual } from './fast-deep-equal.mjs';
4
+
5
+ /**
6
+ * Returns `next` with every part deeply equal to the same part of `current`
7
+ * replaced by that part of `current` (structural sharing). When the whole of
8
+ * `next` is deeply equal to `current`, `current` itself is returned, so an
9
+ * `Object.is` comparison of the result with `current` tells whether anything
10
+ * changed, and a reader that compares by reference sees a new object only where
11
+ * the value really changed.
12
+ *
13
+ * Only plain objects and arrays are walked into. Anything else — a `Map`, a
14
+ * `Set`, a `Date`, a class instance — is compared whole with
15
+ * {@link fastDeepEqual} and kept or replaced whole.
16
+ *
17
+ * Array items are matched by index. Give `key` to match them by key instead:
18
+ * an item for which it returns something other than `undefined` is matched with
19
+ * the item of `current` it returns the same key for, and an item it returns
20
+ * `undefined` for is still matched by index. The one `key` is used for the
21
+ * arrays at every depth.
22
+ *
23
+ * Neither argument is changed. The result shares objects with both, so it
24
+ * relies on neither being changed afterwards.
25
+ *
26
+ * Call it where the new value is written over the one held, so that nothing
27
+ * downstream has a new object it must compare again.
28
+ *
29
+ * @param current - The value held now
30
+ * @param next - The value to write over it
31
+ * @param options - {@link ReplaceEqualDeepOptions}
32
+ * @returns `current` when `next` is deeply equal to it; otherwise a value
33
+ * deeply equal to `next` that reuses the equal parts of `current`
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * type PullReport = DeepReadonly<{
38
+ * summary: { open: number };
39
+ * pulls: { number: number; title: string }[];
40
+ * }>;
41
+ *
42
+ * const held: PullReport = {
43
+ * summary: { open: 2 },
44
+ * pulls: [
45
+ * { number: 1, title: 'one' },
46
+ * { number: 2, title: 'two' },
47
+ * ],
48
+ * } as const;
49
+ *
50
+ * // A server answers with the whole report every time.
51
+ * const answer: PullReport = {
52
+ * summary: { open: 2 },
53
+ * pulls: [
54
+ * { number: 1, title: 'one' },
55
+ * { number: 2, title: 'two (edited)' },
56
+ * ],
57
+ * } as const;
58
+ *
59
+ * const written = replaceEqualDeep(held, answer);
60
+ *
61
+ * assert.deepStrictEqual(written, answer);
62
+ *
63
+ * // What did not change is the object already held,
64
+ * assert.strictEqual(written.summary, held.summary);
65
+ *
66
+ * assert.strictEqual(written.pulls[0], held.pulls[0]);
67
+ *
68
+ * // and what changed is new.
69
+ * assert.notStrictEqual(written.pulls[1], held.pulls[1]);
70
+ *
71
+ * // An answer that changes nothing gives back the value held.
72
+ * assert.strictEqual(replaceEqualDeep(held, structuredClone(held)), held);
73
+ *
74
+ * // Match array items by key rather than by index.
75
+ * const opened = replaceEqualDeep(
76
+ * held.pulls,
77
+ * [{ number: 0, title: 'zero' }, ...structuredClone(held.pulls)],
78
+ * {
79
+ * key: (item) =>
80
+ * isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
81
+ * },
82
+ * );
83
+ *
84
+ * assert.strictEqual(opened[1], held.pulls[0]);
85
+ *
86
+ * assert.strictEqual(opened[2], held.pulls[1]);
87
+ * ```
88
+ */
89
+ export const replaceEqualDeep = <T,>(
90
+ current: T,
91
+ next: T,
92
+ options?: ReplaceEqualDeepOptions,
93
+ ): T =>
94
+ // The walk sees `unknown`; what it returns is `current`, `next`, or a copy of
95
+ // `next`'s shape whose parts are parts of either, so it is a `T`.
96
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
97
+ replace(current, next, options?.key) as T;
98
+
99
+ /** Options of {@link replaceEqualDeep}. */
100
+ export type ReplaceEqualDeepOptions = Readonly<{
101
+ /**
102
+ * The key an array item is matched by. Items it returns `undefined` for are
103
+ * matched by index. Keys are compared with `SameValueZero`, as `Map` compares
104
+ * them.
105
+ */
106
+ key?: (item: unknown) => unknown;
107
+ }>;
108
+
109
+ type Key = (item: unknown) => unknown;
110
+
111
+ const isPlainArray = (value: unknown): value is readonly unknown[] =>
112
+ Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
113
+
114
+ const isPlainObject = (value: unknown): value is UnknownRecord =>
115
+ isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
116
+
117
+ const replace = (
118
+ current: unknown,
119
+ next: unknown,
120
+ key: Key | undefined,
121
+ ): unknown =>
122
+ Object.is(current, next)
123
+ ? current
124
+ : isPlainArray(current) && isPlainArray(next)
125
+ ? replaceArray(current, next, key)
126
+ : isPlainObject(current) && isPlainObject(next)
127
+ ? replaceObject(current, next, key)
128
+ : fastDeepEqual(current, next)
129
+ ? current
130
+ : next;
131
+
132
+ const indexByKey = (
133
+ items: readonly unknown[],
134
+ key: Key,
135
+ ): ReadonlyMap<unknown, unknown> => {
136
+ const mut_byKey = new Map<unknown, unknown>();
137
+
138
+ for (const item of items) {
139
+ const itemKey = key(item);
140
+
141
+ if (itemKey !== undefined && !mut_byKey.has(itemKey)) {
142
+ mut_byKey.set(itemKey, item);
143
+ }
144
+ }
145
+
146
+ return mut_byKey;
147
+ };
148
+
149
+ // Each walk returns `current` when every part of the result is the same part of
150
+ // `current`, `next` when every part is the same part of `next` (nothing was
151
+ // reused, so no copy is needed), and the copy otherwise.
152
+
153
+ const replaceArray = (
154
+ current: readonly unknown[],
155
+ next: readonly unknown[],
156
+ key: Key | undefined,
157
+ ): readonly unknown[] => {
158
+ const byKey = key === undefined ? undefined : indexByKey(current, key);
159
+
160
+ let mut_isCurrent = current.length === next.length;
161
+
162
+ let mut_isNext = true;
163
+
164
+ const mut_shared: unknown[] = [];
165
+
166
+ for (let mut_index = 0; mut_index < next.length; mut_index += 1) {
167
+ const index = mut_index;
168
+
169
+ const item = next[index];
170
+
171
+ const itemKey = key?.(item);
172
+
173
+ const before =
174
+ itemKey === undefined || byKey === undefined
175
+ ? index < current.length
176
+ ? current[index]
177
+ : absent
178
+ : byKey.has(itemKey)
179
+ ? byKey.get(itemKey)
180
+ : absent;
181
+
182
+ const result = before === absent ? item : replace(before, item, key);
183
+
184
+ mut_isCurrent &&= Object.is(result, current[index]);
185
+
186
+ mut_isNext &&= Object.is(result, item);
187
+
188
+ mut_shared.push(result);
189
+ }
190
+
191
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
192
+ };
193
+
194
+ const replaceObject = (
195
+ current: UnknownRecord,
196
+ next: UnknownRecord,
197
+ key: Key | undefined,
198
+ ): UnknownRecord => {
199
+ const nextKeys = Object.keys(next);
200
+
201
+ let mut_isCurrent = Object.keys(current).length === nextKeys.length;
202
+
203
+ let mut_isNext = true;
204
+
205
+ const mut_shared: MutableRecord<string, unknown> = {};
206
+
207
+ for (const k of nextKeys) {
208
+ const value = next[k];
209
+
210
+ const has = hasKey(current, k);
211
+
212
+ const result = has ? replace(current[k], value, key) : value;
213
+
214
+ mut_isCurrent &&= has && Object.is(result, current[k]);
215
+
216
+ mut_isNext &&= Object.is(result, value);
217
+
218
+ mut_shared[k] = result;
219
+ }
220
+
221
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
222
+ };
223
+
224
+ // What an item of `next` is matched with when nothing of `current` is.
225
+ const absent: unique symbol = Symbol('absent');
@@ -0,0 +1,348 @@
1
+ // A `Date` is one of the values compared whole, so the cases build some.
2
+ /* eslint-disable unicorn/prefer-temporal */
3
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
4
+ import { expectType } from '../expect-type.mjs';
5
+ import { hasKey, isRecord } from '../guard/index.mjs';
6
+ import { replaceEqualDeep } from './replace-equal-deep.mjs';
7
+
8
+ describe(replaceEqualDeep, () => {
9
+ test('returns the current value when the next one is deeply equal', () => {
10
+ const current = {
11
+ id: 1,
12
+ tags: ['a', 'b'],
13
+ owner: { name: 'x', roles: [{ role: 'admin' }] },
14
+ } as const;
15
+
16
+ const next = {
17
+ id: 1,
18
+ tags: ['a', 'b'],
19
+ owner: { name: 'x', roles: [{ role: 'admin' }] },
20
+ } as const;
21
+
22
+ assert.strictEqual(replaceEqualDeep(current, next), current);
23
+ });
24
+
25
+ test('keeps the branches that did not change and replaces the rest', () => {
26
+ const current: Snapshot = {
27
+ summary: { open: 3, merged: 1 },
28
+ queue: [{ number: 1 }, { number: 2 }],
29
+ readAt: 100,
30
+ } as const;
31
+
32
+ const next: Snapshot = {
33
+ summary: { open: 3, merged: 1 },
34
+ queue: [{ number: 1 }, { number: 3 }],
35
+ readAt: 200,
36
+ } as const;
37
+
38
+ const shared = replaceEqualDeep(current, next);
39
+
40
+ assert.deepStrictEqual(shared, next);
41
+
42
+ assert.notStrictEqual(shared, current);
43
+
44
+ assert.strictEqual(shared.summary, current.summary);
45
+
46
+ assert.notStrictEqual(shared.queue, current.queue);
47
+
48
+ assert.strictEqual(shared.queue[0], current.queue[0]);
49
+
50
+ assert.strictEqual(shared.queue[1], next.queue[1]);
51
+
52
+ assert.strictEqual(shared.readAt, 200);
53
+ });
54
+
55
+ test('returns the next primitive when it differs', () => {
56
+ assert.strictEqual(replaceEqualDeep(1, 2), 2);
57
+
58
+ assert.strictEqual(replaceEqualDeep('a', 'a'), 'a');
59
+
60
+ assert.isTrue(Number.isNaN(replaceEqualDeep(Number.NaN, Number.NaN)));
61
+ });
62
+
63
+ test('replaces a value whose kind changed whole', () => {
64
+ const next: unknown = { 0: 'a' } as const;
65
+
66
+ assert.strictEqual(replaceEqualDeep<unknown>(['a'], next), next);
67
+
68
+ const nextArray: unknown = ['a'] as const;
69
+
70
+ assert.strictEqual(
71
+ replaceEqualDeep<unknown>({ 0: 'a' }, nextArray),
72
+ nextArray,
73
+ );
74
+
75
+ const nextNull: unknown = null;
76
+
77
+ assert.strictEqual(replaceEqualDeep<unknown>({}, nextNull), null);
78
+ });
79
+
80
+ test('builds a new object when a property is added or removed', () => {
81
+ const inner = { x: 1 } as const;
82
+
83
+ const added = replaceEqualDeep<ReadonlyRecord<string, unknown>>(
84
+ { inner },
85
+ { inner: { x: 1 }, extra: true },
86
+ );
87
+
88
+ assert.deepStrictEqual(added, { inner: { x: 1 }, extra: true });
89
+
90
+ assert.strictEqual(added['inner'], inner);
91
+
92
+ const current: ReadonlyRecord<string, unknown> = {
93
+ inner,
94
+ gone: 1,
95
+ } as const;
96
+
97
+ const removed = replaceEqualDeep(current, { inner: { x: 1 } });
98
+
99
+ assert.deepStrictEqual(removed, { inner: { x: 1 } });
100
+
101
+ assert.notStrictEqual(removed, current);
102
+
103
+ assert.isFalse(Object.hasOwn(removed, 'gone'));
104
+
105
+ assert.strictEqual(removed['inner'], inner);
106
+ });
107
+
108
+ test('builds a new object when a property set to undefined goes missing', () => {
109
+ const current: Readonly<{ a?: number | undefined }> = {
110
+ a: undefined,
111
+ } as const;
112
+
113
+ const shared = replaceEqualDeep(current, {});
114
+
115
+ assert.notStrictEqual(shared, current);
116
+
117
+ assert.isFalse(Object.hasOwn(shared, 'a'));
118
+ });
119
+
120
+ test('matches array items by index by default', () => {
121
+ const one: Numbered = { number: 1 } as const;
122
+
123
+ const two: Numbered = { number: 2 } as const;
124
+
125
+ const current: readonly Numbered[] = [one, two] as const;
126
+
127
+ const shorter = replaceEqualDeep(current, [{ number: 1 }]);
128
+
129
+ assert.deepStrictEqual(shorter, [{ number: 1 }]);
130
+
131
+ assert.strictEqual(shorter[0], one);
132
+
133
+ const longer = replaceEqualDeep(current, [
134
+ { number: 1 },
135
+ { number: 2 },
136
+ { number: 3 },
137
+ ]);
138
+
139
+ assert.strictEqual(longer[0], one);
140
+
141
+ assert.strictEqual(longer[1], two);
142
+
143
+ // An item put in front moves every one after it, so nothing matches.
144
+ const shifted = replaceEqualDeep(current, [
145
+ { number: 0 },
146
+ { number: 1 },
147
+ { number: 2 },
148
+ ]);
149
+
150
+ assert.notStrictEqual(shifted[1], one);
151
+
152
+ assert.notStrictEqual(shifted[2], two);
153
+ });
154
+
155
+ test('matches array items by key when `key` returns one', () => {
156
+ const one: Pull = { number: 1, title: 'one' } as const;
157
+
158
+ const two: Pull = { number: 2, title: 'two' } as const;
159
+
160
+ const current: readonly Pull[] = [one, two] as const;
161
+
162
+ const key = (item: unknown): unknown =>
163
+ isRecord(item) && hasKey(item, 'number') ? item.number : undefined;
164
+
165
+ const shared = replaceEqualDeep(
166
+ current,
167
+ [
168
+ { number: 0, title: 'zero' },
169
+ { number: 1, title: 'one' },
170
+ { number: 2, title: 'two (edited)' },
171
+ ],
172
+ { key },
173
+ );
174
+
175
+ assert.strictEqual(shared[1], one);
176
+
177
+ assert.notStrictEqual(shared[2], two);
178
+
179
+ assert.deepStrictEqual(shared[2], { number: 2, title: 'two (edited)' });
180
+
181
+ const reordered = replaceEqualDeep(
182
+ current,
183
+ [
184
+ { number: 2, title: 'two' },
185
+ { number: 1, title: 'one' },
186
+ ],
187
+ { key },
188
+ );
189
+
190
+ assert.notStrictEqual(reordered, current);
191
+
192
+ assert.strictEqual(reordered[0], two);
193
+
194
+ assert.strictEqual(reordered[1], one);
195
+
196
+ const same = replaceEqualDeep(
197
+ current,
198
+ [
199
+ { number: 1, title: 'one' },
200
+ { number: 2, title: 'two' },
201
+ ],
202
+ { key },
203
+ );
204
+
205
+ assert.strictEqual(same, current);
206
+ });
207
+
208
+ test('matches by index the items `key` gives no key, at every depth', () => {
209
+ const key = (item: unknown): unknown =>
210
+ isRecord(item) && hasKey(item, 'id') ? item.id : undefined;
211
+
212
+ const labels: readonly string[] = ['bug', 'ui'] as const;
213
+
214
+ const current: readonly Labeled[] = [{ id: 'a', labels }] as const;
215
+
216
+ const shared = replaceEqualDeep(
217
+ current,
218
+ [
219
+ { id: 'b', labels: ['x'] },
220
+ { id: 'a', labels: ['bug', 'ui'] },
221
+ ],
222
+ { key },
223
+ );
224
+
225
+ assert.strictEqual(shared[1], current[0]);
226
+
227
+ assert.strictEqual(shared[1]?.labels, labels);
228
+ });
229
+
230
+ test('compares a Map, a Set, a Date or a class instance whole', () => {
231
+ const map = new Map<string, Readonly<{ x: number }>>([['a', { x: 1 }]]);
232
+
233
+ assert.strictEqual(
234
+ replaceEqualDeep(
235
+ map,
236
+ new Map<string, Readonly<{ x: number }>>([['a', { x: 1 }]]),
237
+ ),
238
+ map,
239
+ );
240
+
241
+ const changedMap = new Map<string, Readonly<{ x: number }>>([
242
+ ['a', { x: 1 }],
243
+ ['b', { x: 2 }],
244
+ ]);
245
+
246
+ const sharedMap = replaceEqualDeep(map, changedMap);
247
+
248
+ assert.strictEqual(sharedMap, changedMap);
249
+
250
+ // Nothing inside is reused.
251
+ assert.notStrictEqual(sharedMap.get('a'), map.get('a'));
252
+
253
+ const set = new Set<number>([1, 2]);
254
+
255
+ assert.strictEqual(replaceEqualDeep(set, new Set<number>([1, 2])), set);
256
+
257
+ const date = new Date(0);
258
+
259
+ assert.strictEqual(replaceEqualDeep(date, new Date(0)), date);
260
+
261
+ const laterDate = new Date(1);
262
+
263
+ assert.strictEqual(replaceEqualDeep(date, laterDate), laterDate);
264
+
265
+ class Point {
266
+ readonly x: number;
267
+
268
+ readonly inner: Readonly<{ y: number }>;
269
+
270
+ constructor(x: number, y: number) {
271
+ this.x = x;
272
+
273
+ this.inner = { y };
274
+ }
275
+ }
276
+
277
+ const point = new Point(1, 2);
278
+
279
+ assert.strictEqual(replaceEqualDeep(point, new Point(1, 2)), point);
280
+
281
+ const moved = new Point(3, 2);
282
+
283
+ const sharedPoint = replaceEqualDeep(point, moved);
284
+
285
+ assert.strictEqual(sharedPoint, moved);
286
+
287
+ assert.notStrictEqual(sharedPoint.inner, point.inner);
288
+ });
289
+
290
+ test('reuses a Map compared whole inside a plain object', () => {
291
+ const map = new Map<string, number>([['a', 1]]);
292
+
293
+ const current = { map, count: 1 } as const;
294
+
295
+ const shared = replaceEqualDeep(current, {
296
+ map: new Map<string, number>([['a', 1]]),
297
+ count: 2,
298
+ });
299
+
300
+ assert.strictEqual(shared.map, map);
301
+
302
+ assert.strictEqual(shared.count, 2);
303
+ });
304
+
305
+ test('changes neither argument', () => {
306
+ const current: Listed = { list: [{ a: 1 }, { a: 2 }] } as const;
307
+
308
+ const next: Listed = { list: [{ a: 1 }, { a: 3 }] } as const;
309
+
310
+ const currentCopy = structuredClone(current);
311
+
312
+ const nextCopy = structuredClone(next);
313
+
314
+ replaceEqualDeep(current, next);
315
+
316
+ assert.deepStrictEqual(current, currentCopy);
317
+
318
+ assert.deepStrictEqual(next, nextCopy);
319
+ });
320
+
321
+ test('keeps the type of its arguments', () => {
322
+ type PullReport = Readonly<{
323
+ entries: readonly Readonly<{ number: number }>[];
324
+ }>;
325
+
326
+ const report: PullReport = { entries: [] } as const;
327
+
328
+ const shared = replaceEqualDeep(report, { entries: [{ number: 1 }] });
329
+
330
+ expectType<typeof shared, PullReport>('=');
331
+
332
+ assert.deepStrictEqual(shared, { entries: [{ number: 1 }] });
333
+ });
334
+ });
335
+
336
+ type Snapshot = DeepReadonly<{
337
+ summary: { open: number; merged: number };
338
+ queue: { number: number }[];
339
+ readAt: number;
340
+ }>;
341
+
342
+ type Numbered = Readonly<{ number: number }>;
343
+
344
+ type Pull = Readonly<{ number: number; title: string }>;
345
+
346
+ type Labeled = Readonly<{ id: string; labels: readonly string[] }>;
347
+
348
+ type Listed = DeepReadonly<{ list: { a: number }[] }>;
@@ -522,8 +522,8 @@ export namespace Str {
522
522
  * `NonEmptyString` is an alias of `MinLengthString<1>`, so this is the
523
523
  * length-1 specialization of {@link asMinLengthString}. Use this instead of
524
524
  * writing `s as unknown as NonEmptyString`: the length is verified at
525
- * runtime, and the original string type (e.g. literal types) is preserved
526
- * via intersection.
525
+ * runtime, and the original string type (e.g. literal types) is preserved via
526
+ * intersection.
527
527
  *
528
528
  * @example
529
529
  *