Repository: remeda/remeda
Pull request #482 (2024-02-01 14:20:37)

chore(deps-dev): bump semantic-release from 22.0.12 to 23.0.0
Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 22.0.12 to 23.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/semantic-release/semantic-release/releases">semantic-release's releases</a>.</em></p>
<blockquote>
<h2>v23.0.0</h2>
<h1><a href="https://github.com/semantic-release/semantic-release/compare/v22.0.12...v23.0.0">23.0.0</a> (2024-01-12)</h1>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency cosmiconfig to v9 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3105">#3105</a>) (<a href="https://github.com/semantic-release/semantic-release/commit/07dde042bbdd4a407dbf7f7a5b8ab2d11abdb8c3">07dde04</a>)</li>
<li><strong>deps:</strong> update dependency marked to v11 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3079">#3079</a>) (<a href="https://github.com/semantic-release/semantic-release/commit/6d2a6f1aa658cfdfa905f19a4c54f38ebdfc94ef">6d2a6f1</a>)</li>
<li><strong>deps:</strong> upgraded to the latest version of env-ci (<a href="https://github.com/semantic-release/semantic-release/commit/0d0ed9d08a26ed5e146fcba5a58d760f7a1ebd02">0d0ed9d</a>)</li>
<li>use one note reference per tag to prevent conflicts (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/2085">#2085</a>) (<a href="https://github.com/semantic-release/semantic-release/commit/020ea7d7d5aa6c123fe4c97754b6b1b36c0a8e2c">020ea7d</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li><strong>node-versions:</strong> dropped support for node v18 and raised the minimum for v20 (<a href="https://github.com/semantic-release/semantic-release/commit/2904832967c9160d3e293ce4be7a12aef0318a95">2904832</a>), closes <a href="https://redirect.github.com//redirect.github.com/semantic-release/gitlab/pull/647/issues/discussion_r1423131205">semantic-release/gitlab#647</a></li>
</ul>
<h3>BREAKING CHANGES</h3>
<ul>
<li><strong>deps:</strong> woodpecker ci users need to upgrade to v1.0.0 or higher to remain supported. see
<a href="https://github.com/semantic-release/env-ci/releases/tag/v11.0.0">https://github.com/semantic-release/env-ci/releases/tag/v11.0.0</a> for more information</li>
<li><strong>node-versions:</strong> support for node v18 has been dropped and the minimum for v20 is now v20.8.1</li>
</ul>
<p>related to <a href="https://github.com/semantic-release/semantic-release/discussions/3088">https://github.com/semantic-release/semantic-release/discussions/3088</a></p>
<ul>
<li><strong>deps:</strong> if using <code>release.config.js</code> as the name of your config file, it needs to be moved to a <code>.config/</code> directory or renamed to <code>.releaserc.js</code> to keep it in the root of the repository. see <a href="https://github.com/cosmiconfig/cosmiconfig/releases/tag/v9.0.0">https://github.com/cosmiconfig/cosmiconfig/releases/tag/v9.0.0</a> for more detail</li>
</ul>
<h2>v23.0.0-beta.5</h2>
<h1><a href="https://github.com/semantic-release/semantic-release/compare/v23.0.0-beta.4...v23.0.0-beta.5">23.0.0-beta.5</a> (2024-01-05)</h1>
<h3>Bug Fixes</h3>
<ul>
<li>use one note reference per tag to prevent conflicts (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/2085">#2085</a>) (<a href="https://github.com/semantic-release/semantic-release/commit/020ea7d7d5aa6c123fe4c97754b6b1b36c0a8e2c">020ea7d</a>)</li>
</ul>
<h2>v23.0.0-beta.4</h2>
<h1><a href="https://github.com/semantic-release/semantic-release/compare/v23.0.0-beta.3...v23.0.0-beta.4">23.0.0-beta.4</a> (2024-01-05)</h1>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency marked to v11 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3079">#3079</a>) (<a href="https://github.com/semantic-release/semantic-release/commit/6d2a6f1aa658cfdfa905f19a4c54f38ebdfc94ef">6d2a6f1</a>)</li>
</ul>
<h2>v23.0.0-beta.3</h2>
<h1><a href="https://github.com/semantic-release/semantic-release/compare/v23.0.0-beta.2...v23.0.0-beta.3">23.0.0-beta.3</a> (2023-12-16)</h1>
<h3>Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/semantic-release/semantic-release/commit/58e21774cb91456da01c9d999359deb102e8c0d3"><code>58e2177</code></a> next major (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3111">#3111</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/8066b7fd5433fa8b9fc9ec6decf9c0163551cf7f"><code>8066b7f</code></a> Merge branch 'master' of github.com:semantic-release/semantic-release into beta</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/f31d43c429241144bdb8ecb4af87a91657b5b4bc"><code>f31d43c</code></a> ci(action): update github/codeql-action action to v3.23.0 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3136">#3136</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/13f980149abfe16e7594b56300f30766ada24d89"><code>13f9801</code></a> chore(deps): lock file maintenance (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3134">#3134</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/020ea7d7d5aa6c123fe4c97754b6b1b36c0a8e2c"><code>020ea7d</code></a> fix: use one note reference per tag to prevent conflicts (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/2085">#2085</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/6d2a6f1aa658cfdfa905f19a4c54f38ebdfc94ef"><code>6d2a6f1</code></a> fix(deps): update dependency marked to v11 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3079">#3079</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/ce580be16d13b96e6fdb19eafc6fa26b1aee9833"><code>ce580be</code></a> Merge branch 'master' into beta</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/d4b7c9a23d3d031efd8499d26f1cc239746c5246"><code>d4b7c9a</code></a> chore(deps): update dependency c8 to v9 (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3128">#3128</a>)</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/7983c573007def335ce88912157dc1d1dd1be09b"><code>7983c57</code></a> ci(renovate): fixed reference to the org-level config</li>
<li><a href="https://github.com/semantic-release/semantic-release/commit/50ad70f2296f4cc22ea075393c1a8d9bbcacd5b6"><code>50ad70f</code></a> chore(deps): lock file maintenance (<a href="https://redirect.github.com/semantic-release/semantic-release/issues/3126">#3126</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/semantic-release/semantic-release/compare/v22.0.12...v23.0.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=semantic-release&package-manager=npm_and_yarn&previous-version=22.0.12&new-version=23.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions


</details>

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/482/builds/467537) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit d5a9feb00dd5c28a12d114e1efa68e235e6b5594:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/482?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:
> Comparison is base [(`96a4e82`)](https://app.codecov.io/gh/remeda/remeda/commit/96a4e8276231ba124b6ff3ac6ff997112a56d2d6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 98.41% compared to head [(`d5a9feb`)](https://app.codecov.io/gh/remeda/remeda/pull/482?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 98.41%.


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


```diff
@@           Coverage Diff           @@
##           master     #482   +/-   ##
=======================================
  Coverage   98.41%   98.41%           
=======================================
  Files         127      127           
  Lines        7954     7954           
  Branches      724      724           
=======================================
  Hits         7828     7828           
  Misses        123      123           
  Partials        3        3           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/482?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/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
index daf342f..8e544ad 100644
--- a/.github/workflows/codecov.yml
+++ b/.github/workflows/codecov.yml
@@ -24,4 +24,4 @@ jobs:
         run: npm run coverage
 
       - name: 🗺️ Upload coverage
-        uses: codecov/codecov-action@v3
+        uses: codecov/codecov-action@v4
diff --git a/mapping.md b/mapping.md
index de663b2..cb56353 100644
--- a/mapping.md
+++ b/mapping.md
@@ -120,13 +120,17 @@ provide the exact functionality of the listed Lodash and Ramda functions.
 Be sure to consult each library's documentation and to check what features
 you are relying on when migrating._
 
-| Lodash     | Ramda    | TypeScript                    |
-| ---------- | -------- | ----------------------------- |
-|            | `append` | `(arr, val) => [...arr, val]` |
-| `constant` | `always` | `x => () => x`                |
-| `nth`      | `nth`    | `n => a[n]`                   |
-|            | `path`   | `a?.b?.c`                     |
-|            | `propEq` | `x => x.a === value`          |
-|            | `propOr` | `x => x.a ?? defaultValue`    |
-| `words`    |          | `str => str.split(/\s+/)`     |
-| `compact`  |          | `R.filter(R.isTruthy)`        |
+| Lodash     | Ramda    | TypeScript                        |
+| ---------- | -------- | --------------------------------- |
+|            | `append` | `(arr, val) => [...arr, val]`     |
+| `constant` | `always` | `x => () => x`                    |
+| `nth`      | `nth`    | `n => a[n]`                       |
+|            | `path`   | `a?.b?.c`                         |
+|            | `propEq` | `x => x.a === value`              |
+|            | `propOr` | `x => x.a ?? defaultValue`        |
+| `words`    |          | `str => str.split(/\s+/)`         |
+| `compact`  |          | `R.filter(R.isTruthy)`            |
+| `max`      | `max`    | `R.firstBy([R.identity, "desc"])` |
+| `maxBy`    | `maxBy`  | `R.firstBy([fn, "desc"])`         |
+| `min`      | `min`    | `R.firstBy(R.identity)`           |
+| `minBy`    | `minBy`  | `R.firstBy(fn)`                   |
diff --git a/package-lock.json b/package-lock.json
index baf80a4..583dcd0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
         "husky": "^8.0.3",
         "lint-staged": "^15.2.0",
         "prettier": "^3.1.0",
-        "semantic-release": "^22.0.12",
+        "semantic-release": "^23.0.0",
         "typescript": "^5.3.2",
         "vitest": "^1.0.1"
       }
@@ -2346,15 +2346,15 @@
       "dev": true
     },
     "node_modules/cosmiconfig": {
-      "version": "8.3.6",
-      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
-      "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+      "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
       "dev": true,
       "dependencies": {
+        "env-paths": "^2.2.1",
         "import-fresh": "^3.3.0",
         "js-yaml": "^4.1.0",
-        "parse-json": "^5.2.0",
-        "path-type": "^4.0.0"
+        "parse-json": "^5.2.0"
       },
       "engines": {
         "node": ">=14"
@@ -2529,9 +2529,9 @@
       "dev": true
     },
     "node_modules/env-ci": {
-      "version": "10.0.0",
-      "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-10.0.0.tgz",
-      "integrity": "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw==",
+      "version": "11.0.0",
+      "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.0.0.tgz",
+      "integrity": "sha512-apikxMgkipkgTvMdRT9MNqWx5VLOci79F4VBd7Op/7OPjjoanjdAvn6fglMCCEf/1bAh8eOiuEVCUs4V3qP3nQ==",
       "dev": true,
       "dependencies": {
         "execa": "^8.0.0",
@@ -2541,6 +2541,15 @@
         "node": "^18.17 || >=20.6.1"
       }
     },
+    "node_modules/env-paths": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+      "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/error-ex": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -4072,15 +4081,15 @@
       }
     },
     "node_modules/marked": {
-      "version": "9.1.6",
-      "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz",
-      "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==",
+      "version": "11.2.0",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz",
+      "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==",
       "dev": true,
       "bin": {
         "marked": "bin/marked.js"
       },
       "engines": {
-        "node": ">= 16"
+        "node": ">= 18"
       }
     },
     "node_modules/marked-terminal": {
@@ -8046,9 +8055,9 @@
       "dev": true
     },
     "node_modules/semantic-release": {
-      "version": "22.0.12",
-      "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-22.0.12.tgz",
-      "integrity": "sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw==",
+      "version": "23.0.0",
+      "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-23.0.0.tgz",
+      "integrity": "sha512-Jz7jEWO2igTtske112gC4PPE2whCMVrsgxUPG3/SZI7VE357suIUZFlJd1Yu0g2I6RPc2HxNEfUg7KhmDTjwqg==",
       "dev": true,
       "dependencies": {
         "@semantic-release/commit-analyzer": "^11.0.0",
@@ -8057,9 +8066,9 @@
         "@semantic-release/npm": "^11.0.0",
         "@semantic-release/release-notes-generator": "^12.0.0",
         "aggregate-error": "^5.0.0",
-        "cosmiconfig": "^8.0.0",
+        "cosmiconfig": "^9.0.0",
         "debug": "^4.0.0",
-        "env-ci": "^10.0.0",
+        "env-ci": "^11.0.0",
         "execa": "^8.0.0",
         "figures": "^6.0.0",
         "find-versions": "^5.1.0",
@@ -8069,7 +8078,7 @@
         "hosted-git-info": "^7.0.0",
         "import-from-esm": "^1.3.1",
         "lodash-es": "^4.17.21",
-        "marked": "^9.0.0",
+        "marked": "^11.0.0",
         "marked-terminal": "^6.0.0",
         "micromatch": "^4.0.2",
         "p-each-series": "^3.0.0",
@@ -8085,7 +8094,7 @@
         "semantic-release": "bin/semantic-release.js"
       },
       "engines": {
-        "node": "^18.17 || >=20.6.1"
+        "node": ">=20.8.1"
       }
     },
     "node_modules/semantic-release/node_modules/get-stream": {
diff --git a/package.json b/package.json
index 58dda9b..d5832fb 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
     "husky": "^8.0.3",
     "lint-staged": "^15.2.0",
     "prettier": "^3.1.0",
-    "semantic-release": "^22.0.12",
+    "semantic-release": "^23.0.0",
     "typescript": "^5.3.2",
     "vitest": "^1.0.1"
   }
diff --git a/src/_heap.ts b/src/_heap.ts
new file mode 100644
index 0000000..17d54aa
--- /dev/null
+++ b/src/_heap.ts
@@ -0,0 +1,96 @@
+/**
+ * Heap related utilities.
+ */
+
+import { swapInPlace } from './_swapInPlace';
+import type { CompareFunction } from './_types';
+
+/**
+ * Mutates an array into a "max"-heap based on `compareFn` so that for any `item` in the heap, `compareFn(heap[0], item) > 0`
+ *
+ * @param heap - The array to be heapified. The array would be mutated!
+ * @param compareFn - The comparator used to order items in the heap. Use the
+ * same function in all calls mutating the same heap otherwise you'd get
+ * unexpected results.
+ * @returns - void, the result is in the provided heap array.
+ */
+export function heapify<T>(
+  heap: Array<T>,
+  compareFn: CompareFunction<T>
+): void {
+  for (let i = Math.floor(heap.length / 2) - 1; i >= 0; i--) {
+    heapSiftDown(heap, i, compareFn);
+  }
+}
+
+/**
+ * Insert an item into a heap if it's "smaller" (in regards to `compareFn`) than
+ * the current head of the heap (which is the "largest" value in the heap). If
+ * the item is inserted, the previous head of the heap is returned, otherwise
+ * `undefined` is returned and the heap is unchanged.
+ *
+ * @param heap - a *mutable* array representing a heap (see `heapify`).
+ * @param compareFn - The comparator used to order items in the heap. Use the
+ * @param item - The item to be inserted into the heap.
+ * @returns `undefined` if the heap is unchanged, or the previous head of the
+ * heap if the item was inserted.
+ */
+export function heapMaybeInsert<T>(
+  heap: Array<T>,
+  compareFn: CompareFunction<T>,
+  item: T
+): T | undefined {
+  const head = heap[0];
+
+  if (compareFn(item, head) >= 0) {
+    // The item shouldn't be inserted into the heap, the heap is unchanged.
+    return;
+  }
+
+  heap[0] = item;
+  heapSiftDown(heap, 0, compareFn);
+  return head;
+}
+
+/**
+ * The main heap operation. Takes a `heap` and an `index` and sifts the item
+ * down the heap until it reaches the correct position based on `compareFn`,
+ * swapping other items in the process.
+ */
+function heapSiftDown<T>(
+  heap: Array<T>,
+  index: number,
+  compareFn: CompareFunction<T>
+): void {
+  let currentIndex = index;
+
+  // The loop continues while the currentIndex has children in the heap.
+  while (currentIndex * 2 + 1 < heap.length) {
+    const firstChildIndex = currentIndex * 2 + 1;
+
+    let swapIndex =
+      compareFn(heap[currentIndex], heap[firstChildIndex]) < 0
+        ? // Is the parent "smaller" (in regards to `compareFn`) to its child?
+          firstChildIndex
+        : currentIndex;
+
+    const secondChildIndex = firstChildIndex + 1;
+    if (
+      secondChildIndex < heap.length &&
+      compareFn(heap[swapIndex], heap[secondChildIndex]) < 0
+    ) {
+      // Is there a second child? Is it the smallest of the three?
+      swapIndex = secondChildIndex;
+    }
+
+    if (swapIndex === currentIndex) {
+      // We assume the array is a heap and the existing order of items satisfies
+      // the compareFn so we can stop here.
+      return;
+    }
+
+    swapInPlace(heap, currentIndex, swapIndex);
+
+    currentIndex = swapIndex;
+  }
+}
diff --git a/src/_purryOrderRules.ts b/src/_purryOrderRules.ts
new file mode 100644
index 0000000..657d338
--- /dev/null
+++ b/src/_purryOrderRules.ts
@@ -0,0 +1,175 @@
+import type { CompareFunction, NonEmptyArray } from './_types';
+
+// We define the comparators in a global const so that they are only
+// instantiated once, and so we can couple a label (string) for them that could
+// be used in runtime to refer to them (e.g. "asc", "desc").
+const COMPARATORS = {
+  asc: <T>(x: T, y: T) => x > y,
+  desc: <T>(x: T, y: T) => x < y,
+} as const;
+
+/**
+ * An order rule defines a projection/extractor that returns a comparable from
+ * the data being compared. It would be run on each item being compared, and a
+ * comparator would then be used on the results to determine the order.
+ *
+ * There are 2 forms of the order rule, a simple one which only provides the
+ * projection function and assumes ordering is ascending, and a 2-tuple where
+ * the first element is the projection function and the second is the direction;
+ * this allows changing the direction without defining a more complex projection
+ * to simply negate the value (e.g. `(x) => -x`).
+ *
+ * We rely on the javascript implementation of `<` and `>` for comparison, which
+ * will attempt to transform both operands into a primitive comparable value via
+ * the built in `valueOf` function (and then `toString`). It's up to the caller
+ * to make sure that the projection is returning a value that makes sense for
+ * this logic.
+ *
+ * It's important to note that there is no built-in caching/memoization of
+ * projection function and therefore no guarantee that it would only be called
+ * once.
+ */
+export type OrderRule<T> =
+  | Projection<T>
+  | readonly [projection: Projection<T>, direction: keyof typeof COMPARATORS];
+
+type Projection<T> = (x: T) => Comparable;
+
+// We define the Comparable based on how JS coerces values into primitives when
+// used with the `<` and `>` operators.
+// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#type_coercion
+type Comparable =
+  | ComparablePrimitive
+  | { [Symbol.toPrimitive](hint: string): ComparablePrimitive }
+  | { valueOf(): ComparablePrimitive }
+  | { toString(): string };
+
+//  Notice that `boolean` is special in that it is coerced as a number (0 for
+// `false`, 1 for `true`) implicitly.
+type ComparablePrimitive = number | string | boolean;
+
+/**
+ * Allows functions that want to handle a variadic number of order rules a
+ * a simplified API that hides most of the implementation details. The only
+ * thing users of this function need to do is provide a function that would take
+ * the data, and a compare function that can be used to determine the order
+ * between the items of the array.
+ * This functions takes care of the rest; it will parse rules, built the
+ * comparer, and manage the purrying of the input arguments.
+ */
+export function purryOrderRules<T>(
+  func: (data: ReadonlyArray<T>, compareFn: CompareFunction<T>) => unknown,
+  inputArgs: IArguments | ReadonlyArray<unknown>
+): unknown {
+  // We rely on casting blindly here, but we rely on casting blindly everywhere
+  // else when we call purry so it's fine...
+  const [dataOrRule, ...rules] = (
+    Array.isArray(inputArgs) ? inputArgs : Array.from(inputArgs)
+  ) as
+    | [data: ReadonlyArray<T>, ...rules: Readonly<NonEmptyArray<OrderRule<T>>>]
+    | Readonly<NonEmptyArray<OrderRule<T>>>;
+
+  if (!isOrderRule<T>(dataOrRule)) {
+    // dataFirst!
+
+    // @ts-expect-error [ts2556]: Typescript is failing to infer the type of rules
+    // correctly here after the type refinement above, rules should be non-empty
+    // when we get here.
+    const compareFn = orderRuleComparer(...rules);
+    return func(dataOrRule, compareFn);
+  }
+
+  // dataLast!
+
+  // Important: initialize the comparer outside of the returned function so it
+  // it's constructed and shared everywhere (it's stateless so should be safe
+  // if used multiple times).
+  const compareFn = orderRuleComparer(dataOrRule, ...rules);
+  return (data: ReadonlyArray<T>) => func(data, compareFn);
+}
+
+/**
+ * Some functions need an extra number argument, this helps facilitate that.
+ */
+export function purryOrderRulesWithArgument(
+  func: <T>(
+    data: ReadonlyArray<T>,
+    compareFn: CompareFunction<T>,
+    arg: any
+  ) => unknown,
+  inputArgs: IArguments
+): unknown {
+  const [first, second, ...rest] = Array.from(inputArgs);
+
+  // We need to pull the `n` argument out to make it work with purryOrderRules.
+  let arg: unknown;
+  let argRemoved: ReadonlyArray<unknown>;
+  if (isOrderRule(second)) {
+    // dataLast!
+    arg = first;
+    argRemoved = [second, ...rest];
+  } else {
+    // dataFirst!
+    arg = second;
+    argRemoved = [first, ...rest];
+  }
+
+  return purryOrderRules((...args) => func(...args, arg), argRemoved);
+}
+
+function orderRuleComparer<T>(
+  primaryRule: OrderRule<T>,
+  secondaryRule?: OrderRule<T>,
+  ...otherRules: ReadonlyArray<OrderRule<T>>
+): (a: T, b: T) => number {
+  const projector =
+    typeof primaryRule === 'function' ? primaryRule : primaryRule[0];
+
+  const direction = typeof primaryRule === 'function' ? 'asc' : primaryRule[1];
+  const comparator = COMPARATORS[direction];
+
+  const nextComparer =
+    secondaryRule === undefined
+      ? undefined
+      : orderRuleComparer(secondaryRule, ...otherRules);
+
+  return (a, b) => {
+    const projectedA = projector(a);
+    const projectedB = projector(b);
+
+    if (comparator(projectedA, projectedB)) {
+      return 1;
+    }
+
+    if (comparator(projectedB, projectedA)) {
+      return -1;
+    }
+
+    // The elements are equal base on the current comparator and projection. So
+    // we need to check the elements using the next comparer, if one exists,
+    // otherwise we consider them as true equal (returning 0).
+    return nextComparer?.(a, b) ?? 0;
+  };
+}
+
+function isOrderRule<T>(x: unknown): x is OrderRule<T> {
+  if (isProjection(x)) {
+    return true;
+  }
+
+  if (typeof x !== 'object' || !Array.isArray(x)) {
+    return false;
+  }
+
+  const [maybeProjection, maybeDirection, ...rest] = x;
+
+  return (
+    isProjection(maybeProjection) &&
+    maybeDirection in COMPARATORS &&
+    // Has to be a 2-tuple
+    rest.length === 0
+  );
+}
+
+const isProjection = <T>(x: unknown): x is Projection<T> =>
+  typeof x === 'function' && x.length === 1;
diff --git a/src/_quickSelect.ts b/src/_quickSelect.ts
new file mode 100644
index 0000000..79d83a9
--- /dev/null
+++ b/src/_quickSelect.ts
@@ -0,0 +1,88 @@
+/**
+ * A simple implementation of the *QuickSelect* algorithm.
+ * @see https://en.wikipedia.org/wiki/Quickselect
+ */
+
+import { swapInPlace } from './_swapInPlace';
+import type { CompareFunction } from './_types';
+
+/**
+ * Perform QuickSelect on the given data. Notice that the data would be cloned
+ * shallowly so that it could be mutated in-place, and then discarded once the
+ * algorithm is done. This means that running this function multiple times on
+ * the same array might be slower then sorting the array before.
+ * @param data - The data to perform the selection on.
+ * @param index - The index of the item we are looking for.
+ * @param compareFn - The compare function to use for sorting.
+ * @returns The item at the given index, or `undefined` if the index is out-of-
+ * bounds.
+ */
+export const quickSelect = <T>(
+  data: ReadonlyArray<T>,
+  index: number,
+  compareFn: CompareFunction<T>
+): T | undefined =>
+  index < 0 || index >= data.length
+    ? // Quickselect doesn't work with out-of-bound indices
+      undefined
+    : quickSelectImplementation(
+        // We need to clone the array because quickSelect mutates it in-place.
+        [...data],
+        0 /* left */,
+        data.length - 1 /* right */,
+        index,
+        compareFn
+      );
+
+/**
+ * The actual implementation, called recursively.
+ */
+function quickSelectImplementation<T>(
+  data: Array<T>,
+  left: number,
+  right: number,
+  index: number,
+  compareFn: CompareFunction<T>
+): T {
+  if (left === right) {
+    return data[left];
+  }
+
+  const pivotIndex = partition(data, left, right, compareFn);
+
+  return index === pivotIndex
+    ? // Once a pivot is chosen it's location is final, so if it matches the
+      // index we found out item!
+      data[index]
+    : quickSelectImplementation(
+        data,
+        // We continue by recursing into the partition where index would be
+        index < pivotIndex ? left : pivotIndex + 1,
+        index < pivotIndex ? pivotIndex - 1 : right,
+        index,
+        compareFn
+      );
+}
+
+function partition<T>(
+  data: Array<T>,
+  left: number,
+  right: number,
+  compareFn: CompareFunction<T>
+): number {
+  const pivot = data[right];
+
+  let i = left;
+  for (let j = left; j < right; j++) {
+    if (compareFn(data[j], pivot) < 0) {
+      // Move items smaller then the pivot to the start of the array.
+      swapInPlace(data, i, j);
+      i++;
+    }
+  }
+
+  swapInPlace(data, i, right);
+
+  // The location of the pivot by the end of the partition function.
+  return i;
+}
diff --git a/src/_swapInPlace.ts b/src/_swapInPlace.ts
new file mode 100644
index 0000000..0541266
--- /dev/null
+++ b/src/_swapInPlace.ts
@@ -0,0 +1,8 @@
+/**
+ * An efficient hack to swap the values at two indices in an array *in-place*.
+ */
+export function swapInPlace(data: Array<unknown>, i: number, j: number): void {
+  // We use destructuring to perform an in-place swap *without* needing a
+  // temporary variable
+  [data[i], data[j]] = [data[j], data[i]];
+}
diff --git a/src/_types.ts b/src/_types.ts
index 2af773b..ba51ba4 100644
--- a/src/_types.ts
+++ b/src/_types.ts
@@ -56,6 +56,12 @@ type BuildTupleHelper<
   ? readonly [...Rest] // Terminate with readonly array (aka tuple)
   : BuildTupleHelper<Element, Length, [Element, ...Rest]>;
 
+/**
+ * A compare function that is compatible with the native `Array.sort` function.
+ * @returns >0 if `a` should come after `b`, 0 if they are equal, and <0 if `a` should come before `b`.
+ */
+export type CompareFunction<T> = (a: T, b: T) => number;
+
 /**
  * Based on type-fest's IsAny
  * @see https://github.com/sindresorhus/type-fest/blob/main/source/is-any.d.ts
diff --git a/src/dropFirstBy.ts b/src/dropFirstBy.ts
new file mode 100644
index 0000000..bf15717
--- /dev/null
+++ b/src/dropFirstBy.ts
@@ -0,0 +1,77 @@
+import { heapify, heapMaybeInsert } from './_heap';
+import { OrderRule, purryOrderRulesWithArgument } from './_purryOrderRules';
+import type { CompareFunction, NonEmptyArray } from './_types';
+
+/**
+ * Drop the first `n` items from `data` based on the provided ordering criteria. This allows you to avoid sorting the array before dropping the items. The complexity of this function is *O(Nlogn)* where `N` is the length of the array.
+ *
+ * For the opposite operation (to keep `n` elements) see `takeFirstBy`.
+ *
+ * @params data - the input array
+ * @params n - the number of items to drop. If `n` is non-positive no items would be dropped and a *clone* of the input would be returned, if `n` is bigger then data.length no items would be returned.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns a subset of the input array.
+ * @signature
+ *   R.dropFirstBy(data, n, ...rules);
+ * @example
+ *   R.dropFirstBy(['aa', 'aaaa', 'a', 'aaa'], 2, x => x.length); // => ['aaa', 'aaaa']
+ * @dataFirst
+ * @category Array
+ */
+export function dropFirstBy<T>(
+  data: ReadonlyArray<T>,
+  n: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): Array<T>;
+
+/**
+ * Drop the first `n` items from `data` based on the provided ordering criteria. This allows you to avoid sorting the array before dropping the items. The complexity of this function is *O(Nlogn)* where `N` is the length of the array.
+ *
+ * For the opposite operation (to keep `n` elements) see `takeFirstBy`.
+ *
+ * @params data - the input array
+ * @params n - the number of items to drop. If `n` is non-positive no items would be dropped and a *clone* of the input would be returned, if `n` is bigger then data.length no items would be returned.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns a subset of the input array.
+ * @signature
+ *   R.dropFirstBy(n, ...rules)(data);
+ * @example
+ *   R.pipe(['aa', 'aaaa', 'a', 'aaa'], R.dropFirstBy(2, x => x.length)); // => ['aaa', 'aaaa']
+ * @dataLast
+ * @category Array
+ */
+export function dropFirstBy<T>(
+  n: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): (data: ReadonlyArray<T>) => Array<T>;
+
+export function dropFirstBy(): unknown {
+  return purryOrderRulesWithArgument(dropFirstByImplementation, arguments);
+}
+
+function dropFirstByImplementation<T>(
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>,
+  n: number
+): Array<T> {
+  if (n >= data.length) {
+    return [];
+  }
+
+  if (n <= 0) {
+    return [...data];
+  }
+
+  const heap = data.slice(0, n);
+  heapify(heap, compareFn);
+
+  const out = [];
+
+  const rest = data.slice(n);
+  for (const item of rest) {
+    const previousHead = heapMaybeInsert(heap, compareFn, item);
+    out.push(previousHead ?? item);
+  }
+
+  return out;
+}
diff --git a/src/firstBy.ts b/src/firstBy.ts
new file mode 100644
index 0000000..e581de7
--- /dev/null
+++ b/src/firstBy.ts
@@ -0,0 +1,101 @@
+import { OrderRule, purryOrderRules } from './_purryOrderRules';
+import type {
+  CompareFunction,
+  IterableContainer,
+  NonEmptyArray,
+} from './_types';
+import { hasAtLeast } from './hasAtLeast';
+
+type FirstBy<T extends IterableContainer> =
+  | T[number]
+  | (T extends readonly [unknown, ...ReadonlyArray<unknown>]
+      ? never
+      : T extends readonly [...ReadonlyArray<unknown>, unknown]
+        ? never
+        : undefined);
+
+/**
+ * Find the first element in the array that adheres to the order rules provided. This is a superset of what a typical `maxBy` or `minBy` function would do as it allows defining "tie-breaker" rules when values are equal, and allows comparing items using any logic. This function is equivalent to calling `R.first(R.sortBy(...))` but runs at *O(n)* instead of *O(nlogn)*.
+ *
+ * Use `nthBy` if you need an element other that the first, or `takeFirstBy` if you more than just the first element.
+ *
+ * @param data an array of items
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns the first element by the order criteria, or `undefined` if the array
+ * is empty. (The function provides strong typing if the input type assures the
+ * array isn't empty).
+ * @signature
+ *   R.firstBy(...rules)(data);
+ * @example
+ *   const max = R.pipe([1,2,3], R.firstBy([R.identity, "desc"])); // => 3;
+ *   const min = R.pipe([1,2,3], R.firstBy([1,2,3])); // => 1;
+ *
+ *   const data = [{ a: "a" }, { a: "aa" }, { a: "aaa" }] as const;
+ *   const maxBy = R.pipe(data, R.firstBy([(item) => item.a.length, "desc"])); // => { a: "aaa" };
+ *   const minBy = R.pipe(data, R.firstBy((item) => item.a.length)); // => { a: "a" };
+ *
+ *   const data = [{type: "cat", size: 1}, {type: "cat", size: 2}, {type: "dog", size: 3}] as const;
+ *   const multi = R.pipe(data, R.firstBy(R.prop('type'), [R.prop('size'), 'desc'])); // => {type: "cat", size: 2}
+ * @dataLast
+ * @category Array
+ */
+export function firstBy<T extends IterableContainer>(
+  ...rules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
+): (data: T) => FirstBy<T>;
+
+/**
+ * Find the first element in the array that adheres to the order rules provided. This is a superset of what a typical `maxBy` or `minBy` function would do as it allows defining "tie-breaker" rules when values are equal, and allows comparing items using any logic. This function is equivalent to calling `R.first(R.sortBy(...))` but runs at *O(n)* instead of *O(nlogn)*.
+ *
+ * Use `nthBy` if you need an element other that the first, or `takeFirstBy` if you more than just the first element.
+ *
+ * @param data an array of items
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns the first element by the order criteria, or `undefined` if the array
+ * is empty. (The function provides strong typing if the input type assures the
+ * array isn't empty).
+ * @signature
+ *   R.firstBy(data, ...rules);
+ * @example
+ *   const max = R.firstBy([1,2,3], [R.identity, "desc"]); // => 3;
+ *   const min = R.firstBy([1,2,3], R.identity); // => 1;
+ *
+ *   const data = [{ a: "a" }, { a: "aa" }, { a: "aaa" }] as const;
+ *   const maxBy = R.firstBy(data, [(item) => item.a.length, "desc"]); // => { a: "aaa" };
+ *   const minBy = R.firstBy(data, (item) => item.a.length); // => { a: "a" };
+ *
+ *   const data = [{type: "cat", size: 1}, {type: "cat", size: 2}, {type: "dog", size: 3}] as const;
+ *   const multi = R.firstBy(data, R.prop('type'), [R.prop('size'), 'desc']); // => {type: "cat", size: 2}
+ * @dataFirst
+ * @category Array
+ */
+export function firstBy<T extends IterableContainer>(
+  data: T,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
+): FirstBy<T>;
+
+export function firstBy(): unknown {
+  return purryOrderRules(firstByImplementation, arguments);
+}
+
+function firstByImplementation<T>(
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>
+): T | undefined {
+  let [currentFirst] = data;
+
+  if (!hasAtLeast(data, 2)) {
+    // If we have 0 or 1 item we simply return the trivial result.
+    return currentFirst;
+  }
+
+  // Remove the first item, we won't compare it with itself.
+  const [, ...rest] = data;
+  for (const item of rest) {
+    if (compareFn(item, currentFirst) < 0) {
+      // item comes before currentFirst in the order.
+      currentFirst = item;
+    }
+  }
+
+  return currentFirst;
+}
diff --git a/src/index.ts b/src/index.ts
index 5b71ccb..1fc4569 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,6 +15,7 @@ export * from './difference';
 export * from './differenceWith';
 export * from './divide';
 export * from './drop';
+export * from './dropFirstBy';
 export * from './dropLast';
 export * from './dropLastWhile';
 export * from './dropWhile';
@@ -25,6 +26,7 @@ export * from './findIndex';
 export * from './findLast';
 export * from './findLastIndex';
 export * from './first';
+export * from './firstBy';
 export * from './flatMap';
 export * from './flatMapToObj';
 export * from './flatten';
@@ -74,6 +76,7 @@ export * from './mergeDeep';
 export * from './minBy';
 export * from './multiply';
 export * from './noop';
+export * from './nthBy';
 export * from './objOf';
 export * from './omit';
 export * from './omitBy';
@@ -87,6 +90,7 @@ export * from './prop';
 export * from './purry';
 export * from './randomString';
 export * from './range';
+export * from './rankBy';
 export * from './reduce';
 export * from './reject';
 export * from './reverse';
@@ -112,6 +116,7 @@ export * from './sumBy';
 export * from './swapIndices';
 export * from './swapProps';
 export * from './take';
+export * from './takeFirstBy';
 export * from './takeLastWhile';
 export * from './takeWhile';
 export * from './tap';
diff --git a/src/maxBy.ts b/src/maxBy.ts
index 4949953..e70674b 100644
--- a/src/maxBy.ts
+++ b/src/maxBy.ts
@@ -19,6 +19,9 @@ const _maxBy =
 
 /**
  * Returns the max element using the provided predicate.
+ *
+ * If you need more control over how "max" is defined, consider using `firstBy` instead. maxBy might be deprecated in the future!
+ *
  * @param fn the predicate
  * @signature
  *    R.maxBy(fn)(array)
diff --git a/src/minBy.ts b/src/minBy.ts
index a94d375..b324beb 100644
--- a/src/minBy.ts
+++ b/src/minBy.ts
@@ -19,6 +19,9 @@ const _minBy =
 
 /**
  * Returns the min element using the provided predicate.
+ *
+ * If you need more control over how "min" is defined, consider using `firstBy` instead. minBy might be deprecated in the future!
+ *
  * @param fn the predicate
  * @signature
  *    R.minBy(fn)(array)
diff --git a/src/nthBy.ts b/src/nthBy.ts
new file mode 100644
index 0000000..a42efa3
--- /dev/null
+++ b/src/nthBy.ts
@@ -0,0 +1,66 @@
+import { OrderRule, purryOrderRulesWithArgument } from './_purryOrderRules';
+import { quickSelect } from './_quickSelect';
+import type {
+  CompareFunction,
+  IterableContainer,
+  NonEmptyArray,
+} from './_types';
+
+/**
+ * Retrieves the element that would be at the given index if the array were sorted according to specified rules. This function uses the *QuickSelect* algorithm running at an average complexity of *O(n)*. Semantically it is equivalent to `sortBy(data, ...rules).at(index)` which would run at *O(nlogn)*.
+ *
+ * See also `firstBy` which provides an even more efficient algorithm and a stricter return type, but only for `index === 0`. See `takeFirstBy` to get all the elements up to and including `index`.
+ *
+ * @param data - The input array.
+ * @param index - The zero-based index for selecting the element in the sorted order. Negative indices count backwards from the end.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns The element at the specified index in the sorted order, or `undefined` if the index is out of bounds.
+ * @signature
+ *   R.nthBy(data, index, ...rules);
+ * @example
+ *   R.nthBy([2,1,4,5,3,], 2, identity); // => 3
+ * @dataFirst
+ * @category Array
+ */
+export function nthBy<T extends IterableContainer>(
+  data: T,
+  index: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
+): T[number] | undefined;
+
+/**
+ * Retrieves the element that would be at the given index if the array were sorted according to specified rules. This function uses the *QuickSelect* algorithm running at an average complexity of *O(n)*. Semantically it is equivalent to `sortBy(data, ...rules)[index]` which would run at *O(nlogn)*.
+ *
+ * See also `firstBy` which provides an even more efficient algorithm and a stricter return type, but only for `index === 0`. See `takeFirstBy` to get all the elements up to and including `index`.
+ *
+ * @param data - The input array.
+ * @param index - The zero-based index for selecting the element in the sorted order. Negative indices count backwards from the end.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns The element at the specified index in the sorted order, or `undefined` if the index is out of bounds.
+ * @signature
+ *   R.nthBy(index, ...rules)(data);
+ * @example
+ *   R.pipe([2,1,4,5,3,], R.nthBy(2, identity)); // => 3
+ * @dataLast
+ * @category Array
+ */
+export function nthBy<T extends IterableContainer>(
+  index: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
+): (data: T) => T[number] | undefined;
+
+export function nthBy(): unknown {
+  return purryOrderRulesWithArgument(nthByImplementation, arguments);
+}
+
+const nthByImplementation = <T>(
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>,
+  index: number
+): T | undefined =>
+  quickSelect(
+    data,
+    // Allow negative indices gracefully
+    index >= 0 ? index : data.length + index,
+    compareFn
+  );
diff --git a/src/rankBy.ts b/src/rankBy.ts
new file mode 100644
index 0000000..3b80cfc
--- /dev/null
+++ b/src/rankBy.ts
@@ -0,0 +1,69 @@
+import { OrderRule, purryOrderRulesWithArgument } from './_purryOrderRules';
+import type { CompareFunction, NonEmptyArray } from './_types';
+
+/**
+ * Calculates the rank of an item in an array based on `rules`. The rank is the position where the item would appear in the sorted array. This function provides an efficient way to determine the rank in *O(n)* time, compared to *O(nlogn)* for the equivalent `sortedIndex(sortBy(data, ...rules), item)`.
+ *
+ * @param data - The input array.
+ * @param item - The item whose rank is to be determined.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns - The rank of the item in the sorted array in the range [0..data.length]
+ * @signature
+ *   R.rankBy(data, item, ...rules)
+ * @example
+ *   const DATA = [{ a: 5 }, { a: 1 }, { a: 3 }] as const;
+ *   R.rankBy(DATA, 0, R.prop('a')) // => 0
+ *   R.rankBy(DATA, 1, R.prop('a')) // => 1
+ *   R.rankBy(DATA, 2, R.prop('a')) // => 1
+ *   R.rankBy(DATA, 3, R.prop('a')) // => 2
+ * @dataFirst
+ * @category Array
+ */
+export function rankBy<T>(
+  data: ReadonlyArray<T>,
+  item: T,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): number;
+
+/**
+ * Calculates the rank of an item in an array based on `rules`. The rank is the position where the item would appear in the sorted array. This function provides an efficient way to determine the rank in *O(n)* time, compared to *O(nlogn)* for the equivalent `sortedIndex(sortBy(data, ...rules), item)`.
+ *
+ * @param data - The input array.
+ * @param item - The item whose rank is to be determined.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns - The rank of the item in the sorted array in the range [0..data.length]
+ * @signature
+ *   R.rankBy(item, ...rules)(data)
+ * @example
+ *   const DATA = [{ a: 5 }, { a: 1 }, { a: 3 }] as const;
+ *   R.pipe(DATA, R.rankBy(0, R.prop('a'))) // => 0
+ *   R.pipe(DATA, R.rankBy(1, R.prop('a'))) // => 1
+ *   R.pipe(DATA, R.rankBy(2, R.prop('a'))) // => 1
+ *   R.pipe(DATA, R.rankBy(3, R.prop('a'))) // => 2
+ * @dataLast
+ * @category Array
+ */
+export function rankBy<T>(
+  item: T,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): (data: ReadonlyArray<T>) => number;
+
+export function rankBy(): unknown {
+  return purryOrderRulesWithArgument(rankByImplementation, arguments);
+}
+
+function rankByImplementation<T>(
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>,
+  targetItem: T
+): number {
+  let rank = 0;
+  for (const item of data) {
+    if (compareFn(targetItem, item) > 0) {
+      // The rank of the item is equivalent to the number of items that would
+      // come before it if the array was sorted. We assume that the
+      rank++;
+    }
+  }
+  return rank;
+}
diff --git a/src/sortBy.ts b/src/sortBy.ts
index 545d175..73147cb 100644
--- a/src/sortBy.ts
+++ b/src/sortBy.ts
@@ -1,37 +1,29 @@
-import type { IterableContainer, NonEmptyArray } from './_types';
-import { purry } from './purry';
-
-const ALL_DIRECTIONS = ['asc', 'desc'] as const;
-type Direction = (typeof ALL_DIRECTIONS)[number];
-
-type ComparablePrimitive = number | string | boolean;
-type Comparable = ComparablePrimitive | { valueOf(): ComparablePrimitive };
-type SortProjection<T> = (x: T) => Comparable;
-type SortPair<T> = readonly [
-  projector: SortProjection<T>,
-  direction: Direction,
-];
-type SortRule<T> = SortProjection<T> | SortPair<T>;
-
-const COMPARATOR = {
-  asc: <T>(x: T, y: T) => x > y,
-  desc: <T>(x: T, y: T) => x < y,
-} as const;
+import { OrderRule, purryOrderRules } from './_purryOrderRules';
+import type {
+  CompareFunction,
+  IterableContainer,
+  NonEmptyArray,
+} from './_types';
 
 /**
- * Sorts the list according to the supplied functions and directions.
- * Sorting is based on a native `sort` function. It's not guaranteed to be stable.
+ * Sorts `data` using the provided ordering rules. The `sort` is done via the native `Array.prototype.sort` but is performed on a shallow copy of the array to avoid mutating the original data.
  *
- * Directions are applied to functions in order and default to ascending if not specified.
+ * To maintain the shape of more complex inputs (like non-empty arrays, tuples, etc...) use the `strict` variant.
  *
- * If the input array is more complex (non-empty array, tuple, etc...) use the
- * strict mode to maintain it's shape.
+ * There are several other functions that take order rules and **bypass** the need to sort the array first (in *O(nlogn)* time):
+ * * `firstBy` === `first(sortBy(data, ...rules))`, O(n).
+ * * `takeFirstBy` === `take(sortBy(data, ...rules), k)`, O(nlogk).
+ * * `dropFirstBy` === `drop(sortBy(data, ...rules), k)`, O(nlogk).
+ * * `nthBy` === `sortBy(data, ...rules).at(k)`, O(n).
+ * * `rankBy` === `sortedIndex(sortBy(data, ...rules), item)`, O(n).
+ * Refer to the docs for more details.
  *
- * @param sortRule main sort rule
- * @param additionalSortRules subsequent sort rules (these are only relevant when two items are equal based on the previous sort rule)
+ * @param data - The input array.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @return - A shallow copy of the input array sorted by the provided rules.
  * @signature
- *    R.sortBy(sortRule, ...additionalSortRules)(array)
- *    R.sortBy.strict(sortRule, ...additionalSortRules)(array)
+ *    R.sortBy(...rules)(data)
+ *    R.sortBy.strict(...rules)(data)
  * @example
  *    R.pipe(
  *      [{ a: 1 }, { a: 3 }, { a: 7 }, { a: 2 }],
@@ -46,24 +38,28 @@ const COMPARATOR = {
  * @strict
  */
 export function sortBy<T>(
-  ...sortRules: Readonly<NonEmptyArray<SortRule<T>>>
-): (array: ReadonlyArray<T>) => Array<T>;
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): (data: ReadonlyArray<T>) => Array<T>;
 
 /**
- * Sorts the list according to the supplied functions and directions.
- * Sorting is based on a native `sort` function. It's not guaranteed to be stable.
+ * Sorts `data` using the provided ordering rules. The `sort` is done via the native `Array.prototype.sort` but is performed on a shallow copy of the array to avoid mutating the original data.
  *
- * Directions are applied to functions in order and default to ascending if not specified.
+ * To maintain the shape of more complex inputs (like non-empty arrays, tuples, etc...) use the `strict` variant.
  *
- * If the input array is more complex (non-empty array, tuple, etc...) use the
- * strict mode to maintain it's shape.
+ * There are several other functions that take order rules and **bypass** the need to sort the array first (in *O(nlogn)* time):
+ * * `firstBy` === `first(sortBy(data, ...rules))`, O(n).
+ * * `takeFirstBy` === `take(sortBy(data, ...rules), k)`, O(nlogk).
+ * * `dropFirstBy` === `drop(sortBy(data, ...rules), k)`, O(nlogk).
+ * * `nthBy` === `sortBy(data, ...rules).at(k)`, O(n).
+ * * `rankBy` === `sortedIndex(sortBy(data, ...rules), item)`, O(n).
+ * Refer to the docs for more details.
  *
- * @param array the array to sort
- * @param sortRule main sort rule
- * @param additionalSortRules subsequent sort rules (these are only relevant when two items are equal based on the previous sort rule)
+ * @param data - The input array.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @return - A shallow copy of the input array sorted by the provided rules.
  * @signature
- *    R.sortBy(array, sortRule, ...additionalSortRules)
- *    R.sortBy.strict(array, sortRule, ...additionalSortRules)
+ *    R.sortBy(data, ...rules)
+ *    R.sortBy.strict(data, ...rules)
  * @example
  *    R.sortBy(
  *      [{ a: 1 }, { a: 3 }, { a: 7 }, { a: 2 }],
@@ -98,95 +94,28 @@ export function sortBy<T>(
  */
 export function sortBy<T>(
   array: ReadonlyArray<T>,
-  ...sortRules: Readonly<NonEmptyArray<SortRule<T>>>
+  ...sortRules: Readonly<NonEmptyArray<OrderRule<T>>>
 ): Array<T>;
 
-export function sortBy<T>(
-  arrayOrSortRule: ReadonlyArray<T> | SortRule<T>,
-  ...sortRules: ReadonlyArray<SortRule<T>>
-): unknown {
-  const args = isSortRule(arrayOrSortRule)
-    ? // *data-last invocation*: put all sort rules into a single array to be
-      // passed as the first param.
-      [[arrayOrSortRule, ...sortRules]]
-    : // *data-first invocation*: put the arrayOrSort (which is array now) as
-      // the first param, and all the sorts (as an array) into the second param.
-      // `purry` would pick the right "flavour" based on the length of the
-      // params tuple.
-      [arrayOrSortRule, sortRules];
-
-  return purry(_sortBy, args);
-}
-
-function isSortRule<T>(x: ReadonlyArray<T> | SortRule<T>): x is SortRule<T> {
-  if (typeof x === 'function') {
-    // must be a SortProjection
-    return true;
-  }
-
-  const [maybeProjection, maybeDirection, ...rest] = x;
-
-  if (rest.length > 0) {
-    // Not a SortPair if we have more stuff in the array
-    return false;
-  }
-
-  return (
-    typeof maybeProjection === 'function' &&
-    ALL_DIRECTIONS.indexOf(maybeDirection as Direction) !== -1
-  );
+export function sortBy(): unknown {
+  return purryOrderRules(_sortBy, arguments);
 }
 
 const _sortBy = <T>(
-  array: ReadonlyArray<T>,
-  sorts: Readonly<NonEmptyArray<SortRule<T>>>
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>
 ): Array<T> =>
   // Sort is done in-place so we need to copy the array.
-  [...array].sort(comparer(...sorts));
-
-function comparer<T>(
-  primaryRule: SortRule<T>,
-  secondaryRule?: SortRule<T>,
-  ...otherRules: ReadonlyArray<SortRule<T>>
-): (a: T, b: T) => number {
-  const projector =
-    typeof primaryRule === 'function' ? primaryRule : primaryRule[0];
-
-  const direction = typeof primaryRule === 'function' ? 'asc' : primaryRule[1];
-  const comparator = COMPARATOR[direction];
-
-  const nextComparer =
-    secondaryRule === undefined
-      ? undefined
-      : comparer(secondaryRule, ...otherRules);
-
-  return (a, b) => {
-    const projectedA = projector(a);
-    const projectedB = projector(b);
-
-    if (comparator(projectedA, projectedB)) {
-      return 1;
-    }
-
-    if (comparator(projectedB, projectedA)) {
-      return -1;
-    }
-
-    // The elements are equal base on the current comparator and projection. So
-    // we need to check the elements using the next comparer, if one exists,
-    // otherwise we consider them as true equal (returning 0).
-    return nextComparer?.(a, b) ?? 0;
-  };
-}
+  [...data].sort(compareFn);
 
 interface Strict {
   <T extends IterableContainer>(
-    ...sortRules: Readonly<NonEmptyArray<SortRule<T[number]>>>
+    ...sortRules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
   ): (array: T) => SortedBy<T>;
 
   <T extends IterableContainer>(
     array: T,
-    ...sortRules: Readonly<NonEmptyArray<SortRule<T[number]>>>
+    ...sortRules: Readonly<NonEmptyArray<OrderRule<T[number]>>>
   ): SortedBy<T>;
 }
 
diff --git a/src/takeFirstBy.ts b/src/takeFirstBy.ts
new file mode 100644
index 0000000..ffdbf83
--- /dev/null
+++ b/src/takeFirstBy.ts
@@ -0,0 +1,74 @@
+import { heapify, heapMaybeInsert } from './_heap';
+import { OrderRule, purryOrderRulesWithArgument } from './_purryOrderRules';
+import type { CompareFunction, NonEmptyArray } from './_types';
+
+/**
+ * Take the first `n` items from `data` based on the provided ordering criteria. This allows you to avoid sorting the array before taking the items. The complexity of this function is *O(Nlogn)* where `N` is the length of the array.
+ *
+ * For the opposite operation (to drop `n` elements) see `dropFirstBy`.
+ *
+ * @params data - the input array
+ * @params n - the number of items to take. If `n` is non-positive no items would be returned, if `n` is bigger then data.length a *clone* of `data` would be returned.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns a subset of the input array.
+ * @signature
+ *   R.takeFirstBy(data, n, ...rules);
+ * @example
+ *   R.takeFirstBy(['aa', 'aaaa', 'a', 'aaa'], 2, x => x.length); // => ['a', 'aa']
+ * @dataFirst
+ * @category Array
+ */
+export function takeFirstBy<T>(
+  data: ReadonlyArray<T>,
+  n: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): Array<T>;
+
+/**
+ * Take the first `n` items from `data` based on the provided ordering criteria. This allows you to avoid sorting the array before taking the items. The complexity of this function is *O(Nlogn)* where `N` is the length of the array.
+ *
+ * For the opposite operation (to drop `n` elements) see `dropFirstBy`.
+ *
+ * @params data - the input array
+ * @params n - the number of items to take. If `n` is non-positive no items would be returned, if `n` is bigger then data.length a *clone* of `data` would be returned.
+ * @param rules - A variadic array of order rules defining the sorting criteria. Each order rule is a projection function that extracts a comparable value from the data. Sorting is based on these extracted values using the native `<` and `>` operators. Earlier rules take precedence over later ones. Use the syntax `[projection, "desc"]` for descending order.
+ * @returns a subset of the input array.
+ * @signature
+ *   R.takeFirstBy(n, ...rules)(data);
+ * @example
+ *   R.pipe(['aa', 'aaaa', 'a', 'aaa'], R.takeFirstBy(2, x => x.length)); // => ['a', 'aa']
+ * @dataLast
+ * @category Array
+ */
+export function takeFirstBy<T>(
+  n: number,
+  ...rules: Readonly<NonEmptyArray<OrderRule<T>>>
+): (data: ReadonlyArray<T>) => Array<T>;
+
+export function takeFirstBy(): unknown {
+  return purryOrderRulesWithArgument(takeFirstByImplementation, arguments);
+}
+
+function takeFirstByImplementation<T>(
+  data: ReadonlyArray<T>,
+  compareFn: CompareFunction<T>,
+  n: number
+): Array<T> {
+  if (n <= 0) {
+    return [];
+  }
+
+  if (n >= data.length) {
+    return [...data];
+  }
+
+  const heap = data.slice(0, n);
+  heapify(heap, compareFn);
+
+  const rest = data.slice(n);
+  for (const item of rest) {
+    heapMaybeInsert(heap, compareFn, item);
+  }
+
+  return heap;
+}


Test patch:
diff --git a/src/dropFirstBy.test.ts b/src/dropFirstBy.test.ts
new file mode 100644
index 0000000..ad0720c
--- /dev/null
+++ b/src/dropFirstBy.test.ts
@@ -0,0 +1,133 @@
+import { dropFirstBy } from './dropFirstBy';
+import { identity } from './identity';
+import { pipe } from './pipe';
+
+describe('runtime (dataFirst)', () => {
+  it('works', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(dropFirstBy(data, 2, identity)).toEqual([5, 6, 4, 3, 7]);
+  });
+
+  it('handles empty arrays gracefully', () => {
+    const data: Array<number> = [];
+    expect(dropFirstBy(data, 1, identity)).toHaveLength(0);
+  });
+
+  it('handles negative numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(dropFirstBy(data, -3, identity)).toHaveLength(data.length);
+  });
+
+  it('handles overflowing numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(dropFirstBy(data, 100, identity)).toHaveLength(0);
+  });
+
+  it('clones the input when needed', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    const result = dropFirstBy(data, 0, identity);
+    expect(result).not.toBe(data);
+    expect(result).toEqual(data);
+  });
+
+  it('works with complex compare rules', () => {
+    const data = [
+      'a',
+      'aaa',
+      'bbbbb',
+      'aa',
+      'bb',
+      'bbb',
+      'bbbb',
+      'aaaa',
+      'b',
+      'aaaaa',
+    ];
+    expect(dropFirstBy(data, 3, x => x.length, identity)).toEqual([
+      'bbbbb',
+      'aaa',
+      'bbb',
+      'bbbb',
+      'aaaa',
+      'bb',
+      'aaaaa',
+    ]);
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  it('works', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        dropFirstBy(2, x => x)
+      )
+    ).toEqual([5, 6, 4, 3, 7]);
+  });
+
+  it('handles empty arrays gracefully', () => {
+    const data: Array<number> = [];
+    expect(
+      pipe(
+        data,
+        dropFirstBy(1, x => x)
+      )
+    ).toHaveLength(0);
+  });
+
+  it('handles negative numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        dropFirstBy(-3, x => x)
+      )
+    ).toHaveLength(data.length);
+  });
+
+  it('handles overflowing numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        dropFirstBy(100, x => x)
+      )
+    ).toHaveLength(0);
+  });
+
+  it('clones the data when needed', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    const result = pipe(
+      data,
+      dropFirstBy(0, x => x)
+    );
+    expect(result).not.toBe(data);
+    expect(result).toEqual(data);
+  });
+
+  it('works with complex compare rules', () => {
+    const data = [
+      'a',
+      'aaa',
+      'bbbbb',
+      'aa',
+      'bb',
+      'bbb',
+      'bbbb',
+      'aaaa',
+      'b',
+      'aaaaa',
+    ];
+    expect(
+      pipe(
+        data,
+        dropFirstBy(
+          3,
+          x => x.length,
+          x => x
+        )
+      )
+    ).toEqual(['bbbbb', 'aaa', 'bbb', 'bbbb', 'aaaa', 'bb', 'aaaaa']);
+  });
+});
diff --git a/src/firstBy.test.ts b/src/firstBy.test.ts
new file mode 100644
index 0000000..da53f75
--- /dev/null
+++ b/src/firstBy.test.ts
@@ -0,0 +1,155 @@
+import { NonEmptyArray } from './_types';
+import { firstBy } from './firstBy';
+import { identity } from './identity';
+import { pipe } from './pipe';
+
+describe('runtime (dataFirst)', () => {
+  it('returns undefined on empty', () => {
+    expect(firstBy([], identity)).toBeUndefined();
+  });
+
+  it('returns the item on a single item array', () => {
+    expect(firstBy([1], identity)).toBe(1);
+  });
+
+  it('finds the minimum', () => {
+    expect(firstBy([2, 1, 4, 3, 5], identity)).toBe(1);
+  });
+
+  it('finds the minimum with a non-trivial order rule', () => {
+    expect(firstBy(['aa', 'a', 'aaaa', 'aaa', 'aaaaa'], x => x.length)).toBe(
+      'a'
+    );
+  });
+
+  it("finds the max with 'desc' order rules", () => {
+    expect(firstBy([2, 1, 4, 3, 5], [identity, 'desc'])).toBe(5);
+  });
+
+  it("finds the max with non-trivial 'desc' order rules", () => {
+    expect(
+      firstBy(['aa', 'a', 'aaaa', 'aaa', 'aaaaa'], [identity, 'desc'])
+    ).toBe('aaaaa');
+  });
+
+  it('breaks ties with multiple order rules', () => {
+    const data = ['a', 'bb', 'b', 'aaaa', 'bbb', 'aa', 'aaa', 'bbbb'];
+    expect(firstBy(data, x => x.length, identity)).toBe('a');
+    expect(firstBy(data, [x => x.length, 'desc'], identity)).toBe('aaaa');
+    expect(firstBy(data, x => x.length, [identity, 'desc'])).toBe('b');
+    expect(firstBy(data, [x => x.length, 'desc'], [identity, 'desc'])).toBe(
+      'bbbb'
+    );
+  });
+
+  it('can compare strings', () => {
+    expect(firstBy(['b', 'a', 'c'], identity)).toBe('a');
+  });
+
+  it('can compare numbers', () => {
+    expect(firstBy([2, 1, 3], identity)).toBe(1);
+  });
+
+  it('can compare booleans', () => {
+    expect(firstBy([true, false, true, true, false], identity)).toBe(false);
+  });
+
+  it('can compare valueOfs', () => {
+    expect(firstBy([new Date(), new Date(1), new Date(2)], identity)).toEqual(
+      new Date(1)
+    );
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  it('returns undefined on empty', () => {
+    expect(pipe([], firstBy(identity))).toBeUndefined();
+  });
+
+  it('returns the item on a single item array', () => {
+    expect(pipe([1], firstBy(identity))).toBe(1);
+  });
+
+  it('finds the minimum', () => {
+    expect(pipe([2, 1, 4, 3, 5], firstBy(identity))).toBe(1);
+  });
+
+  it('finds the minimum with a non-trivial order rule', () => {
+    expect(
+      pipe(
+        ['aa', 'a', 'aaaa', 'aaa', 'aaaaa'],
+        firstBy(x => x.length)
+      )
+    ).toBe('a');
+  });
+
+  it("finds the max with 'desc' order rules", () => {
+    expect(pipe([2, 1, 4, 3, 5], firstBy([identity, 'desc']))).toBe(5);
+  });
+
+  it("finds the max with non-trivial 'desc' order rules", () => {
+    expect(
+      pipe(['aa', 'a', 'aaaa', 'aaa', 'aaaaa'], firstBy([identity, 'desc']))
+    ).toBe('aaaaa');
+  });
+
+  it('breaks ties with multiple order rules', () => {
+    const data = ['a', 'bb', 'b', 'aaaa', 'bbb', 'aa', 'aaa', 'bbbb'];
+    expect(
+      pipe(
+        data,
+        firstBy(x => x.length, identity)
+      )
+    ).toBe('a');
+    expect(pipe(data, firstBy([x => x.length, 'desc'], identity))).toBe('aaaa');
+    expect(
+      pipe(
+        data,
+        firstBy(x => x.length, [identity, 'desc'])
+      )
+    ).toBe('b');
+    expect(
+      pipe(data, firstBy([(x: string) => x.length, 'desc'], [identity, 'desc']))
+    ).toBe('bbbb');
+  });
+
+  it('can compare strings', () => {
+    expect(pipe(['b', 'a', 'c'], firstBy(identity))).toBe('a');
+  });
+
+  it('can compare numbers', () => {
+    expect(pipe([2, 1, 3], firstBy(identity))).toBe(1);
+  });
+
+  it('can compare booleans', () => {
+    expect(pipe([true, false, true, true, false], firstBy(identity))).toBe(
+      false
+    );
+  });
+
+  it('can compare valueOfs', () => {
+    expect(
+      pipe([new Date(), new Date(1), new Date(2)], firstBy(identity))
+    ).toEqual(new Date(1));
+  });
+});
+
+describe('typing', () => {
+  it('can return undefined on arrays', () => {
+    const data: ReadonlyArray<number> = [1, 2, 3];
+    const result = firstBy(data, identity);
+    expectTypeOf(result).toBeNullable();
+  });
+
+  it("can't return undefined on non-empty array", () => {
+    const data: NonEmptyArray<number> = [1, 2, 3];
+    const result = firstBy(data, identity);
+    expectTypeOf(result).not.toBeNullable();
+  });
+
+  it('only returns null on the empty array', () => {
+    const data = [] as const;
+    const result = firstBy(data, identity);
+    expectTypeOf(result).toBeUndefined();
+  });
+});
diff --git a/src/nthBy.test.ts b/src/nthBy.test.ts
new file mode 100644
index 0000000..6cfca5f
--- /dev/null
+++ b/src/nthBy.test.ts
@@ -0,0 +1,127 @@
+import { nthBy } from './nthBy';
+import { identity } from './identity';
+import { pipe } from './pipe';
+
+describe('runtime (dataFirst)', () => {
+  it('works', () => {
+    const data = [2, 1, 3];
+    expect(nthBy(data, 0, identity)).toEqual(1);
+    expect(nthBy(data, 1, identity)).toEqual(2);
+    expect(nthBy(data, 2, identity)).toEqual(3);
+  });
+
+  it('handles negative indexes', () => {
+    const data = [2, 1, 3];
+    expect(nthBy(data, -1, identity)).toEqual(3);
+    expect(nthBy(data, -2, identity)).toEqual(2);
+    expect(nthBy(data, -3, identity)).toEqual(1);
+  });
+
+  it('handles overflows gracefully', () => {
+    expect(nthBy([1, 2, 3], 100, identity)).toBeUndefined();
+    expect(nthBy([1, 2, 3], -100, identity)).toBeUndefined();
+  });
+
+  it('works with complex order rules', () => {
+    const data = ['aaaa', 'b', 'bb', 'a', 'aaa', 'bbbb', 'aa', 'bbb'] as const;
+    expect(nthBy(data, 0, a => a.length, identity)).toEqual('a');
+    expect(nthBy(data, 1, a => a.length, identity)).toEqual('b');
+    expect(nthBy(data, 2, a => a.length, identity)).toEqual('aa');
+    expect(nthBy(data, 3, a => a.length, identity)).toEqual('bb');
+    expect(nthBy(data, 4, a => a.length, identity)).toEqual('aaa');
+    expect(nthBy(data, 5, a => a.length, identity)).toEqual('bbb');
+    expect(nthBy(data, 6, a => a.length, identity)).toEqual('aaaa');
+    expect(nthBy(data, 7, a => a.length, identity)).toEqual('bbbb');
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  it('works', () => {
+    const data = [2, 1, 3];
+    expect(pipe(data, nthBy(0, identity))).toEqual(1);
+    expect(pipe(data, nthBy(1, identity))).toEqual(2);
+    expect(pipe(data, nthBy(2, identity))).toEqual(3);
+  });
+
+  it('handles negative indexes', () => {
+    const data = [2, 1, 3];
+    expect(pipe(data, nthBy(-1, identity))).toEqual(3);
+    expect(pipe(data, nthBy(-2, identity))).toEqual(2);
+    expect(pipe(data, nthBy(-3, identity))).toEqual(1);
+  });
+
+  it('handles overflows gracefully', () => {
+    expect(pipe([1, 2, 3], nthBy(100, identity))).toBeUndefined();
+    expect(pipe([1, 2, 3], nthBy(-100, identity))).toBeUndefined();
+  });
+
+  it('works with complex order rules', () => {
+    const data = ['aaaa', 'b', 'bb', 'a', 'aaa', 'bbbb', 'aa', 'bbb'] as const;
+    expect(
+      pipe(
+        data,
+        nthBy(0, a => a.length, identity)
+      )
+    ).toEqual('a');
+    expect(
+      pipe(
+        data,
+        nthBy(1, a => a.length, identity)
+      )
+    ).toEqual('b');
+    expect(
+      pipe(
+        data,
+        nthBy(2, a => a.length, identity)
+      )
+    ).toEqual('aa');
+    expect(
+      pipe(
+        data,
+        nthBy(3, a => a.length, identity)
+      )
+    ).toEqual('bb');
+    expect(
+      pipe(
+        data,
+        nthBy(4, a => a.length, identity)
+      )
+    ).toEqual('aaa');
+    expect(
+      pipe(
+        data,
+        nthBy(5, a => a.length, identity)
+      )
+    ).toEqual('bbb');
+    expect(
+      pipe(
+        data,
+        nthBy(6, a => a.length, identity)
+      )
+    ).toEqual('aaaa');
+    expect(
+      pipe(
+        data,
+        nthBy(7, a => a.length, identity)
+      )
+    ).toEqual('bbbb');
+  });
+});
+
+describe('typing', () => {
+  it('works with regular arrays', () => {
+    const result = nthBy([1, 2, 3], 0, identity);
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+
+  it('works with negative indices', () => {
+    const result = nthBy([1, 2, 3], -1, identity);
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+
+  it('works with tuples', () => {
+    const data: [string, boolean, number] = ['a', true, 1];
+    const result = nthBy(data, 1, identity);
+    expectTypeOf(result).toEqualTypeOf<string | number | boolean | undefined>();
+  });
+});
diff --git a/src/rankBy.test.ts b/src/rankBy.test.ts
new file mode 100644
index 0000000..4982d88
--- /dev/null
+++ b/src/rankBy.test.ts
@@ -0,0 +1,28 @@
+import { identity } from './identity';
+import { rankBy } from './rankBy';
+
+describe('runtime (dataFirst)', () => {
+  it('works trivially with empty arrays', () => {
+    expect(rankBy([], 1, identity)).toBe(0);
+  });
+
+  it('maintains the rank for items already in the array', () => {
+    const data = [5, 1, 3] as const;
+    const sorted = [...data].sort();
+    for (let i = 0; i < sorted.length; i += 1) {
+      expect(rankBy(data, sorted[i], identity)).toBe(i);
+    }
+  });
+
+  it('can rank items not in the array', () => {
+    const data = [5, 1, 3] as const;
+    expect(rankBy(data, 0, identity)).toBe(0);
+    expect(rankBy(data, 2, identity)).toBe(1);
+    expect(rankBy(data, 4, identity)).toBe(2);
+  });
+
+  it('finds items ranked at the end of the array', () => {
+    const data = [5, 1, 3] as const;
+    expect(rankBy(data, 6, identity)).toBe(3);
+  });
+});
diff --git a/src/takeFirstBy.test.ts b/src/takeFirstBy.test.ts
new file mode 100644
index 0000000..13f4b21
--- /dev/null
+++ b/src/takeFirstBy.test.ts
@@ -0,0 +1,129 @@
+import { identity } from './identity';
+import { pipe } from './pipe';
+import { takeFirstBy } from './takeFirstBy';
+
+describe('runtime (dataFirst)', () => {
+  it('works', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(takeFirstBy(data, 2, identity)).toEqual([2, 1]);
+  });
+
+  it('handles empty arrays gracefully', () => {
+    const data: Array<number> = [];
+    expect(takeFirstBy(data, 1, identity)).toHaveLength(0);
+  });
+
+  it('handles negative numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(takeFirstBy(data, -3, identity)).toHaveLength(0);
+  });
+
+  it('handles overflowing numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(takeFirstBy(data, 100, identity)).toHaveLength(data.length);
+  });
+
+  it('clones the array when needed', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    const result = takeFirstBy(data, 100, identity);
+    expect(result).not.toBe(data);
+    expect(result).toEqual(data);
+  });
+
+  it('works with complex compare rules', () => {
+    const data = [
+      'a',
+      'aaa',
+      'bbbbb',
+      'aa',
+      'bb',
+      'bbb',
+      'bbbb',
+      'aaaa',
+      'b',
+      'aaaaa',
+    ];
+    expect(takeFirstBy(data, 3, x => x.length, identity)).toEqual([
+      'aa',
+      'b',
+      'a',
+    ]);
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  it('works', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        takeFirstBy(2, x => x)
+      )
+    ).toEqual([2, 1]);
+  });
+
+  it('handles empty arrays gracefully', () => {
+    const data: Array<number> = [];
+    expect(
+      pipe(
+        data,
+        takeFirstBy(1, x => x)
+      )
+    ).toHaveLength(0);
+  });
+
+  it('handles negative numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        takeFirstBy(-3, x => x)
+      )
+    ).toHaveLength(0);
+  });
+
+  it('handles overflowing numbers gracefully', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    expect(
+      pipe(
+        data,
+        takeFirstBy(100, x => x)
+      )
+    ).toHaveLength(data.length);
+  });
+
+  it('clones the array when needed', () => {
+    const data = [4, 5, 1, 6, 2, 3, 7];
+    const result = pipe(
+      data,
+      takeFirstBy(100, x => x)
+    );
+    expect(result).not.toBe(data);
+    expect(result).toEqual(data);
+  });
+
+  it('works with complex compare rules', () => {
+    const data = [
+      'a',
+      'aaa',
+      'bbbbb',
+      'aa',
+      'bb',
+      'bbb',
+      'bbbb',
+      'aaaa',
+      'b',
+      'aaaaa',
+    ];
+    expect(
+      pipe(
+        data,
+        takeFirstBy(
+          3,
+          x => x.length,
+          x => x
+        )
+      )
+    ).toEqual(['aa', 'b', 'a']);
+  });
+});