@nunofyobiz/effect-extras 2.1.0 → 3.1.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 (46) hide show
  1. package/README.md +2 -1
  2. package/dist/ArrayX.d.ts +0 -34
  3. package/dist/ArrayX.d.ts.map +1 -1
  4. package/dist/ArrayX.js +4 -58
  5. package/dist/ArrayX.js.map +1 -1
  6. package/dist/InclusiveOr.d.ts +1123 -0
  7. package/dist/InclusiveOr.d.ts.map +1 -0
  8. package/dist/InclusiveOr.js +1074 -0
  9. package/dist/InclusiveOr.js.map +1 -0
  10. package/dist/NonNullableX.d.ts.map +1 -1
  11. package/dist/NonNullableX.js +5 -0
  12. package/dist/NonNullableX.js.map +1 -1
  13. package/dist/OptionX.d.ts +8 -2
  14. package/dist/OptionX.d.ts.map +1 -1
  15. package/dist/OptionX.js +7 -1
  16. package/dist/OptionX.js.map +1 -1
  17. package/dist/PredicateX.d.ts +32 -0
  18. package/dist/PredicateX.d.ts.map +1 -1
  19. package/dist/PredicateX.js +38 -0
  20. package/dist/PredicateX.js.map +1 -1
  21. package/dist/RecordX.d.ts +128 -1
  22. package/dist/RecordX.d.ts.map +1 -1
  23. package/dist/RecordX.js +162 -1
  24. package/dist/RecordX.js.map +1 -1
  25. package/dist/StringX.d.ts +61 -0
  26. package/dist/StringX.d.ts.map +1 -1
  27. package/dist/StringX.js +68 -0
  28. package/dist/StringX.js.map +1 -1
  29. package/dist/WarnResult.d.ts +115 -70
  30. package/dist/WarnResult.d.ts.map +1 -1
  31. package/dist/WarnResult.js +141 -210
  32. package/dist/WarnResult.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/ArrayX.ts +4 -86
  39. package/src/InclusiveOr.ts +1255 -0
  40. package/src/NonNullableX.ts +5 -0
  41. package/src/OptionX.ts +8 -2
  42. package/src/PredicateX.ts +41 -0
  43. package/src/RecordX.ts +183 -1
  44. package/src/StringX.ts +113 -0
  45. package/src/WarnResult.ts +297 -227
  46. package/src/index.ts +1 -0
@@ -162,10 +162,15 @@ export const map = dual<
162
162
  return map(a);
163
163
  }
164
164
 
165
+ // Every value is either nullish or not, so once the check above has failed
166
+ // `isNullish(a)` is always true — its false branch (and the defensive throw
167
+ // below) is unreachable.
168
+ /* v8 ignore next */
165
169
  if (Predicate.isNullish(a)) {
166
170
  return a;
167
171
  }
168
172
 
173
+ /* v8 ignore next */
169
174
  throw new Error(`Value is neither nullable nor non-nullable: ${String(a)}`);
170
175
  },
171
176
  );
package/src/OptionX.ts CHANGED
@@ -15,7 +15,7 @@ import { dual } from "effect/Function";
15
15
  *
16
16
  * @example
17
17
  * ```ts
18
- * import { Option } from "effect"
18
+ * import { Option, pipe } from "effect"
19
19
  * import { OptionX } from "@nunofyobiz/effect-extras"
20
20
  *
21
21
  * // Both Some — succeeds with the pair
@@ -29,13 +29,19 @@ import { dual } from "effect/Function";
29
29
  * OptionX.tupleOf(Option.some(1), Option.none()),
30
30
  * Option.none(),
31
31
  * )
32
+ *
33
+ * // Data-last (piped): the piped Option fills the first tuple slot
34
+ * assert.deepStrictEqual(
35
+ * pipe(Option.some(1), OptionX.tupleOf(Option.some("a"))),
36
+ * Option.some([1, "a"]),
37
+ * )
32
38
  * ```
33
39
  *
34
40
  * @category combinators
35
41
  * @since 0.0.0
36
42
  */
37
43
  export const tupleOf = dual<
38
- <A>(a: Option.Option<A>) => <B>(b: Option.Option<B>) => Option.Option<[A, B]>,
44
+ <B>(b: Option.Option<B>) => <A>(a: Option.Option<A>) => Option.Option<[A, B]>,
39
45
  <A, B>(a: Option.Option<A>, b: Option.Option<B>) => Option.Option<[A, B]>
40
46
  >(
41
47
  2,
package/src/PredicateX.ts CHANGED
@@ -96,3 +96,44 @@ export function isNonEmptyString(value: unknown): value is string {
96
96
  String.isNonEmpty(value)
97
97
  );
98
98
  }
99
+
100
+ /**
101
+ * Refines an `unknown` value to a plain `Record<string, unknown>` — `true` only
102
+ * for a non-null, non-array object whose prototype is `Object.prototype` (an
103
+ * object literal) or `null` (an `Object.create(null)` map).
104
+ *
105
+ * Effect ships no `Predicate.isRecord`. `Predicate.isObject` is the closest, but
106
+ * it also accepts `Map`, `Set`, `Date`, `RegExp`, and class instances. This guard
107
+ * adds a prototype check to rule those out, narrowing to `Record<string, unknown>`
108
+ * so the JSON-tree helpers (`RecordX.deepMerge`, `RecordX.canonicalize`,
109
+ * `RecordX.deleteByPath`) can compose without an `as` cast.
110
+ *
111
+ * It is named **`unsafe`** because the narrowing comes purely from the value's
112
+ * runtime shape — it does *not* validate the key or value types. A symbol-keyed
113
+ * entry still passes despite the `string` key claim, and values are asserted
114
+ * `unknown` without any check. Treat it as a structural convenience, not a
115
+ * `Schema` validation: reach for `Schema` when you need real key/value guarantees.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * import { PredicateX } from "@nunofyobiz/effect-extras"
120
+ *
121
+ * assert.deepStrictEqual(PredicateX.unsafeIsRecord({ a: 1 }), true)
122
+ * assert.deepStrictEqual(PredicateX.unsafeIsRecord(Object.create(null)), true)
123
+ * assert.deepStrictEqual(PredicateX.unsafeIsRecord([1, 2]), false)
124
+ * assert.deepStrictEqual(PredicateX.unsafeIsRecord(new Map()), false)
125
+ * assert.deepStrictEqual(PredicateX.unsafeIsRecord(null), false)
126
+ * ```
127
+ *
128
+ * @category refinements
129
+ * @since 0.0.0
130
+ */
131
+ export function unsafeIsRecord(
132
+ value: unknown,
133
+ ): value is Record<string, unknown> {
134
+ if (!Predicate.isObject(value)) {
135
+ return false;
136
+ }
137
+ const prototype = Object.getPrototypeOf(value);
138
+ return prototype === Object.prototype || prototype === null;
139
+ }
package/src/RecordX.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * @since 0.0.0
5
5
  */
6
- import { Array, Option, Order, Predicate, Record, pipe } from "effect";
6
+ import { Array, Option, Order, Predicate, Record, Reducer, pipe } from "effect";
7
7
  import { dual } from "effect/Function";
8
8
  import * as ArrayX from "./ArrayX.js";
9
+ import * as PredicateX from "./PredicateX.js";
9
10
 
10
11
  /**
11
12
  * Returns `true` when `record` has at least one entry, narrowing it to a
@@ -476,3 +477,184 @@ export const collectBy = dual<
476
477
  Record.set<K, V, K, V>(accumulator, identify(value), value),
477
478
  ),
478
479
  );
480
+
481
+ /**
482
+ * Deep-merges two JSON values, with `b` winning on conflicts.
483
+ *
484
+ * Plain objects (per `PredicateX.unsafeIsRecord`) are merged recursively,
485
+ * key-by-key; every other shape — primitives, arrays, and impostors like
486
+ * `Map`/`Date`/class instances — is replaced wholesale by `b`. If either side
487
+ * isn't a plain object, `b` is returned as-is. Neither input is mutated. Reach
488
+ * for it to layer partial JSON overrides on top of a base.
489
+ *
490
+ * @example
491
+ * ```ts
492
+ * import { RecordX } from "@nunofyobiz/effect-extras"
493
+ * import { pipe } from "effect"
494
+ *
495
+ * // data-first — nested objects merge, b wins on leaf conflicts
496
+ * assert.deepStrictEqual(
497
+ * RecordX.deepMerge({ a: { x: 1 }, b: 2 }, { a: { y: 3 }, c: 4 }),
498
+ * { a: { x: 1, y: 3 }, b: 2, c: 4 },
499
+ * )
500
+ *
501
+ * // arrays are replaced wholesale, not concatenated
502
+ * assert.deepStrictEqual(RecordX.deepMerge({ a: [1, 2] }, { a: [3] }), { a: [3] })
503
+ *
504
+ * // data-last (pipeable) — merges the override onto the piped base
505
+ * assert.deepStrictEqual(pipe({ a: 1 }, RecordX.deepMerge({ b: 2 })), {
506
+ * a: 1,
507
+ * b: 2,
508
+ * })
509
+ * ```
510
+ *
511
+ * @category combining
512
+ * @since 0.0.0
513
+ */
514
+ export const deepMerge = dual<
515
+ (b: unknown) => (a: unknown) => unknown,
516
+ (a: unknown, b: unknown) => unknown
517
+ >(2, (a: unknown, b: unknown): unknown => {
518
+ if (!PredicateX.unsafeIsRecord(a) || !PredicateX.unsafeIsRecord(b)) {
519
+ return b;
520
+ }
521
+ return Record.reduce(b, { ...a }, (accumulator, value, key) => ({
522
+ ...accumulator,
523
+ [key]: key in a ? deepMerge(a[key], value) : value,
524
+ }));
525
+ });
526
+
527
+ /**
528
+ * {@link deepMerge} as a `Reducer` (monoid) with identity `{}`.
529
+ *
530
+ * `deepMergeReducer.combineAll(layers)` folds an iterable of object layers
531
+ * left-to-right via {@link deepMerge} — the universal "merge N JSON objects into
532
+ * one" fold, replacing a hand-rolled `Array.reduce(layers, {}, deepMerge)`. The
533
+ * `{}` identity is exact for the object-valued layers these folds carry: an empty
534
+ * list yields `{}`, and a single layer is returned unchanged.
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * import { RecordX } from "@nunofyobiz/effect-extras"
539
+ *
540
+ * assert.deepStrictEqual(
541
+ * RecordX.deepMergeReducer.combineAll([
542
+ * { a: { x: 1 } },
543
+ * { a: { y: 2 }, b: 3 },
544
+ * { b: 4 },
545
+ * ]),
546
+ * { a: { x: 1, y: 2 }, b: 4 },
547
+ * )
548
+ *
549
+ * assert.deepStrictEqual(RecordX.deepMergeReducer.combineAll([]), {})
550
+ * ```
551
+ *
552
+ * @category instances
553
+ * @since 0.0.0
554
+ */
555
+ export const deepMergeReducer: Reducer.Reducer<unknown> = Reducer.make<unknown>(
556
+ deepMerge,
557
+ {},
558
+ );
559
+
560
+ /**
561
+ * Canonicalizes a JSON value by recursively sorting object keys; arrays keep
562
+ * their order.
563
+ *
564
+ * Two structurally-equal values that differ only in key order canonicalize to
565
+ * the same shape, so `JSON.stringify(canonicalize(x))` is a stable structural
566
+ * key for comparison, deduping, or hashing. Plain objects (per
567
+ * `PredicateX.unsafeIsRecord`) have their keys sorted ascending and their values
568
+ * canonicalized recursively; arrays are canonicalized element-wise in place;
569
+ * everything else passes through unchanged.
570
+ *
571
+ * @example
572
+ * ```ts
573
+ * import { RecordX } from "@nunofyobiz/effect-extras"
574
+ *
575
+ * assert.deepStrictEqual(
576
+ * JSON.stringify(RecordX.canonicalize({ b: 1, a: { d: 1, c: 2 } })),
577
+ * JSON.stringify({ a: { c: 2, d: 1 }, b: 1 }),
578
+ * )
579
+ *
580
+ * // arrays keep their order; primitives pass through
581
+ * assert.deepStrictEqual(RecordX.canonicalize([3, 1, 2]), [3, 1, 2])
582
+ * assert.deepStrictEqual(RecordX.canonicalize("x"), "x")
583
+ * ```
584
+ *
585
+ * @category transformations
586
+ * @since 0.0.0
587
+ */
588
+ export const canonicalize = (value: unknown): unknown => {
589
+ if (Array.isArray(value)) {
590
+ return Array.map(value, canonicalize);
591
+ }
592
+ if (!PredicateX.unsafeIsRecord(value)) {
593
+ return value;
594
+ }
595
+ return pipe(
596
+ Record.toEntries(value),
597
+ Array.map(([key, entry]) => [key, canonicalize(entry)] as const),
598
+ Array.sort(
599
+ Order.mapInput(Order.String, ([key]: readonly [string, unknown]) => key),
600
+ ),
601
+ Record.fromEntries,
602
+ );
603
+ };
604
+
605
+ /**
606
+ * Immutably deletes the value at a `path` from a JSON object, pruning any parent
607
+ * objects left empty by the deletion.
608
+ *
609
+ * Walks `path` from the root; when it resolves to an existing key, that key is
610
+ * removed and every ancestor that becomes empty as a result is removed too.
611
+ * Returns `Some(newObject)` when the path existed (so callers can tell something
612
+ * changed), or `None` when it was absent or `object` isn't a plain object. The
613
+ * input is never mutated.
614
+ *
615
+ * @example
616
+ * ```ts
617
+ * import { RecordX } from "@nunofyobiz/effect-extras"
618
+ * import { Option, pipe } from "effect"
619
+ *
620
+ * // deletes the leaf and prunes the now-empty parent
621
+ * assert.deepStrictEqual(
622
+ * RecordX.deleteByPath({ a: { b: 1 } }, ["a", "b"]),
623
+ * Option.some({}),
624
+ * )
625
+ *
626
+ * // a sibling key keeps the parent alive
627
+ * assert.deepStrictEqual(
628
+ * RecordX.deleteByPath({ a: { b: 1, c: 2 } }, ["a", "b"]),
629
+ * Option.some({ a: { c: 2 } }),
630
+ * )
631
+ *
632
+ * // absent path → None; data-last (pipeable) form
633
+ * assert.deepStrictEqual(pipe({ a: 1 }, RecordX.deleteByPath(["b"])), Option.none())
634
+ * ```
635
+ *
636
+ * @category combining
637
+ * @since 0.0.0
638
+ */
639
+ export const deleteByPath = dual<
640
+ (path: readonly string[]) => (object: unknown) => Option.Option<unknown>,
641
+ (object: unknown, path: readonly string[]) => Option.Option<unknown>
642
+ >(2, (object: unknown, path: readonly string[]): Option.Option<unknown> => {
643
+ if (!PredicateX.unsafeIsRecord(object)) {
644
+ return Option.none();
645
+ }
646
+ const [head, ...rest] = path;
647
+ if (head === undefined) {
648
+ return Option.none();
649
+ }
650
+ if (rest.length === 0) {
651
+ return head in object
652
+ ? Option.some(Record.remove(object, head))
653
+ : Option.none();
654
+ }
655
+ return Option.map(deleteByPath(object[head], rest), (newChild) =>
656
+ PredicateX.unsafeIsRecord(newChild) && Record.isEmptyRecord(newChild)
657
+ ? Record.remove(object, head)
658
+ : { ...object, [head]: newChild },
659
+ );
660
+ });
package/src/StringX.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @since 0.0.0
5
5
  */
6
+ import { Array } from "effect";
6
7
  import { dual } from "effect/Function";
7
8
 
8
9
  /**
@@ -95,3 +96,115 @@ export const ensurePrepend = dual<
95
96
 
96
97
  return `${start}${string_}`;
97
98
  });
99
+
100
+ /**
101
+ * Replaces the inclusive line range `[startLine, endLine]` of `content` with
102
+ * `replacement` lines, returning the rejoined string.
103
+ *
104
+ * `content` is split on `\n`; the zero-based lines `startLine` through `endLine`
105
+ * (both inclusive) are dropped and `replacement` is spliced into their place.
106
+ * Pass an empty `replacement` to delete the range. Indices clamp naturally via
107
+ * `Array.take`/`Array.drop`, so out-of-range values don't throw.
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * import { pipe } from "effect"
112
+ * import { StringX } from "@nunofyobiz/effect-extras"
113
+ *
114
+ * // data-first — replace lines 1..2 with a single line
115
+ * assert.deepStrictEqual(
116
+ * StringX.replaceLineRange("a\nb\nc\nd", 1, 2, ["X"]),
117
+ * "a\nX\nd",
118
+ * )
119
+ *
120
+ * // an empty replacement deletes the range
121
+ * assert.deepStrictEqual(StringX.replaceLineRange("a\nb\nc\nd", 1, 2, []), "a\nd")
122
+ *
123
+ * // data-last (piped)
124
+ * assert.deepStrictEqual(
125
+ * pipe("a\nb\nc", StringX.replaceLineRange(1, 1, ["X", "Y"])),
126
+ * "a\nX\nY\nc",
127
+ * )
128
+ * ```
129
+ *
130
+ * @category combinators
131
+ * @since 0.0.0
132
+ */
133
+ export const replaceLineRange = dual<
134
+ (
135
+ startLine: number,
136
+ endLine: number,
137
+ replacement: readonly string[],
138
+ ) => (content: string) => string,
139
+ (
140
+ content: string,
141
+ startLine: number,
142
+ endLine: number,
143
+ replacement: readonly string[],
144
+ ) => string
145
+ >(
146
+ 4,
147
+ (
148
+ content: string,
149
+ startLine: number,
150
+ endLine: number,
151
+ replacement: readonly string[],
152
+ ): string => {
153
+ const lines = content.split("\n");
154
+ return Array.join(
155
+ [
156
+ ...Array.take(lines, startLine),
157
+ ...replacement,
158
+ ...Array.drop(lines, endLine + 1),
159
+ ],
160
+ "\n",
161
+ );
162
+ },
163
+ );
164
+
165
+ /**
166
+ * Inserts `lines` immediately before the line at `anchorIndex`, preserving the
167
+ * anchor line and everything after it; returns the rejoined string.
168
+ *
169
+ * `content` is split on `\n` and `lines` are spliced in just before the
170
+ * zero-based `anchorIndex`. An `anchorIndex` of `0` prepends, and one at or past
171
+ * the end appends.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * import { pipe } from "effect"
176
+ * import { StringX } from "@nunofyobiz/effect-extras"
177
+ *
178
+ * // data-first — insert before line 1, keeping the anchor and the rest
179
+ * assert.deepStrictEqual(StringX.insertBeforeLine("a\nb\nc", 1, ["X"]), "a\nX\nb\nc")
180
+ *
181
+ * // an anchor at the end appends
182
+ * assert.deepStrictEqual(StringX.insertBeforeLine("a\nb", 2, ["X"]), "a\nb\nX")
183
+ *
184
+ * // data-last (piped)
185
+ * assert.deepStrictEqual(pipe("a\nb", StringX.insertBeforeLine(0, ["X"])), "X\na\nb")
186
+ * ```
187
+ *
188
+ * @category combinators
189
+ * @since 0.0.0
190
+ */
191
+ export const insertBeforeLine = dual<
192
+ (
193
+ anchorIndex: number,
194
+ lines: readonly string[],
195
+ ) => (content: string) => string,
196
+ (content: string, anchorIndex: number, lines: readonly string[]) => string
197
+ >(
198
+ 3,
199
+ (content: string, anchorIndex: number, lines: readonly string[]): string => {
200
+ const split = content.split("\n");
201
+ return Array.join(
202
+ [
203
+ ...Array.take(split, anchorIndex),
204
+ ...lines,
205
+ ...Array.drop(split, anchorIndex),
206
+ ],
207
+ "\n",
208
+ );
209
+ },
210
+ );