@nunofyobiz/effect-extras 0.0.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/dist/index.d.ts +3703 -0
  4. package/dist/index.js +1006 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +103 -0
  7. package/src/ArrayX/ArrayX.ts +818 -0
  8. package/src/ArrayX/index.ts +1 -0
  9. package/src/BigIntX/BigIntX.ts +35 -0
  10. package/src/BigIntX/index.ts +1 -0
  11. package/src/BooleanX/BooleanX.ts +24 -0
  12. package/src/BooleanX/index.ts +1 -0
  13. package/src/DurationX/DurationX.ts +178 -0
  14. package/src/DurationX/index.ts +1 -0
  15. package/src/EffectX/EffectX.ts +183 -0
  16. package/src/EffectX/index.ts +1 -0
  17. package/src/FormDataX/FormDataX.ts +57 -0
  18. package/src/FormDataX/index.ts +1 -0
  19. package/src/MapX/MapX.ts +54 -0
  20. package/src/MapX/index.ts +1 -0
  21. package/src/NonNullableX/NonNullableX.ts +290 -0
  22. package/src/NonNullableX/index.ts +2 -0
  23. package/src/NumberX/NumberX.ts +282 -0
  24. package/src/NumberX/index.ts +1 -0
  25. package/src/OptionX/OptionX.ts +234 -0
  26. package/src/OptionX/index.ts +1 -0
  27. package/src/OrderX/OrderX.ts +35 -0
  28. package/src/OrderX/index.ts +1 -0
  29. package/src/PredicateX/PredicateX.ts +98 -0
  30. package/src/PredicateX/index.ts +1 -0
  31. package/src/PromiseX/PromiseX.ts +32 -0
  32. package/src/PromiseX/index.ts +1 -0
  33. package/src/RecordX/RecordX.ts +478 -0
  34. package/src/RecordX/index.ts +1 -0
  35. package/src/ResultX/ResultX.ts +53 -0
  36. package/src/ResultX/index.ts +1 -0
  37. package/src/SchemaX/SchemaX.ts +324 -0
  38. package/src/SchemaX/index.ts +1 -0
  39. package/src/SetX/SetX.ts +160 -0
  40. package/src/SetX/index.ts +1 -0
  41. package/src/StringX/StringX.ts +97 -0
  42. package/src/StringX/index.ts +1 -0
  43. package/src/StructX/StructX.ts +310 -0
  44. package/src/StructX/index.ts +1 -0
  45. package/src/These/These.ts +1173 -0
  46. package/src/These/index.ts +1 -0
  47. package/src/index.ts +20 -0
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Generic, framework-agnostic extensions to Effect's `Array` module.
3
+ *
4
+ * @since 0.0.0
5
+ */
6
+ import {
7
+ Array,
8
+ Equivalence,
9
+ Option,
10
+ Order,
11
+ Predicate,
12
+ Record,
13
+ pipe,
14
+ } from "effect";
15
+ import { dual, identity } from "effect/Function";
16
+ import { RecordX } from "../RecordX";
17
+ import { These } from "../These";
18
+ import { ResultX } from "../ResultX";
19
+
20
+ /**
21
+ * Returns a shallow copy of `array` between `start` (inclusive) and `end`
22
+ * (exclusive), as a pipeable, dual-form alias for `Array.prototype.slice`.
23
+ *
24
+ * `Array.prototype.slice` is already non-mutating (it returns a shallow copy),
25
+ * but it isn't pipeable. This helper makes it composable inside `pipe(...)`
26
+ * chains alongside the rest of the codebase's Effect-style utilities.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { pipe } from "effect"
31
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
32
+ *
33
+ * // data-first
34
+ * assert.deepStrictEqual(ArrayX.slice([1, 2, 3, 4], 1, 3), [2, 3])
35
+ *
36
+ * // data-last (pipeable)
37
+ * assert.deepStrictEqual(pipe([1, 2, 3, 4], ArrayX.slice(1, 3)), [2, 3])
38
+ * ```
39
+ *
40
+ * @category getters
41
+ * @since 0.0.0
42
+ */
43
+ export const slice = dual<
44
+ <A>(start: number, end: number) => (array: readonly A[]) => A[],
45
+ <A>(array: readonly A[], start: number, end: number) => A[]
46
+ >(3, <A>(array: readonly A[], start: number, end: number): A[] =>
47
+ array.slice(start, end),
48
+ );
49
+
50
+ /**
51
+ * Zips two arrays into one, calling `f` with a `These` for each index so that
52
+ * length mismatches are handled explicitly rather than truncated.
53
+ *
54
+ * Unlike `Array.zipWith` (which stops at the shorter array), this walks to the
55
+ * length of the *longer* array. At each index `f` receives a `These.These<A, B>`:
56
+ * `LeftAndRight` when both arrays have an element, `LeftOnly` when only the
57
+ * first does, and `RightOnly` when only the second does. Use it when the
58
+ * "extra" tail of either array still carries meaning.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import { ArrayX, These } from "@nunofyobiz/effect-extras"
63
+ *
64
+ * const describe = These.match({
65
+ * LeftOnly: ({ left }) => `left ${left}`,
66
+ * RightOnly: ({ right }) => `right ${right}`,
67
+ * LeftAndRight: ({ left, right }) => `both ${left}/${right}`,
68
+ * })
69
+ *
70
+ * assert.deepStrictEqual(ArrayX.zipWithThese([1, 2, 3], [10, 20], describe), [
71
+ * "both 1/10",
72
+ * "both 2/20",
73
+ * "left 3",
74
+ * ])
75
+ * ```
76
+ *
77
+ * @category combinators
78
+ * @since 0.0.0
79
+ */
80
+ export const zipWithThese = dual<
81
+ <A, B, C>(
82
+ f: (ab: These.These<A, B>) => C,
83
+ ) => (array1: readonly A[], array2: readonly B[]) => C[],
84
+ <A, B, C>(
85
+ array1: readonly A[],
86
+ array2: readonly B[],
87
+ f: (ab: These.These<A, B>) => C,
88
+ ) => C[]
89
+ >(
90
+ 3,
91
+ <A, B, C>(
92
+ array1: readonly A[],
93
+ array2: readonly B[],
94
+ f: (ab: These.These<A, B>) => C,
95
+ ): C[] => {
96
+ const newLength = Math.max(array1.length, array2.length);
97
+
98
+ if (newLength === 0) {
99
+ return [];
100
+ }
101
+
102
+ return Array.makeBy(newLength, (index) => {
103
+ if (index < array1.length && index < array2.length) {
104
+ return f(
105
+ These.LeftAndRight({
106
+ left: array1[index],
107
+ right: array2[index],
108
+ }),
109
+ );
110
+ }
111
+
112
+ if (index < array1.length) {
113
+ return f(
114
+ These.LeftOnly({
115
+ left: array1[index],
116
+ }),
117
+ );
118
+ }
119
+
120
+ if (index < array2.length) {
121
+ return f(
122
+ These.RightOnly({
123
+ right: array2[index],
124
+ }),
125
+ );
126
+ }
127
+
128
+ throw new Error(`Index ${index} is out of bounds for array1 and array2`);
129
+ });
130
+ },
131
+ );
132
+
133
+ /**
134
+ * Moves a unique item within an array to a new position, using a custom identification function.
135
+ *
136
+ * **Assumption**: Items should be unique in the array based on the identification function.
137
+ *
138
+ * **Happy case**: If the source item is found exactly once and the destination reference item is found (or null, to move to the end):
139
+ * The source item is moved from its current position to the new position
140
+ *
141
+ * **Source item not found**: The array is returned unchanged, regardless of whether the destination reference item exists.
142
+ *
143
+ * **Source item found but duplicated**:
144
+ * - If destination reference item is found: All copies of the source item are removed, then a single copy is inserted before the destination reference item
145
+ * - If destination reference item is not found: The array is returned completely unchanged (no items are moved or removed)
146
+ *
147
+ * Used internally by {@link insertUniq}; not exported as the codebase has no
148
+ * direct callers.
149
+ */
150
+ const moveUniqWith = dual<
151
+ <A, I extends string | number>(config: {
152
+ identify: (item: A) => I;
153
+ sourceId: I;
154
+ moveToBeLeftOfId: I | null;
155
+ }) => (array: readonly A[] | A[]) => A[],
156
+ <A, I extends string | number>(
157
+ array: readonly A[] | A[],
158
+ config: {
159
+ identify: (item: A) => I;
160
+ sourceId: I;
161
+ moveToBeLeftOfId: I | null;
162
+ },
163
+ ) => A[]
164
+ >(
165
+ 2,
166
+ <A, I extends string | number>(
167
+ inputArray: readonly A[] | A[],
168
+ {
169
+ identify,
170
+ sourceId,
171
+ moveToBeLeftOfId,
172
+ }: {
173
+ identify: (item: A) => I;
174
+ sourceId: I;
175
+ moveToBeLeftOfId: I | null;
176
+ },
177
+ ): A[] => {
178
+ const array: A[] = [...inputArray];
179
+
180
+ // Find the source item and its index
181
+ const sourceIndex = array.findIndex((item) => identify(item) === sourceId);
182
+ if (sourceIndex < 0) {
183
+ return array;
184
+ }
185
+
186
+ const sourceItem = array[sourceIndex];
187
+
188
+ // Remove ALL occurrences of the source item from the array
189
+ const arrayWithoutSource = array.filter(
190
+ (item) => identify(item) !== sourceId,
191
+ );
192
+
193
+ // If moveToBeLeftOfId is null, move to end
194
+ if (moveToBeLeftOfId === null) {
195
+ return [...arrayWithoutSource, sourceItem];
196
+ }
197
+
198
+ // Find the destination index in the array without the source item
199
+ const destinationIndex = arrayWithoutSource.findIndex(
200
+ (item) => identify(item) === moveToBeLeftOfId,
201
+ );
202
+ if (destinationIndex < 0) {
203
+ // If destination not found, leave array completely unchanged
204
+ return array;
205
+ }
206
+
207
+ // Insert the source item before the destination index
208
+ return [
209
+ ...slice(arrayWithoutSource, 0, destinationIndex),
210
+ sourceItem,
211
+ ...slice(arrayWithoutSource, destinationIndex, arrayWithoutSource.length),
212
+ ];
213
+ },
214
+ );
215
+
216
+ /**
217
+ * Inserts or moves a unique item in an array at a specified position.
218
+ *
219
+ * **Assumption**: Items should be unique in the array based on standard equality.
220
+ *
221
+ * **Happy case**: If the item doesn't exist in the array and the destination reference item is found:
222
+ * The new item is inserted before the destination reference item
223
+ *
224
+ * **Item not found in array**:
225
+ * - If destination reference item is found: The new item is inserted before the destination reference item
226
+ * - If destination reference item is not found: The new item is inserted at the end of the array
227
+ *
228
+ * **Item found but duplicated**:
229
+ * - If destination reference item is found: All existing copies are removed, then a single copy is inserted before the destination reference item
230
+ * - If destination reference item is not found: All existing copies are removed, then a single copy is inserted at the end of the array
231
+ *
232
+ * @param array - The input array to modify
233
+ * @param config - Configuration object containing:
234
+ * - `item`: The item to insert or update (must be a string or number)
235
+ * - `insertToBeLeftOf`: The item to position the new/updated item before,
236
+ * or null to insert at the end
237
+ *
238
+ * @returns A new array with the item inserted or moved to the specified position
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
243
+ *
244
+ * // Move an existing item to sit just before "c"
245
+ * assert.deepStrictEqual(
246
+ * ArrayX.insertUniq(["a", "b", "c", "d"], { item: "a", insertToBeLeftOf: "c" }),
247
+ * ["b", "a", "c", "d"],
248
+ * )
249
+ *
250
+ * // Insert a brand-new item; unknown destination falls through to the end
251
+ * assert.deepStrictEqual(
252
+ * ArrayX.insertUniq(["a", "b"], { item: "new", insertToBeLeftOf: null }),
253
+ * ["a", "b", "new"],
254
+ * )
255
+ * ```
256
+ *
257
+ * @category combinators
258
+ * @since 0.0.0
259
+ */
260
+ export const insertUniq = dual<
261
+ <A extends string | number>(config: {
262
+ item: A;
263
+ insertToBeLeftOf: A | null;
264
+ }) => (array: readonly A[] | A[]) => A[],
265
+ <A extends string | number>(
266
+ array: readonly A[] | A[],
267
+ config: {
268
+ item: A;
269
+ insertToBeLeftOf: A | null;
270
+ },
271
+ ) => A[]
272
+ >(
273
+ 2,
274
+ <A extends string | number>(
275
+ array: readonly A[] | A[],
276
+ { item, insertToBeLeftOf }: { item: A; insertToBeLeftOf: A | null },
277
+ ): A[] => {
278
+ // Always deduplicate and append the item to the end for insertUniq
279
+ // This ensures we always have exactly one copy of the item, regardless of destination
280
+ const arrayWithNewItem = pipe(
281
+ array,
282
+ Array.filter((existingItem) => existingItem !== item),
283
+ Array.append(item),
284
+ );
285
+
286
+ // Now move that new item to the desired position
287
+ return moveUniqWith(arrayWithNewItem, {
288
+ identify: identity,
289
+ sourceId: item,
290
+ moveToBeLeftOfId: insertToBeLeftOf,
291
+ });
292
+ },
293
+ );
294
+
295
+ /**
296
+ * Maps over `array` while threading an accumulator, iterating from right to
297
+ * left instead of left to right.
298
+ *
299
+ * Identical to `Array.mapAccum`, except the traversal order is reversed: `f` is
300
+ * called on the last element first, and the resulting array is returned in the
301
+ * original (left-to-right) order. Use it when each element's mapped value
302
+ * depends on state accumulated from the elements that follow it.
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
307
+ *
308
+ * // Running suffix-sum: each slot holds the sum of itself and everything after it
309
+ * assert.deepStrictEqual(
310
+ * ArrayX.mapRightAccum([1, 2, 3], 0, (total, n) => [total + n, total + n]),
311
+ * [6, [6, 5, 3]],
312
+ * )
313
+ * ```
314
+ *
315
+ * @category folding
316
+ * @since 0.0.0
317
+ */
318
+ export const mapRightAccum = dual<
319
+ <A, B, C>(
320
+ initialAccumulator: C,
321
+ f: (accumulator: C, a: A, index: number) => [C, B],
322
+ ) => (array: A[]) => [C, B[]],
323
+ <A, B, C>(
324
+ array: A[],
325
+ initialAccumulator: C,
326
+ f: (accumulator: C, a: A, index: number) => [C, B],
327
+ ) => [C, B[]]
328
+ >(
329
+ 3,
330
+ <A, B, C>(
331
+ array: A[],
332
+ initialAccumulator: C,
333
+ f: (accumulator: C, a: A, index: number) => [C, B],
334
+ ): [C, B[]] => {
335
+ const [accumulator, result] = pipe(
336
+ array,
337
+ Array.reverse,
338
+ Array.mapAccum(initialAccumulator, f),
339
+ );
340
+ return [accumulator, Array.reverse(result)];
341
+ },
342
+ );
343
+
344
+ /**
345
+ * Returns the maximum element of `array` according to `order`, wrapped in an
346
+ * `Option` so that empty arrays are handled safely.
347
+ *
348
+ * Effect's `Array.max` throws on an empty array; this returns `Option.none()`
349
+ * instead, and `Option.some(max)` otherwise. Reach for it whenever the input
350
+ * array might be empty.
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * import { Option, Order, pipe } from "effect"
355
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
356
+ *
357
+ * assert.deepStrictEqual(
358
+ * pipe([3, 7, 2], ArrayX.maxOption(Order.Number)),
359
+ * Option.some(7),
360
+ * )
361
+ * assert.deepStrictEqual(
362
+ * pipe([], ArrayX.maxOption(Order.Number)),
363
+ * Option.none(),
364
+ * )
365
+ * ```
366
+ *
367
+ * @category getters
368
+ * @since 0.0.0
369
+ */
370
+ export const maxOption = dual<
371
+ <A>(order: Order.Order<A>) => (array: A[]) => Option.Option<A>,
372
+ <A>(array: A[], order: Order.Order<A>) => Option.Option<A>
373
+ >(
374
+ 2,
375
+ <A>(array: A[], order: Order.Order<A>): Option.Option<A> =>
376
+ pipe(
377
+ // If the array is empty, there is no max
378
+ array,
379
+ Option.liftPredicate(Array.isArrayNonEmpty),
380
+
381
+ // If it is non-empty, get the max
382
+ Option.map(Array.max(order)),
383
+ ),
384
+ );
385
+
386
+ const takeFirstOrLastWhere = dual<
387
+ <A, B extends A>(
388
+ predicate: Predicate.Refinement<A, B>,
389
+ takeOne: (array: Array.NonEmptyReadonlyArray<B>) => B,
390
+ ) => (array: A[]) => Option.Option<B>,
391
+ <A, B extends A>(
392
+ array: A[],
393
+ predicate: Predicate.Refinement<A, B>,
394
+ takeOne: (array: Array.NonEmptyReadonlyArray<B>) => B,
395
+ ) => Option.Option<B>
396
+ >(
397
+ 3,
398
+ <A, B extends A>(
399
+ array: A[],
400
+ predicate: Predicate.Refinement<A, B>,
401
+ takeOne: (array: Array.NonEmptyReadonlyArray<B>) => B,
402
+ ): Option.Option<B> =>
403
+ pipe(
404
+ // Keep only the items that match
405
+ array,
406
+ Array.filter(predicate),
407
+
408
+ // If there is anything left, take one
409
+ Option.liftPredicate(Array.isArrayNonEmpty),
410
+ Option.map(takeOne),
411
+ ),
412
+ );
413
+
414
+ /**
415
+ * Returns the smallest element of `array` (per `order`) that matches
416
+ * `predicate`, narrowed to the refined type `B`, or `Option.none()` if none
417
+ * match.
418
+ *
419
+ * Combines a refinement filter with `Array.min`: only elements satisfying
420
+ * `predicate` are considered, and the minimum of those (by `order`) is
421
+ * returned. The refinement narrows the element type, so the resulting `Option`
422
+ * carries the more specific `B`.
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * import { Option, Order, pipe } from "effect"
427
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
428
+ *
429
+ * const isEven = (n: number): n is number => n % 2 === 0
430
+ *
431
+ * assert.deepStrictEqual(
432
+ * pipe([3, 4, 1, 2, 5], ArrayX.takeFirstWhere(isEven, Order.Number)),
433
+ * Option.some(2),
434
+ * )
435
+ * assert.deepStrictEqual(
436
+ * pipe([1, 3, 5], ArrayX.takeFirstWhere(isEven, Order.Number)),
437
+ * Option.none(),
438
+ * )
439
+ * ```
440
+ *
441
+ * @category getters
442
+ * @since 0.0.0
443
+ */
444
+ export const takeFirstWhere = dual<
445
+ <A, B extends A>(
446
+ predicate: Predicate.Refinement<A, B>,
447
+ order: Order.Order<B>,
448
+ ) => (array: A[]) => Option.Option<B>,
449
+ <A, B extends A>(
450
+ array: A[],
451
+ predicate: Predicate.Refinement<A, B>,
452
+ order: Order.Order<B>,
453
+ ) => Option.Option<B>
454
+ >(
455
+ 3,
456
+ <A, B extends A>(
457
+ array: A[],
458
+ predicate: Predicate.Refinement<A, B>,
459
+ order: Order.Order<B>,
460
+ ): Option.Option<B> =>
461
+ takeFirstOrLastWhere(array, predicate, Array.min(order)),
462
+ );
463
+
464
+ /**
465
+ * Returns the largest element of `array` (per `order`) that matches
466
+ * `predicate`, narrowed to the refined type `B`, or `Option.none()` if none
467
+ * match.
468
+ *
469
+ * The mirror of {@link takeFirstWhere}: only elements satisfying `predicate`
470
+ * are considered, and the maximum of those (by `order`) is returned. The
471
+ * refinement narrows the element type, so the resulting `Option` carries the
472
+ * more specific `B`.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * import { Option, Order, pipe } from "effect"
477
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
478
+ *
479
+ * const isEven = (n: number): n is number => n % 2 === 0
480
+ *
481
+ * assert.deepStrictEqual(
482
+ * pipe([3, 4, 1, 2, 5], ArrayX.takeLastWhere(isEven, Order.Number)),
483
+ * Option.some(4),
484
+ * )
485
+ * assert.deepStrictEqual(
486
+ * pipe([1, 3, 5], ArrayX.takeLastWhere(isEven, Order.Number)),
487
+ * Option.none(),
488
+ * )
489
+ * ```
490
+ *
491
+ * @category getters
492
+ * @since 0.0.0
493
+ */
494
+ export const takeLastWhere = dual<
495
+ <A, B extends A>(
496
+ predicate: Predicate.Refinement<A, B>,
497
+ order: Order.Order<B>,
498
+ ) => (array: A[]) => Option.Option<B>,
499
+ <A, B extends A>(
500
+ array: A[],
501
+ predicate: Predicate.Refinement<A, B>,
502
+ order: Order.Order<B>,
503
+ ) => Option.Option<B>
504
+ >(
505
+ 3,
506
+ <A, B extends A>(
507
+ array: A[],
508
+ predicate: Predicate.Refinement<A, B>,
509
+ order: Order.Order<B>,
510
+ ): Option.Option<B> =>
511
+ takeFirstOrLastWhere(array, predicate, Array.max(order)),
512
+ );
513
+
514
+ /**
515
+ * Groups `items` into a partial record keyed by the category each item maps to
516
+ * via `categorize`.
517
+ *
518
+ * Each item is appended to the array under its category, preserving input
519
+ * order. The result is `Partial<Record<C, A[]>>` because not every possible
520
+ * category `C` is guaranteed to appear — only categories that received at least
521
+ * one item are present.
522
+ *
523
+ * @example
524
+ * ```ts
525
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
526
+ *
527
+ * const parity = (n: number) => (n % 2 === 0 ? "even" : "odd")
528
+ *
529
+ * assert.deepStrictEqual(ArrayX.categorize([1, 2, 3, 4], parity), {
530
+ * odd: [1, 3],
531
+ * even: [2, 4],
532
+ * })
533
+ * ```
534
+ *
535
+ * @category folding
536
+ * @since 0.0.0
537
+ */
538
+ export const categorize = <A, C extends string>(
539
+ items: Iterable<A>,
540
+ categorize: (a: A) => C,
541
+ ): Partial<Record<C, A[]>> =>
542
+ Array.reduce(
543
+ items,
544
+
545
+ // Start with an empty record of categorized items. `Record.empty()`
546
+ // returns a `NonLiteralKey<C>`-keyed record, which is structurally
547
+ // equivalent to `Partial<Record<C, A[]>>`; the cast tells TypeScript
548
+ // we'll be writing typed keys back via the reducer below.
549
+ Record.empty<C, A[]>() as Record<C, A[]>,
550
+
551
+ // For each item, add it to the appropriate category
552
+ (categorizedItems, item: A) =>
553
+ RecordX.upsert(
554
+ categorizedItems,
555
+ categorize(item), // This is the next item's category
556
+ Option.match({
557
+ // This is the first item in this category, so create a new array
558
+ onNone: () => Array.of(item),
559
+
560
+ // Append the item to the existing array
561
+ onSome: Array.append(item),
562
+ }),
563
+ ),
564
+ );
565
+
566
+ /**
567
+ * Removes all `null` and `undefined` elements from `array`, narrowing the
568
+ * element type to `NonNullable<A>`.
569
+ *
570
+ * Falsy-but-present values such as `0` and `""` are kept — only nullish values
571
+ * are dropped. Use it to clean up an array of optionals into a dense array of
572
+ * known-present values.
573
+ *
574
+ * @example
575
+ * ```ts
576
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
577
+ *
578
+ * assert.deepStrictEqual(
579
+ * ArrayX.compactNullable([1, null, 2, undefined, 0, ""]),
580
+ * [1, 2, 0, ""],
581
+ * )
582
+ * ```
583
+ *
584
+ * @category filtering
585
+ * @since 0.0.0
586
+ */
587
+ export const compactNullable = <A>(array: A[]): NonNullable<A>[] =>
588
+ Array.filter(array, Predicate.isNotNullish);
589
+
590
+ /**
591
+ * Drops the leading elements of `array` until `predicate` first holds, keeping
592
+ * everything from the first match onward.
593
+ *
594
+ * The first matching element and all subsequent elements are retained
595
+ * regardless of whether they match — only the prefix *before* the first match
596
+ * is trimmed. If nothing matches, returns an empty array.
597
+ *
598
+ * @example
599
+ * ```ts
600
+ * import { Predicate } from "effect"
601
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
602
+ *
603
+ * // Trims the leading strings, then keeps everything (including the trailing "b")
604
+ * assert.deepStrictEqual(
605
+ * ArrayX.filterHead(["a", 1, 2, "b"], Predicate.isNumber),
606
+ * [1, 2, "b"],
607
+ * )
608
+ * ```
609
+ *
610
+ * @category filtering
611
+ * @since 0.0.0
612
+ */
613
+ export const filterHead = dual<
614
+ <A>(predicate: Predicate.Predicate<A>) => (array: A[]) => A[],
615
+ <A>(array: A[], predicate: Predicate.Predicate<A>) => A[]
616
+ >(2, <A>(array: A[], predicate: Predicate.Predicate<A>): A[] => {
617
+ const firstMatchingIndex = Array.findFirstIndex(array, predicate);
618
+ return Option.match(firstMatchingIndex, {
619
+ onSome: (index) => slice(array, index, array.length),
620
+ onNone: () => [],
621
+ });
622
+ });
623
+
624
+ /**
625
+ * Drops the trailing elements of `array` after `predicate` last holds, keeping
626
+ * everything up to and including the last match.
627
+ *
628
+ * The mirror of {@link filterHead}: the last matching element and all preceding
629
+ * elements are retained regardless of whether they match — only the suffix
630
+ * *after* the last match is trimmed. If nothing matches, returns an empty
631
+ * array.
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * import { Predicate } from "effect"
636
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
637
+ *
638
+ * // Keeps the leading "a" and trims the trailing strings after the last number
639
+ * assert.deepStrictEqual(
640
+ * ArrayX.filterTail(["a", 1, 2, "b"], Predicate.isNumber),
641
+ * ["a", 1, 2],
642
+ * )
643
+ * ```
644
+ *
645
+ * @category filtering
646
+ * @since 0.0.0
647
+ */
648
+ export const filterTail = dual<
649
+ <A>(predicate: Predicate.Predicate<A>) => (array: A[]) => A[],
650
+ <A>(array: A[], predicate: Predicate.Predicate<A>) => A[]
651
+ >(2, <A>(array: A[], predicate: Predicate.Predicate<A>): A[] => {
652
+ const lastMatchingIndex = Array.findLastIndex(array, predicate);
653
+ return Option.match(lastMatchingIndex, {
654
+ onSome: (index) => slice(array, 0, index + 1),
655
+ onNone: () => [],
656
+ });
657
+ });
658
+
659
+ /**
660
+ * Maps `f` over `array` and drops every result that is `null` or `undefined`,
661
+ * narrowing the element type to `NonNullable<B>`.
662
+ *
663
+ * A nullable-friendly `Array.filterMap`: where `filterMap` expects `f` to
664
+ * return an `Option`, this accepts a function returning `B | null` (or
665
+ * `undefined`) and treats nullish results as "skip this element". Falsy-but-
666
+ * present values such as `0` and `""` are kept.
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
671
+ *
672
+ * // Keep only the even numbers, mapped to their halves
673
+ * assert.deepStrictEqual(
674
+ * ArrayX.filterMapNullable([1, 2, 3, 4], (n) => (n % 2 === 0 ? n / 2 : null)),
675
+ * [1, 2],
676
+ * )
677
+ * ```
678
+ *
679
+ * @category filtering
680
+ * @since 0.0.0
681
+ */
682
+ export const filterMapNullable = dual<
683
+ <A, B>(f: (a: A) => B | null) => (array: A[]) => NonNullable<B>[],
684
+ <A, B>(array: A[], f: (a: A) => B | null) => NonNullable<B>[]
685
+ >(2, <A, B>(array: A[], f: (a: A) => B | null): NonNullable<B>[] =>
686
+ pipe(
687
+ array,
688
+ Array.filterMap((value) =>
689
+ pipe(f(value), Option.fromNullishOr, ResultX.fromOption),
690
+ ),
691
+ ),
692
+ );
693
+
694
+ /**
695
+ * Finds the first element of a 2-dimensional array (row-major order) matching
696
+ * `predicate`, returning it alongside its row and column indices.
697
+ *
698
+ * Scans rows top-to-bottom and, within each row, left-to-right. On a match
699
+ * returns `Option.some([value, rowIndex, columnIndex])`; if no element matches
700
+ * (or the grid is empty), returns `Option.none()`.
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * import { Option } from "effect"
705
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
706
+ *
707
+ * const grid = [
708
+ * ["A", "B", "C"],
709
+ * ["D", "E", "F"],
710
+ * ]
711
+ *
712
+ * assert.deepStrictEqual(
713
+ * ArrayX.findFirstWithIndex2d(grid, (cell) => cell === "E"),
714
+ * Option.some(["E", 1, 1]),
715
+ * )
716
+ * ```
717
+ *
718
+ * @category getters
719
+ * @since 0.0.0
720
+ */
721
+ export const findFirstWithIndex2d = dual<
722
+ <A>(
723
+ predicate: Predicate.Predicate<A>,
724
+ ) => (array: A[][]) => Option.Option<[A, number, number]>,
725
+ <A>(
726
+ array: A[][],
727
+ predicate: Predicate.Predicate<A>,
728
+ ) => Option.Option<[A, number, number]>
729
+ >(
730
+ 2,
731
+ <A>(
732
+ array: A[][],
733
+ predicate: Predicate.Predicate<A>,
734
+ ): Option.Option<[A, number, number]> =>
735
+ Array.findFirstWithIndex(array, (row) =>
736
+ Array.findFirstWithIndex(row, predicate),
737
+ ).pipe(
738
+ Option.map(([[value, secondIndex], firstIndex]) => [
739
+ value,
740
+ firstIndex,
741
+ secondIndex,
742
+ ]),
743
+ ),
744
+ );
745
+
746
+ /**
747
+ * Splits `array` into runs of consecutive elements that share the same group
748
+ * value, where the group is derived by `chunk` and compared with the provided
749
+ * `Equivalence`.
750
+ *
751
+ * Only *adjacent* elements are grouped: a new run starts every time the group
752
+ * value changes from the previous element. Each entry in the result carries the
753
+ * `group` value and the non-empty array of `values` that produced it, preserving
754
+ * input order. An empty input yields an empty array. Use it for run-length-style
755
+ * segmentation; reach for `Array.groupBy` instead when you want all elements
756
+ * with the same key collapsed regardless of position.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * import { Equivalence } from "effect"
761
+ * import { ArrayX } from "@nunofyobiz/effect-extras"
762
+ *
763
+ * // Group adjacent numbers by parity
764
+ * assert.deepStrictEqual(
765
+ * ArrayX.chunkBy([2, 4, 1, 3, 6], (n) => n % 2 === 0, Equivalence.Boolean),
766
+ * [
767
+ * { group: true, values: [2, 4] },
768
+ * { group: false, values: [1, 3] },
769
+ * { group: true, values: [6] },
770
+ * ],
771
+ * )
772
+ * ```
773
+ *
774
+ * @category folding
775
+ * @since 0.0.0
776
+ */
777
+ export const chunkBy = dual<
778
+ <A, B>(
779
+ chunk: (a: A) => B,
780
+ GroupEquivalence: Equivalence.Equivalence<B>,
781
+ ) => (array: A[]) => { group: B; values: Array.NonEmptyArray<A> }[],
782
+ <A, B>(
783
+ array: A[],
784
+ chunk: (a: A) => B,
785
+ GroupEquivalence: Equivalence.Equivalence<B>,
786
+ ) => { group: B; values: Array.NonEmptyArray<A> }[]
787
+ >(
788
+ 3,
789
+ <A, B>(
790
+ array: A[],
791
+ chunk: (a: A) => B,
792
+ chunkEquals: Equivalence.Equivalence<B>,
793
+ ): { group: B; values: Array.NonEmptyArray<A> }[] => {
794
+ if (array.length === 0) {
795
+ return [];
796
+ }
797
+
798
+ const result: { group: B; values: Array.NonEmptyArray<A> }[] = [];
799
+
800
+ for (const item of array) {
801
+ const groupValue = chunk(item);
802
+
803
+ if (result.length > 0) {
804
+ const lastGroup = result.at(-1);
805
+ if (lastGroup && chunkEquals(lastGroup.group, groupValue)) {
806
+ // Add to current group
807
+ lastGroup.values.push(item);
808
+ continue;
809
+ }
810
+ }
811
+
812
+ // Start a new group
813
+ result.push({ group: groupValue, values: Array.of(item) });
814
+ }
815
+
816
+ return result;
817
+ },
818
+ );