Repository: remeda/remeda (ts)

Issue:
Add round
We're hoping to replace lodash and ramda entirely with remeda. There are a few functions remeda doesn't have that I believe it could benefit from.

I'm currently using `round` from lodash and would like to see it added. 

[Lodash docs](https://lodash.com/docs/4.17.15#round)
[Lodash source](https://github.com/lodash/lodash/blob/main/src/round.ts)


Pull request:
feat(round, ceil, floor): Add round, ceil, floor functions
Includes implementation of `round`, `ceil` and `floor` functions. I need these (well, mostly `round` but the other two are trivial to implement once you have one) to migrate off of `lodash`, hence this PR. 

The main difference between these and native functions from the `Math` object is the second argument `precision`. I implemented it in the simplest way possible which I believe is correct for most actual use cases. Still, there are certainly one-off rounding issues caused by floating-point nature of `number` type in the language. 

There's one pretty significant difference from `lodash` - `precision` is limited to values from -15 to 15 as higher precision doesn't really make any sense, for example, `Math.PI` has only 15 decimal places. Similar function implemented in C# native library has a similar validation rule (0 to 15, I believe negative precision is pretty useful in some scenarios), [link to their docs for reference](https://learn.microsoft.com/en-us/dotnet/api/system.math.round?view=net-8.0#system-math-round(system-double-system-int32)).
Happy to discuss your concerns regarding this set of functions. 

Also, closes #462

---

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`
- [x] New methods added to `mapping.md`

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


Patch:
diff --git a/mapping.md b/mapping.md
index e5d87b4..0ac4788 100644
--- a/mapping.md
+++ b/mapping.md
@@ -12,6 +12,7 @@ documentation when migrating._
 | `addProp`           | `set`               | `set`               |
 | `allPass`           |                     | `allPass`           |
 | `anyPass`           |                     | `anyPass`           |
+| `ceil`              | `ceil`              |                     |
 | `chunk`             | `chunk`             |                     |
 | `clamp`             | `clamp`             | `clamp`             |
 | `clone`             | `cloneDeep`         | `clone`             |
@@ -35,6 +36,7 @@ documentation when migrating._
 | `flatMap`           | `flatMap`           | `chain`             |
 | `flatten`           | `flatten`           | `flatten`           |
 | `flattenDeep`       | `flattenDeep`       | `flatten`           |
+| `floor`             | `floor`             |                     |
 | `forEach`           | `forEach`           | `forEach`           |
 | `forEachObj`        | `forEach`           | `forEachObjIndexed` |
 | `groupBy`           | `groupBy`           | `groupBy`           |
@@ -72,6 +74,7 @@ documentation when migrating._
 | `reduce`            | `reduce`            | `reduce`            |
 | `reject`            | `reject`            | `reject`            |
 | `reverse`           | `reverse`           | `reverse`           |
+| `round`             | `round`             |                     |
 | `sample`            | `sampleSize`\*      |                     |
 | `set`               | `set`               | `assoc`             |
 | `setPath`           | `set`               | `assocPath`         |
diff --git a/src/_withPrecision.ts b/src/_withPrecision.ts
new file mode 100644
index 0000000..286be8b
--- /dev/null
+++ b/src/_withPrecision.ts
@@ -0,0 +1,28 @@
+// ECMAScript doesn't support more decimal places than 15 anyways,
+// so supporting higher values doesn't make a lot of sense,
+// Considering Number.MAX_SAFE_INTEGER < 10**16, we can use -15
+// for the negative precision limit, too.
+const MAX_PRECISION = 15;
+
+export function _withPrecision(roundingFn: (value: number) => number) {
+  return (value: number, precision: number) => {
+    if (precision === 0) {
+      return roundingFn(value);
+    }
+
+    if (!Number.isInteger(precision)) {
+      throw new TypeError(`precision must be an integer: ${precision}`);
+    }
+
+    if (precision > MAX_PRECISION || precision < -MAX_PRECISION) {
+      throw new RangeError(`precision must be between -15 and 15`);
+    }
+
+    if (Number.isNaN(value) || !Number.isFinite(value)) {
+      return roundingFn(value);
+    }
+
+    const multiplier = 10 ** precision;
+    return roundingFn(value * multiplier) / multiplier;
+  };
+}
diff --git a/src/ceil.ts b/src/ceil.ts
new file mode 100644
index 0000000..7fc281d
--- /dev/null
+++ b/src/ceil.ts
@@ -0,0 +1,42 @@
+import { _withPrecision } from './_withPrecision';
+import { purry } from './purry';
+
+/**
+ * Rounds up a given number to a specific precision.
+ * If you'd like to round up to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.ceil` instead, as it won't incur the additional library overhead.
+ * @param value The number to round up.
+ * @param precision The precision to round up to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.ceil(value, precision);
+ * @example
+ *    R.ceil(123.9876, 3) // => 123.988
+ *    R.ceil(483.22243, 1) // => 483.3
+ *    R.ceil(8541, -1) // => 8550
+ *    R.ceil(456789, -3) // => 457000
+ * @dataFirst
+ * @category Number
+ */
+export function ceil(value: number, precision: number): number;
+
+/**
+ * Rounds up a given number to a specific precision.
+ * If you'd like to round up to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.ceil` instead, as it won't incur the additional library overhead.
+ * @param value The number to round up.
+ * @param precision The precision to round up to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.ceil(precision)(value);
+ * @example
+ *    R.ceil(3)(123.9876) // => 123.988
+ *    R.ceil(1)(483.22243) // => 483.3
+ *    R.ceil(-1)(8541) // => 8550
+ *    R.ceil(-3)(456789) // => 457000
+ * @dataLast
+ * @category Number
+ */
+export function ceil(precision: number): (value: number) => number;
+
+export function ceil() {
+  return purry(_withPrecision(Math.ceil), arguments);
+}
diff --git a/src/floor.ts b/src/floor.ts
new file mode 100644
index 0000000..e470fc4
--- /dev/null
+++ b/src/floor.ts
@@ -0,0 +1,42 @@
+import { _withPrecision } from './_withPrecision';
+import { purry } from './purry';
+
+/**
+ * Rounds down a given number to a specific precision.
+ * If you'd like to round down to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.floor` instead, as it won't incur the additional library overhead.
+ * @param value The number to round down.
+ * @param precision The precision to round down to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.floor(value, precision);
+ * @example
+ *    R.floor(123.9876, 3) // => 123.987
+ *    R.floor(483.22243, 1) // => 483.2
+ *    R.floor(8541, -1) // => 8540
+ *    R.floor(456789, -3) // => 456000
+ * @dataFirst
+ * @category Number
+ */
+export function floor(value: number, precision: number): number;
+
+/**
+ * Rounds down a given number to a specific precision.
+ * If you'd like to round down to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.floor` instead, as it won't incur the additional library overhead.
+ * @param value The number to round down.
+ * @param precision The precision to round down to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.floor(precision)(value);
+ * @example
+ *    R.floor(3)(123.9876) // => 123.987
+ *    R.floor(1)(483.22243) // => 483.2
+ *    R.floor(-1)(8541) // => 8540
+ *    R.floor(-3)(456789) // => 456000
+ * @dataLast
+ * @category Number
+ */
+export function floor(precision: number): (value: number) => number;
+
+export function floor() {
+  return purry(_withPrecision(Math.floor), arguments);
+}
diff --git a/src/index.ts b/src/index.ts
index a524e50..7925fd1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@ export * from './add';
 export * from './addProp';
 export * from './allPass';
 export * from './anyPass';
+export * from './ceil';
 export * from './chunk';
 export * from './clamp';
 export * from './clone';
@@ -28,6 +29,7 @@ export * from './flatMap';
 export * from './flatMapToObj';
 export * from './flatten';
 export * from './flattenDeep';
+export * from './floor';
 export * from './forEach';
 export * from './forEachObj';
 export * from './fromPairs';
@@ -85,6 +87,7 @@ export * from './range';
 export * from './reduce';
 export * from './reject';
 export * from './reverse';
+export * from './round';
 export * from './sample';
 export * from './set';
 export * from './setPath';
diff --git a/src/round.ts b/src/round.ts
new file mode 100644
index 0000000..fbf269f
--- /dev/null
+++ b/src/round.ts
@@ -0,0 +1,42 @@
+import { _withPrecision } from './_withPrecision';
+import { purry } from './purry';
+
+/**
+ * Rounds a given number to a specific precision.
+ * If you'd like to round to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.round` instead, as it won't incur the additional library overhead.
+ * @param value The number to round.
+ * @param precision The precision to round to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.round(value, precision);
+ * @example
+ *    R.round(123.9876, 3) // => 123.988
+ *    R.round(483.22243, 1) // => 483.2
+ *    R.round(8541, -1) // => 8540
+ *    R.round(456789, -3) // => 457000
+ * @dataFirst
+ * @category Number
+ */
+export function round(value: number, precision: number): number;
+
+/**
+ * Rounds a given number to a specific precision.
+ * If you'd like to round to an integer (i.e. use this function with constant `precision === 0`),
+ * use `Math.round` instead, as it won't incur the additional library overhead.
+ * @param value The number to round.
+ * @param precision The precision to round to. Must be an integer between -15 and 15.
+ * @signature
+ *    R.round(precision)(value);
+ * @example
+ *    R.round(3)(123.9876) // => 123.988
+ *    R.round(1)(483.22243) // => 483.2
+ *    R.round(-1)(8541) // => 8540
+ *    R.round(-3)(456789) // => 457000
+ * @dataLast
+ * @category Number
+ */
+export function round(precision: number): (value: number) => number;
+
+export function round() {
+  return purry(_withPrecision(Math.round), arguments);
+}


Test patch:
diff --git a/src/ceil.test.ts b/src/ceil.test.ts
new file mode 100644
index 0000000..fddb76c
--- /dev/null
+++ b/src/ceil.test.ts
@@ -0,0 +1,95 @@
+import { ceil } from './ceil';
+
+describe('data-first', () => {
+  test('should work with positive precision', () => {
+    expect(ceil(8123.4317, 3)).toEqual(8123.432);
+    expect(ceil(483.22243, 1)).toEqual(483.3);
+    expect(ceil(123.4317, 5)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(ceil(8123.4317, -2)).toEqual(8200);
+    expect(ceil(8123.4317, -4)).toEqual(10000);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(ceil(8123.4317, 0)).toEqual(8124);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => ceil(1, val)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => ceil(1, 21.37)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => ceil(1, 16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => ceil(1, -16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(ceil(val, precision)).toStrictEqual(val);
+      }
+    }
+  );
+});
+
+describe('data-last', () => {
+  test('should work with positive precision', () => {
+    expect(ceil(3)(8123.4317)).toEqual(8123.432);
+    expect(ceil(1)(483.22243)).toEqual(483.3);
+    expect(ceil(5)(123.4317)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(ceil(-2)(8123.4317)).toEqual(8200);
+    expect(ceil(-4)(8123.4317)).toEqual(10000);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(ceil(0)(8123.4317)).toEqual(8124);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => ceil(val)(1)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => ceil(21.37)(1)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => ceil(-16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => ceil(16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(ceil(precision)(val)).toStrictEqual(val);
+      }
+    }
+  );
+});
diff --git a/src/floor.test.ts b/src/floor.test.ts
new file mode 100644
index 0000000..7877784
--- /dev/null
+++ b/src/floor.test.ts
@@ -0,0 +1,95 @@
+import { floor } from './floor';
+
+describe('data-first', () => {
+  test('should work with positive precision', () => {
+    expect(floor(8123.4317, 3)).toEqual(8123.431);
+    expect(floor(483.22243, 1)).toEqual(483.2);
+    expect(floor(123.4317, 5)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(floor(8123.4317, -2)).toEqual(8100);
+    expect(floor(8123.4317, -4)).toEqual(0);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(floor(8123.4317, 0)).toEqual(8123);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => floor(1, val)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => floor(1, 21.37)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => floor(1, 16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => floor(1, -16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(floor(val, precision)).toStrictEqual(val);
+      }
+    }
+  );
+});
+
+describe('data-last', () => {
+  test('should work with positive precision', () => {
+    expect(floor(3)(8123.4317)).toEqual(8123.431);
+    expect(floor(1)(483.22243)).toEqual(483.2);
+    expect(floor(5)(123.4317)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(floor(-2)(8123.4317)).toEqual(8100);
+    expect(floor(-4)(8123.4317)).toEqual(0);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(floor(0)(8123.4317)).toEqual(8123);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => floor(val)(1)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => floor(21.37)(1)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => floor(16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => floor(-16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(floor(precision)(val)).toStrictEqual(val);
+      }
+    }
+  );
+});
diff --git a/src/round.test.ts b/src/round.test.ts
new file mode 100644
index 0000000..f84bcb4
--- /dev/null
+++ b/src/round.test.ts
@@ -0,0 +1,95 @@
+import { round } from './round';
+
+describe('data-first', () => {
+  test('should work with positive precision', () => {
+    expect(round(8123.4317, 3)).toEqual(8123.432);
+    expect(round(483.22243, 1)).toEqual(483.2);
+    expect(round(123.4317, 5)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(round(8123.4317, -2)).toEqual(8100);
+    expect(round(8123.4317, -4)).toEqual(10000);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(round(8123.4317, 0)).toEqual(8123);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => round(1, val)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => round(1, 21.37)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => round(1, 16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => round(1, -16)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(round(val, precision)).toStrictEqual(val);
+      }
+    }
+  );
+});
+
+describe('data-last', () => {
+  test('should work with positive precision', () => {
+    expect(round(3)(8123.4317)).toEqual(8123.432);
+    expect(round(1)(483.22243)).toEqual(483.2);
+    expect(round(5)(123.4317)).toEqual(123.4317);
+  });
+
+  test('should work with negative precision', () => {
+    expect(round(-2)(8123.4317)).toEqual(8100);
+    expect(round(-4)(8123.4317)).toEqual(10000);
+  });
+
+  test('should work with precision = 0', () => {
+    expect(round(0)(8123.4317)).toEqual(8123);
+  });
+
+  test.each([NaN, Infinity])('should throw for %d precision', val => {
+    expect(() => round(val)(1)).toThrowError(
+      `precision must be an integer: ${val}`
+    );
+  });
+
+  test('should throw for non integer precision', () => {
+    expect(() => round(21.37)(1)).toThrowError(
+      'precision must be an integer: 21.37'
+    );
+  });
+
+  test('should throw for precision higher than 15 and lower than -15', () => {
+    expect(() => round(16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+    expect(() => round(-16)(1)).toThrowError(
+      'precision must be between -15 and 15'
+    );
+  });
+
+  test.each([NaN, Infinity, -Infinity])(
+    'should return %d when passed as value regardless of precision',
+    val => {
+      for (const precision of [-1, 0, 1]) {
+        expect(round(precision)(val)).toStrictEqual(val);
+      }
+    }
+  );
+});