Repository: remeda/remeda
Pull request #844 (2024-09-02 12:15:43)

feat: add randomBigInt
random `bigint`s need to be generated differently because they overflow the standard Math.random() range.

I had to "polyfill" for node 18, but I didn't want to use `require` to actually use the node module that provides crypto because that requires more changes to our build process. This polyfill is generic and allows any env that doesn't have a global crypto module to work.

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/844/builds/537591) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 7165d3af84869c36b00aa2bed55d7966e2f77c93:

|Sandbox| Source |
|--|--|
|[remeda-example-vanilla](https://codesandbox.io/s/remeda-example-vanilla-2hmhvd)| Configuration |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/844?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`a5a3c97`)](https://app.codecov.io/gh/remeda/remeda/commit/a5a3c9734d1b468a418d0a04132e4b3455b5e852?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`7165d3a`)](https://app.codecov.io/gh/remeda/remeda/commit/7165d3af84869c36b00aa2bed55d7966e2f77c93?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #844   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          311       313    +2     
  Lines        10533     10604   +71     
  Branches      2567      2589   +22     
=========================================
+ Hits         10533     10604   +71     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/844?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).


Patch:
diff --git a/src/index.ts b/src/index.ts
index c5aa285..a10be31 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -98,6 +98,7 @@ export * from "./product";
 export * from "./prop";
 export * from "./pullObject";
 export * from "./purry";
+export * from "./randomBigInt";
 export * from "./randomInteger";
 export * from "./randomString";
 export * from "./range";
diff --git a/src/randomBigInt.ts b/src/randomBigInt.ts
new file mode 100644
index 0000000..d5a40dd
--- /dev/null
+++ b/src/randomBigInt.ts
@@ -0,0 +1,92 @@
+/* eslint-disable no-bitwise --
+ * We randomize the bigint using randomized bitmaps, we have to use bitwise
+ * operations to manipulate them.
+ */
+
+/**
+ * Generate a random `bigint` between `from` and `to` (inclusive).
+ *
+ * ! Important: In most environments this function uses
+ * [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)
+ * under-the-hood which **is** cryptographically strong. When the WebCrypto API
+ * isn't available (Node 18) we fallback to an implementation that uses
+ * [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random)
+ * which is **NOT** cryptographically secure.
+ *
+ * @param from - The minimum value.
+ * @param to - The maximum value.
+ * @returns The random integer.
+ * @signature
+ *   R.randomBigInt(from, to)
+ * @example
+ *   R.randomBigInt(1n, 10n) // => 5n
+ * @dataFirst
+ * @category Number
+ */
+export function randomBigInt(from: bigint, to: bigint): bigint {
+  if (to < from) {
+    throw new RangeError(`randomBigInt: The range [${from},${to}] is empty.`);
+  }
+
+  const range = to - from;
+
+  // Get the max number of bits needed to encode the result. We will generate
+  // this number of random bits. A radix of 2 would give us the binary
+  // representation of the number.
+  const { length: maxBits } = range.toString(2 /* radix */);
+
+  // We can only generate random data in bytes, not bits, so we need to generate
+  // enough bytes to cover the required number of bits.
+  const maxBytes = Math.ceil(maxBits / 8);
+
+  // The number of bits we need to ignore in the random bitmap we generate.
+  const excessBits = BigInt(8 - (maxBits % 8));
+
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition -- The code is more readable like this way than to when using a do..while loop.
+  while (true) {
+    // We use `crypto` for our RNG, it should be supported in all modern
+    // environments we support.
+    const randomBytes = random(maxBytes);
+
+    const raw = asBigInt(randomBytes);
+
+    // Shift the number back to our range, this effectively sets the excess bits
+    // to 0.
+    const result = raw >> excessBits;
+
+    // The generated number might overflow when `range < 2 ** maxBits - 1`, so
+    // it can't be returned. To ensure that all possible results have the same
+    // probability, we ignore the value entirely and try again (instead of
+    // trying to adapt it to the output range somehow).
+    if (result <= range) {
+      return result + from;
+    }
+  }
+}
+
+function asBigInt(bytes: Iterable<number>): bigint {
+  let result = 0n;
+  for (const byte of bytes) {
+    // We build the bigint by shifting the current value by a byte, and putting
+    // the next byte in that "slot".
+    result = (result << 8n) + BigInt(byte);
+  }
+  return result;
+}
+
+function random(numBytes: number): Uint8Array {
+  const output = new Uint8Array(numBytes);
+
+  if (typeof crypto === "undefined") {
+    // Polyfill for environments without `crypto` support. The only env which
+    // requires this and we support is Node 18; once we drop support for it we
+    // can drop the polyfill.
+    for (let index = 0; index < numBytes; index += 1) {
+      output[index] = Math.floor(Math.random() * 256);
+    }
+  } else {
+    crypto.getRandomValues(output);
+  }
+
+  return output;
+}


Test patch:
diff --git a/src/randomBigInt.test-d.ts b/src/randomBigInt.test-d.ts
new file mode 100644
index 0000000..9ac04b2
--- /dev/null
+++ b/src/randomBigInt.test-d.ts
@@ -0,0 +1,6 @@
+import { randomBigInt } from "./randomBigInt";
+
+test("KNOWN LIMITATION: doesn't return never on invalid range", () => {
+  // @ts-expect-error [ts2554] - We can't detect these cases with TypeScript.
+  expectTypeOf(randomBigInt(10n, 0n)).toEqualTypeOf<never>();
+});
diff --git a/src/randomBigInt.test.ts b/src/randomBigInt.test.ts
new file mode 100644
index 0000000..a5258a0
--- /dev/null
+++ b/src/randomBigInt.test.ts
@@ -0,0 +1,119 @@
+import { randomBigInt } from "./randomBigInt";
+
+const ITERATIONS = 10_000;
+
+const HUGE_NUMBER = 9_999_999_999_999_999_999_999n;
+
+test("from is greater than to", () => {
+  expect(() => randomBigInt(10n, 0n)).toThrow(
+    new RangeError("randomBigInt: The range [10,0] is empty."),
+  );
+});
+
+test("non-negative bigints", () => {
+  for (const v of randomBigInts(0n, 10n)) {
+    expect(v).toBeGreaterThanOrEqual(0n);
+    expect(v).toBeLessThanOrEqual(10n);
+  }
+});
+
+test("negative bigints", () => {
+  for (const v of randomBigInts(-10n, -5n)) {
+    expect(v).toBeGreaterThanOrEqual(-10n);
+    expect(v).toBeLessThanOrEqual(-5n);
+  }
+});
+
+test("bigints with same value", () => {
+  for (const v of randomBigInts(10n, 10n)) {
+    expect(v).toBe(10n);
+  }
+});
+
+test("huge bigints", () => {
+  for (const v of randomBigInts(
+    // This is Number.MAX_SAFE_INTEGER + 1
+    9_007_199_254_740_992n,
+    HUGE_NUMBER,
+  )) {
+    expect(v).toBeGreaterThanOrEqual(9_007_199_254_740_992n);
+    expect(v).toBeLessThanOrEqual(HUGE_NUMBER);
+  }
+});
+
+test("tiny ranges with huge numbers", () => {
+  for (const v of randomBigInts(HUGE_NUMBER, HUGE_NUMBER + 1n)) {
+    expect(v).toBeGreaterThanOrEqual(HUGE_NUMBER);
+    expect(v).toBeLessThanOrEqual(HUGE_NUMBER + 1n);
+  }
+});
+
+test("results are varied", () => {
+  const results = new Set<bigint>();
+  for (const v of randomBigInts(1n, 10n)) {
+    results.add(v);
+  }
+  expect(results).toHaveLength(10);
+});
+
+describe("crypto module polyfill", () => {
+  beforeAll(() => {
+    vi.stubGlobal("crypto", undefined);
+  });
+
+  afterAll(() => {
+    vi.unstubAllGlobals();
+  });
+
+  test("non-negative bigints", () => {
+    for (const v of randomBigInts(0n, 10n)) {
+      expect(v).toBeGreaterThanOrEqual(0n);
+      expect(v).toBeLessThanOrEqual(10n);
+    }
+  });
+
+  test("negative bigints", () => {
+    for (const v of randomBigInts(-10n, -5n)) {
+      expect(v).toBeGreaterThanOrEqual(-10n);
+      expect(v).toBeLessThanOrEqual(-5n);
+    }
+  });
+
+  test("bigints with same value", () => {
+    for (const v of randomBigInts(10n, 10n)) {
+      expect(v).toBe(10n);
+    }
+  });
+
+  test("huge bigints", () => {
+    for (const v of randomBigInts(
+      // This is Number.MAX_SAFE_INTEGER + 1
+      9_007_199_254_740_992n,
+      HUGE_NUMBER,
+    )) {
+      expect(v).toBeGreaterThanOrEqual(9_007_199_254_740_992n);
+      expect(v).toBeLessThanOrEqual(HUGE_NUMBER);
+    }
+  });
+
+  test("tiny ranges with huge numbers", () => {
+    for (const v of randomBigInts(HUGE_NUMBER, HUGE_NUMBER + 1n)) {
+      expect(v).toBeGreaterThanOrEqual(HUGE_NUMBER);
+      expect(v).toBeLessThanOrEqual(HUGE_NUMBER + 1n);
+    }
+  });
+
+  test("results are varied", () => {
+    const results = new Set<bigint>();
+    for (const v of randomBigInts(1n, 10n)) {
+      results.add(v);
+    }
+    expect(results).toHaveLength(10);
+  });
+});
+
+function* randomBigInts(from: bigint, to: bigint): Generator<bigint> {
+  for (let i = 0; i < ITERATIONS; i += 1) {
+    yield randomBigInt(from, to);
+  }
+}