ts-data-forge 14.3.0 → 14.5.0

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 (59) hide show
  1. package/README.md +6 -0
  2. package/dist/array/impl/array-utils-search.d.mts.map +1 -1
  3. package/dist/array/impl/array-utils-search.mjs.map +1 -1
  4. package/dist/functional/optional/impl/index.d.mts +1 -0
  5. package/dist/functional/optional/impl/index.d.mts.map +1 -1
  6. package/dist/functional/optional/impl/index.mjs +1 -0
  7. package/dist/functional/optional/impl/index.mjs.map +1 -1
  8. package/dist/functional/optional/impl/optional-match.d.mts +48 -0
  9. package/dist/functional/optional/impl/optional-match.d.mts.map +1 -0
  10. package/dist/functional/optional/impl/optional-match.mjs +22 -0
  11. package/dist/functional/optional/impl/optional-match.mjs.map +1 -0
  12. package/dist/functional/result/impl/index.d.mts +4 -0
  13. package/dist/functional/result/impl/index.d.mts.map +1 -1
  14. package/dist/functional/result/impl/index.mjs +4 -0
  15. package/dist/functional/result/impl/index.mjs.map +1 -1
  16. package/dist/functional/result/impl/result-from-optional.d.mts +41 -0
  17. package/dist/functional/result/impl/result-from-optional.d.mts.map +1 -0
  18. package/dist/functional/result/impl/result-from-optional.mjs +24 -0
  19. package/dist/functional/result/impl/result-from-optional.mjs.map +1 -0
  20. package/dist/functional/result/impl/result-match.d.mts +47 -0
  21. package/dist/functional/result/impl/result-match.d.mts.map +1 -0
  22. package/dist/functional/result/impl/result-match.mjs +24 -0
  23. package/dist/functional/result/impl/result-match.mjs.map +1 -0
  24. package/dist/functional/result/impl/result-safe-try.d.mts +51 -0
  25. package/dist/functional/result/impl/result-safe-try.d.mts.map +1 -0
  26. package/dist/functional/result/impl/result-safe-try.mjs +24 -0
  27. package/dist/functional/result/impl/result-safe-try.mjs.map +1 -0
  28. package/dist/functional/result/impl/result-safe-unwrap.d.mts +36 -0
  29. package/dist/functional/result/impl/result-safe-unwrap.d.mts.map +1 -0
  30. package/dist/functional/result/impl/result-safe-unwrap.mjs +14 -0
  31. package/dist/functional/result/impl/result-safe-unwrap.mjs.map +1 -0
  32. package/dist/object/object.d.mts +113 -0
  33. package/dist/object/object.d.mts.map +1 -1
  34. package/dist/object/object.mjs +29 -0
  35. package/dist/object/object.mjs.map +1 -1
  36. package/dist/others/debounce-function.d.mts.map +1 -1
  37. package/dist/others/debounce-function.mjs.map +1 -1
  38. package/dist/others/fast-deep-equal.d.mts.map +1 -1
  39. package/dist/others/fast-deep-equal.mjs +1 -1
  40. package/dist/others/fast-deep-equal.mjs.map +1 -1
  41. package/package.json +9 -9
  42. package/src/array/impl/array-utils-modification.test.mts +3 -3
  43. package/src/array/impl/array-utils-search.mts +22 -8
  44. package/src/array/impl/array-utils-shape-invariants.test.mts +3 -3
  45. package/src/array/impl/array-utils-transformation.test.mts +1 -1
  46. package/src/functional/optional/impl/index.mts +1 -0
  47. package/src/functional/optional/impl/optional-match.mts +108 -0
  48. package/src/functional/optional.test.mts +79 -0
  49. package/src/functional/result/impl/index.mts +4 -0
  50. package/src/functional/result/impl/result-from-optional.mts +80 -0
  51. package/src/functional/result/impl/result-match.mts +108 -0
  52. package/src/functional/result/impl/result-safe-try.mts +110 -0
  53. package/src/functional/result/impl/result-safe-unwrap.mts +50 -0
  54. package/src/functional/result.test.mts +331 -0
  55. package/src/guard/is-error.test.mts +7 -7
  56. package/src/object/object.mts +187 -0
  57. package/src/object/object.test.mts +120 -1
  58. package/src/others/debounce-function.mts +5 -1
  59. package/src/others/fast-deep-equal.mts +9 -2
@@ -1017,4 +1017,335 @@ describe('Result test', () => {
1017
1017
  >();
1018
1018
  });
1019
1019
  });
1020
+
1021
+ describe('match', () => {
1022
+ test('should apply the ok case for Ok', () => {
1023
+ const result = Result.match(Result.ok(21), {
1024
+ ok: (value) => value * 2,
1025
+ err: () => 0,
1026
+ });
1027
+
1028
+ expectType<typeof result, number>('=');
1029
+
1030
+ assert.deepStrictEqual(result, 42);
1031
+ });
1032
+
1033
+ test('should apply the err case for Err', () => {
1034
+ const result = Result.match(Result.err('failure'), {
1035
+ ok: () => 'ok',
1036
+ err: (error) => `error: ${error}`,
1037
+ });
1038
+
1039
+ assert.deepStrictEqual(result, 'error: failure');
1040
+ });
1041
+
1042
+ test('should support Result union inputs', () => {
1043
+ // Created through a function so that the const is not flow-narrowed to
1044
+ // the Ok side.
1045
+ const makeResult = (): Result<number, string> => Result.ok(2);
1046
+
1047
+ const result = makeResult();
1048
+
1049
+ const folded = Result.match(result, {
1050
+ ok: (value) => value + 1,
1051
+ err: (error) => error.length,
1052
+ });
1053
+
1054
+ expectType<typeof folded, number>('=');
1055
+
1056
+ assert.deepStrictEqual(folded, 3);
1057
+ });
1058
+
1059
+ test('curried version should work', () => {
1060
+ const matcher = Result.match({
1061
+ ok: (value: number) => value + 1,
1062
+ err: (error: string) => error.length,
1063
+ });
1064
+
1065
+ assert.deepStrictEqual(matcher(Result.ok(1)), 2);
1066
+
1067
+ assert.deepStrictEqual(matcher(Result.err('oops')), 4);
1068
+ });
1069
+
1070
+ test('curried version should compose with Array.map and pipe', () => {
1071
+ const matcher = Result.match({
1072
+ ok: (value: number) => `ok:${value}`,
1073
+ err: (error: string) => `err:${error}`,
1074
+ });
1075
+
1076
+ const results: readonly Result<number, string>[] = [
1077
+ Result.ok(1),
1078
+ Result.err('bad'),
1079
+ ] as const;
1080
+
1081
+ assert.deepStrictEqual(results.map(matcher), ['ok:1', 'err:bad']);
1082
+
1083
+ const single: Result<number, string> = Result.ok(3);
1084
+
1085
+ assert.deepStrictEqual(pipe(single).map(matcher).value, 'ok:3');
1086
+ });
1087
+ });
1088
+
1089
+ describe('fromOptional', () => {
1090
+ test('should convert Some to Ok', () => {
1091
+ const result = Result.fromOptional(Optional.some(42), 'missing');
1092
+
1093
+ expectType<typeof result, Result<42, 'missing'>>('=');
1094
+
1095
+ assert.deepStrictEqual(result, Result.ok(42));
1096
+ });
1097
+
1098
+ test('should convert None to Err with the provided error', () => {
1099
+ const result = Result.fromOptional(Optional.none, 'missing');
1100
+
1101
+ assert.isTrue(Result.isErr(result));
1102
+
1103
+ assert.deepStrictEqual(result.value, 'missing');
1104
+ });
1105
+
1106
+ test('should convert Some(undefined) to Ok(undefined), not Err', () => {
1107
+ const makeOptional = (): Optional<undefined> => Optional.some(undefined);
1108
+
1109
+ const result = Result.fromOptional(makeOptional(), 'missing');
1110
+
1111
+ assert.isTrue(Result.isOk(result));
1112
+
1113
+ assert.deepStrictEqual(result.value, undefined);
1114
+ });
1115
+
1116
+ test('should support Optional union inputs', () => {
1117
+ // Created through a function so that the const is not flow-narrowed to
1118
+ // the Some side.
1119
+ const makeOptional = (): Optional<number> => Optional.some(2);
1120
+
1121
+ const result = Result.fromOptional(makeOptional(), 'missing');
1122
+
1123
+ expectType<typeof result, Result<number, 'missing'>>('=');
1124
+
1125
+ assert.deepStrictEqual(result, Result.ok(2));
1126
+ });
1127
+
1128
+ test('curried version should work', () => {
1129
+ const withMissingError = Result.fromOptional('missing');
1130
+
1131
+ const result = withMissingError(Optional.some(1));
1132
+
1133
+ expectType<typeof result, Result<1, 'missing'>>('=');
1134
+
1135
+ assert.deepStrictEqual(result, Result.ok(1));
1136
+
1137
+ assert.deepStrictEqual(
1138
+ withMissingError(Optional.none),
1139
+ Result.err('missing'),
1140
+ );
1141
+ });
1142
+ });
1143
+
1144
+ describe('safeTry', () => {
1145
+ test('should return the body result when every yielded Result is Ok', () => {
1146
+ const result = Result.safeTry(function* () {
1147
+ const x = yield* Result.safeUnwrap(Result.ok(2));
1148
+
1149
+ const y = yield* Result.safeUnwrap(Result.ok(3));
1150
+
1151
+ return Result.ok(x + y);
1152
+ });
1153
+
1154
+ expectType<typeof result, Result<number, never>>('=');
1155
+
1156
+ assert.deepStrictEqual(result, Result.ok(5));
1157
+ });
1158
+
1159
+ test('should short-circuit on the first Err', () => {
1160
+ const makeFailing = (): Result<number, string> =>
1161
+ Result.err('first error');
1162
+
1163
+ const failing = makeFailing();
1164
+
1165
+ let mut_reached = false;
1166
+
1167
+ const result = Result.safeTry(function* () {
1168
+ const x = yield* Result.safeUnwrap(failing);
1169
+
1170
+ mut_reached = true;
1171
+
1172
+ return Result.ok(x);
1173
+ });
1174
+
1175
+ assert.isFalse(mut_reached);
1176
+
1177
+ assert.deepStrictEqual(result, Result.err('first error'));
1178
+ });
1179
+
1180
+ test('should collect error types from yields and the return', () => {
1181
+ const makeSource = (): Result<number, 'e1'> => Result.ok(1);
1182
+
1183
+ const source = makeSource();
1184
+
1185
+ const result = Result.safeTry(function* () {
1186
+ const x = yield* Result.safeUnwrap(source);
1187
+
1188
+ if (x > 0) {
1189
+ return Result.err('e2');
1190
+ }
1191
+
1192
+ return Result.ok(x);
1193
+ });
1194
+
1195
+ expectType<typeof result, Result<number, 'e1' | 'e2'>>('=');
1196
+
1197
+ assert.deepStrictEqual(result, Result.err('e2'));
1198
+ });
1199
+
1200
+ test('async generator should return a Promise of the body result', async () => {
1201
+ const result = await Result.safeTry(async function* () {
1202
+ const x = yield* Result.safeUnwrap(
1203
+ await Result.fromPromise(Promise.resolve(10)),
1204
+ );
1205
+
1206
+ return Result.ok(x + 1);
1207
+ });
1208
+
1209
+ assert.deepStrictEqual(result, Result.ok(11));
1210
+ });
1211
+
1212
+ test('async generator should short-circuit on Err', async () => {
1213
+ const makeFailing = (): Promise<Result<number, string>> =>
1214
+ Promise.resolve(Result.err('async error'));
1215
+
1216
+ let mut_reached = false;
1217
+
1218
+ const promise = Result.safeTry(async function* () {
1219
+ const x = yield* Result.safeUnwrap(await makeFailing());
1220
+
1221
+ mut_reached = true;
1222
+
1223
+ return Result.ok(x);
1224
+ });
1225
+
1226
+ expectType<typeof promise, Promise<Result<number, string>>>('=');
1227
+
1228
+ const result = await promise;
1229
+
1230
+ assert.isFalse(mut_reached);
1231
+
1232
+ assert.deepStrictEqual(result, Result.err('async error'));
1233
+ });
1234
+
1235
+ test('should run pending `finally` blocks when short-circuiting', () => {
1236
+ // Created through a function so that the const is not flow-narrowed to
1237
+ // the Err side.
1238
+ const makeFailing = (): Result<number, string> => Result.err('boom');
1239
+
1240
+ let mut_cleanedUp = false;
1241
+
1242
+ const result = Result.safeTry(function* () {
1243
+ try {
1244
+ const x = yield* Result.safeUnwrap(makeFailing());
1245
+
1246
+ return Result.ok(x);
1247
+ } finally {
1248
+ mut_cleanedUp = true;
1249
+ }
1250
+ });
1251
+
1252
+ assert.isTrue(mut_cleanedUp);
1253
+
1254
+ assert.deepStrictEqual(result, Result.err('boom'));
1255
+ });
1256
+
1257
+ test('should propagate an exception thrown by the body', () => {
1258
+ expect(() =>
1259
+ Result.safeTry(function* () {
1260
+ const x = yield* Result.safeUnwrap(Result.ok(1));
1261
+
1262
+ throw new Error(`boom: ${x}`);
1263
+ }),
1264
+ ).toThrow('boom: 1');
1265
+ });
1266
+
1267
+ test('async generator should reject on an exception thrown by the body', async () => {
1268
+ await expect(
1269
+ Result.safeTry(async function* () {
1270
+ const x = yield* Result.safeUnwrap(
1271
+ await Promise.resolve(Result.ok(1)),
1272
+ );
1273
+
1274
+ throw new Error(`boom: ${x}`);
1275
+ }),
1276
+ ).rejects.toThrow('boom: 1');
1277
+ });
1278
+
1279
+ test('should propagate through a delegating helper generator', () => {
1280
+ const makeSource = (): Result<number, 'e1'> => Result.err('e1');
1281
+
1282
+ const doubled = function* (): Generator<Err<'e1'>, number, unknown> {
1283
+ const x = yield* Result.safeUnwrap(makeSource());
1284
+
1285
+ return x * 2;
1286
+ };
1287
+
1288
+ const result = Result.safeTry(function* () {
1289
+ const y = yield* doubled();
1290
+
1291
+ return Result.ok(y);
1292
+ });
1293
+
1294
+ expectType<typeof result, Result<number, 'e1'>>('=');
1295
+
1296
+ assert.deepStrictEqual(result, Result.err('e1'));
1297
+ });
1298
+
1299
+ test('async generator should run pending `finally` blocks too', async () => {
1300
+ const makeFailing = (): Promise<Result<number, string>> =>
1301
+ Promise.resolve(Result.err('boom'));
1302
+
1303
+ let mut_cleanedUp = false;
1304
+
1305
+ const result = await Result.safeTry(async function* () {
1306
+ try {
1307
+ const x = yield* Result.safeUnwrap(await makeFailing());
1308
+
1309
+ return Result.ok(x);
1310
+ } finally {
1311
+ mut_cleanedUp = true;
1312
+ }
1313
+ });
1314
+
1315
+ assert.isTrue(mut_cleanedUp);
1316
+
1317
+ assert.deepStrictEqual(result, Result.err('boom'));
1318
+ });
1319
+ });
1320
+
1321
+ describe('safeUnwrap', () => {
1322
+ test('should return the value without yielding for an Ok', () => {
1323
+ const generator = Result.safeUnwrap(Result.ok(7));
1324
+
1325
+ assert.deepStrictEqual(generator.next(), { done: true, value: 7 });
1326
+ });
1327
+
1328
+ test('should yield the Err itself for an Err', () => {
1329
+ const makeFailing = (): Result<number, string> => Result.err('boom');
1330
+
1331
+ const generator = Result.safeUnwrap(makeFailing());
1332
+
1333
+ assert.deepStrictEqual(generator.next(), {
1334
+ done: false,
1335
+ value: Result.err('boom'),
1336
+ });
1337
+ });
1338
+
1339
+ test('should throw if resumed after yielding an Err', () => {
1340
+ const makeFailing = (): Result<number, string> => Result.err('boom');
1341
+
1342
+ const generator = Result.safeUnwrap(makeFailing());
1343
+
1344
+ generator.next();
1345
+
1346
+ expect(() => generator.next()).toThrow(
1347
+ 'it must only be used via `yield*` inside `safeTry()`',
1348
+ );
1349
+ });
1350
+ });
1020
1351
  });
@@ -1,4 +1,3 @@
1
- /* eslint-disable functional/immutable-data */
2
1
  /* eslint-disable functional/no-class-inheritance */
3
2
  /* eslint-disable unicorn/error-message */
4
3
  import { isError as isErrorBySindreSorhus } from '@sindresorhus/is';
@@ -129,14 +128,15 @@ describe('isError from @sindresorhus/is', () => {
129
128
  });
130
129
 
131
130
  test('should return false for errors created with Object.create', () => {
132
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
133
- const protoError = Object.create(Error.prototype);
131
+ // Built and then re-pointed rather than `Object.create(Error.prototype)`,
132
+ // which the strict standard library types as `unknown` — correctly, since
133
+ // it knows nothing about the resulting shape. The object under test is the
134
+ // same: Error.prototype in its chain, never constructed as an Error.
135
+ const mut_protoError = { message: 'proto error', name: 'ProtoError' };
134
136
 
135
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
136
- protoError.message = 'proto error';
137
+ Object.setPrototypeOf(mut_protoError, Error.prototype);
137
138
 
138
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
139
- protoError.name = 'ProtoError';
139
+ const protoError = mut_protoError;
140
140
 
141
141
  assert.isFalse(isErrorBySindreSorhus(protoError));
142
142
  });
@@ -17,6 +17,7 @@ import {
17
17
  type UnknownRecord,
18
18
  } from 'ts-type-forge';
19
19
  import { Arr } from '../array/index.mjs';
20
+ import { Optional } from '../functional/index.mjs';
20
21
  import { hasKey, isRecord } from '../guard/index.mjs';
21
22
 
22
23
  /**
@@ -397,6 +398,171 @@ export namespace Obj {
397
398
  );
398
399
  };
399
400
 
401
+ /**
402
+ * Keeps the entries whose value the predicate accepts, leaving the values
403
+ * themselves untouched.
404
+ *
405
+ * **Type Behavior**: the surviving keys are the keys of the source record,
406
+ * made optional — the predicate may reject any of them. A record keyed by an
407
+ * index signature stays total instead, because it names no specific key that
408
+ * could go missing.
409
+ *
410
+ * Passing a type guard narrows the value type of the result, which is what
411
+ * `Object.fromEntries(Object.entries(record).filter(guard))` loses.
412
+ *
413
+ * **Key handling**: keys reach the callback the way `Object.entries` yields
414
+ * them, so a numeric key `1` arrives as `'1'`. Symbol-keyed properties are
415
+ * not enumerated by `Object.entries`; they are dropped from both the result
416
+ * and its type.
417
+ *
418
+ * @example
419
+ *
420
+ * <!-- doc:embed:jsdoc:example:./samples/src/object/filter-example.mts -->
421
+ *
422
+ * ```ts
423
+ * const scores = { alice: 1, bob: 2 } as const;
424
+ *
425
+ * const passing = Obj.filter(scores, (value) => value > 1);
426
+ *
427
+ * assert.deepStrictEqual(passing, { bob: 2 });
428
+ *
429
+ * // The key is available as the second argument.
430
+ * const notAlice = Obj.filter(scores, (_value, key) => key !== 'alice');
431
+ *
432
+ * assert.deepStrictEqual(notAlice, { bob: 2 });
433
+ *
434
+ * // A type guard narrows the value type of the result.
435
+ * const mixed: UnknownRecord = { a: 1, b: 'x' } as const;
436
+ *
437
+ * const strings = Obj.filter(mixed, isString);
438
+ *
439
+ * assert.deepStrictEqual(strings, { b: 'x' });
440
+ * ```
441
+ *
442
+ * <!-- /doc:embed:jsdoc:example:./samples/src/object/filter-example.mts -->
443
+ *
444
+ * @template R - The type of the source record
445
+ * @template V2 - The type the guard narrows values to
446
+ * @param record - The record whose entries are filtered
447
+ * @param predicate - Called once per own enumerable string key, with the
448
+ * value and the key
449
+ * @returns A new record holding only the accepted entries
450
+ */
451
+ export function filter<const R extends UnknownRecord, V2 extends R[keyof R]>(
452
+ record: R,
453
+ predicate: (
454
+ value: R[keyof R],
455
+ key: TsDataForgeInternals.MappedRecordKey<R>,
456
+ ) => value is V2,
457
+ ): TsDataForgeInternals.FilteredRecord<
458
+ TsDataForgeInternals.MappedRecord<R, V2>
459
+ >;
460
+
461
+ export function filter<const R extends UnknownRecord>(
462
+ record: R,
463
+ predicate: (
464
+ value: R[keyof R],
465
+ key: TsDataForgeInternals.MappedRecordKey<R>,
466
+ ) => boolean,
467
+ ): TsDataForgeInternals.FilteredRecord<R>;
468
+
469
+ export function filter<const R extends UnknownRecord>(
470
+ record: R,
471
+ predicate: (
472
+ value: R[keyof R],
473
+ key: TsDataForgeInternals.MappedRecordKey<R>,
474
+ ) => boolean,
475
+ ): TsDataForgeInternals.FilteredRecord<R> {
476
+ // `Object.entries` erases both the key and the value type of `R`, so the
477
+ // callback is widened once here instead of asserting on every entry.
478
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
479
+ const predicateWidened = predicate as (
480
+ value: unknown,
481
+ key: string,
482
+ ) => boolean;
483
+
484
+ return (
485
+ // `Object.entries` enumerates exactly the own enumerable string keys of
486
+ // `record`, and filtering only ever removes entries, so the result has a
487
+ // subset of those keys — which is what `FilteredRecord` claims.
488
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
489
+ Object.fromEntries(
490
+ Object.entries(record).filter(([k, v]) => predicateWidened(v, k)),
491
+ ) as never
492
+ );
493
+ }
494
+
495
+ /**
496
+ * Transforms and drops entries in one pass: the callback returns
497
+ * `Optional.some(value)` to keep an entry with a new value, or
498
+ * `Optional.none` to drop it.
499
+ *
500
+ * **Type Behavior**: as with {@link filter}, the surviving keys are the keys
501
+ * of the source record made optional, and a record keyed by an index
502
+ * signature stays total. Unlike {@link filter}, every kept value takes the
503
+ * type the callback returns.
504
+ *
505
+ * `Optional` rather than `undefined` marks the dropped entries, so a record
506
+ * whose values are legitimately `undefined` stays expressible.
507
+ *
508
+ * **Key handling**: keys reach the callback the way `Object.entries` yields
509
+ * them, so a numeric key `1` arrives as `'1'`. Symbol-keyed properties are
510
+ * not enumerated by `Object.entries`; they are dropped from both the result
511
+ * and its type.
512
+ *
513
+ * @example
514
+ *
515
+ * <!-- doc:embed:jsdoc:example:./samples/src/object/filter-map-example.mts -->
516
+ *
517
+ * ```ts
518
+ * const scores = { alice: 1, bob: 2 } as const;
519
+ *
520
+ * // `Optional.some` keeps an entry with a new value, `Optional.none` drops it.
521
+ * const labelled = Obj.filterMap(scores, (value, key) =>
522
+ * value > 1 ? Optional.some(`${key}=${value}`) : Optional.none,
523
+ * );
524
+ *
525
+ * assert.deepStrictEqual(labelled, { bob: 'bob=2' });
526
+ * ```
527
+ *
528
+ * <!-- /doc:embed:jsdoc:example:./samples/src/object/filter-map-example.mts -->
529
+ *
530
+ * @template R - The type of the source record
531
+ * @template V2 - The type the callback returns for the entries it keeps
532
+ * @param record - The record whose entries are transformed and filtered
533
+ * @param mapFn - Called once per own enumerable string key, with the value
534
+ * and the key
535
+ * @returns A new record holding only the kept entries, with mapped values
536
+ */
537
+ export const filterMap = <const R extends UnknownRecord, V2>(
538
+ record: R,
539
+ mapFn: (
540
+ value: R[keyof R],
541
+ key: TsDataForgeInternals.MappedRecordKey<R>,
542
+ ) => Optional<V2>,
543
+ ): TsDataForgeInternals.FilteredRecord<
544
+ TsDataForgeInternals.MappedRecord<R, V2>
545
+ > => {
546
+ // `Object.entries` erases both the key and the value type of `R`, so the
547
+ // callback is widened once here instead of asserting on every entry.
548
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
549
+ const mapFnWidened = mapFn as (value: unknown, key: string) => Optional<V2>;
550
+
551
+ return (
552
+ // `Object.entries` enumerates exactly the own enumerable string keys of
553
+ // `record`, and this only ever drops entries or replaces their values, so
554
+ // the result has a subset of those keys with `V2` values.
555
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
556
+ Object.fromEntries(
557
+ Object.entries(record).flatMap(([k, v]) => {
558
+ const mapped = mapFnWidened(v, k);
559
+
560
+ return Optional.isSome(mapped) ? [[k, mapped.value] as const] : [];
561
+ }),
562
+ ) as never
563
+ );
564
+ };
565
+
400
566
  /**
401
567
  * Merges multiple records into a single record using `Object.assign`.
402
568
  * Later records override properties from earlier records with the same key.
@@ -691,6 +857,27 @@ declare namespace TsDataForgeInternals {
691
857
  [K in keyof R as K extends symbol ? never : K]: V2;
692
858
  }>;
693
859
 
860
+ /** `R` without the symbol keys `Object.entries` never visits. */
861
+ type StringKeyedRecord<R> = Readonly<{
862
+ [K in keyof R as K extends symbol ? never : K]: R[K];
863
+ }>;
864
+
865
+ /**
866
+ * The result of dropping entries from `R`: symbol keys go away, and the
867
+ * remaining keys become optional because a predicate may reject any of them.
868
+ *
869
+ * A record keyed by an index signature stays total instead. It names no
870
+ * specific key that could go missing, and `noUncheckedIndexedAccess` already
871
+ * adds `| undefined` on access, so making it `Partial` would add nothing and
872
+ * would only stop the result from being assignable back to the record type it
873
+ * came from.
874
+ */
875
+ export type FilteredRecord<R> = string extends keyof R
876
+ ? StringKeyedRecord<R>
877
+ : number extends keyof R
878
+ ? StringKeyedRecord<R>
879
+ : Readonly<{ [K in keyof R as K extends symbol ? never : K]?: R[K] }>;
880
+
694
881
  /**
695
882
  * Merges two object types where keys in B override keys in A, preserving optional modifiers.
696
883
  *
@@ -5,7 +5,8 @@ import {
5
5
  type UnknownRecord,
6
6
  } from 'ts-type-forge';
7
7
  import { expectType } from '../expect-type.mjs';
8
- import { pipe } from '../functional/index.mjs';
8
+ import { Optional, pipe } from '../functional/index.mjs';
9
+ import { isString } from '../guard/index.mjs';
9
10
  import { Obj } from './object.mjs';
10
11
 
11
12
  describe('shallowEq', () => {
@@ -285,6 +286,124 @@ describe('map', () => {
285
286
  });
286
287
  });
287
288
 
289
+ describe('filter', () => {
290
+ test('should keep the entries the predicate accepts', () => {
291
+ const result = Obj.filter({ a: 1, b: 2 }, (value) => value > 1);
292
+
293
+ expectType<typeof result, Readonly<{ a?: 1; b?: 2 }>>('=');
294
+
295
+ assert.deepStrictEqual(result, { b: 2 });
296
+ });
297
+
298
+ test('should keep a record with an index signature total', () => {
299
+ const scores: ReadonlyRecord<string, number> = {
300
+ alice: 1,
301
+ bob: 2,
302
+ } as const;
303
+
304
+ const result = Obj.filter(scores, (value) => value > 1);
305
+
306
+ expectType<typeof result, ReadonlyRecord<string, number>>('=');
307
+
308
+ assert.deepStrictEqual(result, { bob: 2 });
309
+ });
310
+
311
+ test('should pass the key as the second argument', () => {
312
+ const result = Obj.filter({ a: 1, b: 2 }, (_value, key) => {
313
+ expectType<typeof key, 'a' | 'b'>('=');
314
+
315
+ return key === 'a';
316
+ });
317
+
318
+ assert.deepStrictEqual(result, { a: 1 });
319
+ });
320
+
321
+ test('should narrow the value type through a type guard', () => {
322
+ const mixed: UnknownRecord = { a: 1, b: 'x' } as const;
323
+
324
+ const result = Obj.filter(mixed, isString);
325
+
326
+ expectType<typeof result, ReadonlyRecord<string, string>>('=');
327
+
328
+ assert.deepStrictEqual(result, { b: 'x' });
329
+ });
330
+
331
+ test('should drop symbol keys, as Object.entries does', () => {
332
+ const sym = Symbol('s');
333
+
334
+ const result = Obj.filter({ a: 1, [sym]: 2 }, () => true);
335
+
336
+ expectType<typeof result, Readonly<{ a?: 1 }>>('=');
337
+
338
+ assert.deepStrictEqual(result, { a: 1 });
339
+ });
340
+
341
+ test('should handle an empty record', () => {
342
+ const result = Obj.filter({}, () => true);
343
+
344
+ assert.deepStrictEqual(result, {});
345
+ });
346
+
347
+ test('should not mutate the source record', () => {
348
+ const source = { a: 1, b: 2 } as const;
349
+
350
+ Obj.filter(source, (value) => value > 1);
351
+
352
+ assert.deepStrictEqual(source, { a: 1, b: 2 });
353
+ });
354
+ });
355
+
356
+ describe('filterMap', () => {
357
+ test('should keep Optional.some and drop Optional.none', () => {
358
+ const result = Obj.filterMap({ a: 1, b: 2 }, (value) =>
359
+ value > 1 ? Optional.some(value * 10) : Optional.none,
360
+ );
361
+
362
+ expectType<typeof result, Readonly<{ a?: number; b?: number }>>('=');
363
+
364
+ assert.deepStrictEqual(result, { b: 20 });
365
+ });
366
+
367
+ test('should keep a record with an index signature total', () => {
368
+ const scores: ReadonlyRecord<string, number> = {
369
+ alice: 1,
370
+ bob: 2,
371
+ } as const;
372
+
373
+ const result = Obj.filterMap(scores, (value) =>
374
+ value > 1 ? Optional.some(value * 10) : Optional.none,
375
+ );
376
+
377
+ expectType<typeof result, ReadonlyRecord<string, number>>('=');
378
+
379
+ assert.deepStrictEqual(result, { bob: 20 });
380
+ });
381
+
382
+ test('should pass the key as the second argument', () => {
383
+ const result = Obj.filterMap({ a: 1, b: 2 }, (value, key) => {
384
+ expectType<typeof key, 'a' | 'b'>('=');
385
+
386
+ return key === 'a' ? Optional.some(value) : Optional.none;
387
+ });
388
+
389
+ assert.deepStrictEqual(result, { a: 1 });
390
+ });
391
+
392
+ test('should handle an empty record', () => {
393
+ const result = Obj.filterMap({}, () => Optional.none);
394
+
395
+ assert.deepStrictEqual(result, {});
396
+ });
397
+
398
+ test('should not mutate the source record', () => {
399
+ const source = { a: 1, b: 2 } as const;
400
+
401
+ Obj.filterMap(source, (value) => Optional.some(value * 2));
402
+
403
+ assert.deepStrictEqual(source, { a: 1, b: 2 });
404
+ });
405
+ });
406
+
288
407
  describe('merge', () => {
289
408
  test('should merge two objects, later overriding earlier', () => {
290
409
  const a = { a: 0, b: 0 } as const;