Repository: remeda/remeda
Pull request #884 (2024-10-09 18:33:27)

feat(median): Add median
Related to #874. Please let me know if any further adjustments are needed.

---

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 `docs/src/content/mapping` for both Lodash and Ramda.

---


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

Latest deployment of this branch, based on commit cdafccc2ceee809b8a8ffeb8f569fbef8b1ade13:

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

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

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


```diff
@@            Coverage Diff            @@
##              main      #884   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          317       319    +2     
  Lines        10820     10868   +48     
  Branches      2664      2680   +16     
=========================================
+ Hits         10820     10868   +48     
```

</details>

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

grigorischristainas: @eranhirsch that's cool I didn't know QuickSelect, thanks! Your suggestion about `nthBy` would work fine for odd length arrays. However, for even length arrays we need the average of the two middle elements. Please correct me if I am mistaken but in that case it would look something like this: 
```
const middleIndex = Math.floor(data.length / 2)
const median =
      (nthBy(data, middleIndex, identity()) +
        nthBy(data, middleIndex - 1, identity())) /
      2;
```
In any case, if you think that the `median` is not necessary, we can close this PR.
eranhirsch: I trust @cjquines's opinion on this as he has a better intuiton than me on how our users will see this  
cjquines: i do think median of even-length arrays is an annoying enough edge case that i want a median function
eranhirsch: Yesterday, after posting my comments, I was curious to learn more about Quickselect and how different libraries implement their median utility function. I learned that the built-in sort is actually extremely efficient (this is the case for v8, Safari, but not Firefox!, and also Python and Java, amongst others), so the vast majority of libraries implement median by sorting and not by using Quickselect.

With median requiring either an **adapted** quickselect algorithm to find two items (N/2 and N/2+1 when N%2 === 0) to prevent running quickselect twice, your current implementation is more efficient for most practical use cases, and there's no need to change it.
eranhirsch: https://en.wikipedia.org/wiki/Timsort
grigorischristainas: > My biggest concern right now is the return value for bigint arrays of even length. Currently we round down so that the result remains a bigint (`median([1n, 2n]) //=> 1n`). We can return a `number` instead, but that would mean that `median([1_000_000_000_000_000_000_000n])` would overflow.
> 
> I don't know what the right answer is here...

I agree this is a valid concern and we should mention it in the docs. We could also add a note about the limitations of converting large bigint values to number due to overflow risks. Also, a link to [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#operators) would be helpful for users who want to understand the underlying behavior better.
eranhirsch: Just remember that timsort is optimized for real world cases and might perform worse with random data. 
grigorischristainas: @cjquines @eranhirsch I integrated your suggestions from your previous review comments and also enhanced docs about `bigint` limitations. Could you please let me know if and how we will proceed with the `timsort` implementation?
eranhirsch: I think we should use sort because it's the more standard approach with less moving parts on our side to maintain, and i think we should drop bigint support as function has a well defined mathematical meaning that doesn't include truncating the results so might lead to misunderstands and errors. Documenting it isn't enough to protect people from making mistakes. 
grigorischristainas: I agree, we should probably do the same for `mean`. I can open a separate PR for that.

Patch:
diff --git a/docs/src/content/mapping/lodash/partial.md b/docs/src/content/mapping/lodash/partial.md
new file mode 100644
index 0000000..1b01984
--- /dev/null
+++ b/docs/src/content/mapping/lodash/partial.md
@@ -0,0 +1,24 @@
+---
+category: Function
+remeda: partialBind
+---
+
+The placeholder argument `_` is not supported. Some cases can be written as `partialLastBind`; otherwise, use an arrow function.
+
+```ts
+function greet(greeting, firstName, lastName) {
+  return greeting + " " + firstName + " " + lastName;
+}
+
+// Lodash
+_.partial(greet, _, "john", "doe");
+_.partial(greet, "hi", _, "doe");
+
+// Remeda
+partialLastBind(greet, "john", "doe");
+// (not supported)
+
+// Native
+(greeting) => greet(greeting, "john", "doe");
+(firstName) => greet("hi", firstName, "doe");
+```
diff --git a/docs/src/content/mapping/lodash/partialRight.md b/docs/src/content/mapping/lodash/partialRight.md
new file mode 100644
index 0000000..6b774c4
--- /dev/null
+++ b/docs/src/content/mapping/lodash/partialRight.md
@@ -0,0 +1,24 @@
+---
+category: Function
+remeda: partialLastBind
+---
+
+The placeholder argument `_` is not supported. Some cases can be written as `partialBind`; otherwise, use an arrow function.
+
+```ts
+function greet(greeting, firstName, lastName) {
+  return greeting + " " + firstName + " " + lastName;
+}
+
+// Lodash
+_.partialRight(greet, "hi", "john", _);
+_.partialRight(greet, "hi", _, "doe");
+
+// Remeda
+partialBind(greet, ["hi", "john"]);
+// (not supported)
+
+// Native
+(lastName) => greet("hi", "john", lastName);
+(firstName) => greet("hi", firstName, "doe");
+```
diff --git a/docs/src/content/mapping/ramda/__MISSING.md b/docs/src/content/mapping/ramda/__MISSING.md
index 6c85119..7fa79b4 100644
--- a/docs/src/content/mapping/ramda/__MISSING.md
+++ b/docs/src/content/mapping/ramda/__MISSING.md
@@ -34,9 +34,7 @@
 - of
 - on
 - otherwise
-- partial
 - partialObject
-- partialRight
 - pipeWith
 - promap
 - thunkify
@@ -108,7 +106,6 @@
 # Math
 
 - mathMod
-- median
 - modulo
 
 # Object
diff --git a/docs/src/content/mapping/ramda/median.md b/docs/src/content/mapping/ramda/median.md
new file mode 100644
index 0000000..c575e82
--- /dev/null
+++ b/docs/src/content/mapping/ramda/median.md
@@ -0,0 +1,4 @@
+---
+category: Math
+remeda: median
+---
diff --git a/docs/src/content/mapping/ramda/partial.md b/docs/src/content/mapping/ramda/partial.md
new file mode 100644
index 0000000..8a3e058
--- /dev/null
+++ b/docs/src/content/mapping/ramda/partial.md
@@ -0,0 +1,18 @@
+---
+category: Function
+remeda: partialBind
+---
+
+Remeda's `partialBind` takes a variadic list of arguments instead of an array.
+
+```ts
+function greet(greeting, firstName, lastName) {
+  return greeting + " " + firstName + " " + lastName;
+}
+
+// Ramda
+partial(greet, ["hi", "john"]);
+
+// Remeda
+partialBind(greet, "hi", "john");
+```
diff --git a/docs/src/content/mapping/ramda/partialRight.md b/docs/src/content/mapping/ramda/partialRight.md
new file mode 100644
index 0000000..8115b68
--- /dev/null
+++ b/docs/src/content/mapping/ramda/partialRight.md
@@ -0,0 +1,18 @@
+---
+category: Function
+remeda: partialLastBind
+---
+
+Remeda's `partialLastBind` takes a variadic list of arguments instead of an array.
+
+```ts
+function greet(greeting, firstName, lastName) {
+  return greeting + " " + firstName + " " + lastName;
+}
+
+// Ramda
+partialRight(greet, ["john", "doe"]);
+
+// Remeda
+partialLastBind(greet, "john", "doe");
+```
diff --git a/src/fromEntries.ts b/src/fromEntries.ts
index 327c92b..50e6155 100644
--- a/src/fromEntries.ts
+++ b/src/fromEntries.ts
@@ -1,7 +1,12 @@
 import type { Simplify } from "type-fest";
-import type { IterableContainer } from "./internal/types";
+import type { IterableContainer, RemedaTypeError } from "./internal/types";
 import { purry } from "./purry";
 
+type FromEntriesError<Message extends string> = RemedaTypeError<
+  "fromEntries",
+  Message
+>;
+
 type Entry<Key extends PropertyKey = PropertyKey, Value = unknown> = readonly [
   key: Key,
   value: Value,
@@ -23,14 +28,14 @@ type FromEntries<Entries> = Entries extends readonly [
     ? FromEntriesTuple<Last, Head>
     : Entries extends IterableContainer<Entry>
       ? FromEntriesArray<Entries>
-      : "ERROR: Entries array-like could not be inferred";
+      : FromEntriesError<"Entries array-like could not be inferred">;
 
 // For strict tuples we build the result by intersecting each entry as a record
 // between it's key and value, recursively. The recursion goes through our main
 // type so that we support tuples which also contain rest parts.
 type FromEntriesTuple<E, Rest> = E extends Entry
   ? FromEntries<Rest> & Record<E[0], E[1]>
-  : "ERROR: Array-like contains a non-entry element";
+  : FromEntriesError<"Array-like contains a non-entry element">;
 
 // For the array case we also need to handle what kind of keys it defines:
 // 1. If it defines a *broad* key (one that has an infinite set of values, like
diff --git a/src/index.ts b/src/index.ts
index a7e5a97..3663fa4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -79,6 +79,7 @@ export * from "./mapValues";
 export * from "./mapWithFeedback";
 export * from "./mean";
 export * from "./meanBy";
+export * from "./median";
 export * from "./merge";
 export * from "./mergeAll";
 export * from "./mergeDeep";
@@ -89,6 +90,8 @@ export * from "./omit";
 export * from "./omitBy";
 export * from "./once";
 export * from "./only";
+export * from "./partialBind";
+export * from "./partialLastBind";
 export * from "./partition";
 export * from "./pathOr";
 export * from "./pick";
diff --git a/src/internal/types.ts b/src/internal/types.ts
index 369f368..d4237f3 100644
--- a/src/internal/types.ts
+++ b/src/internal/types.ts
@@ -1,5 +1,6 @@
 import type {
   EmptyObject,
+  IsEqual,
   IfNever,
   IsAny,
   IsLiteral,
@@ -13,6 +14,27 @@ import type {
   Tagged,
 } from "type-fest";
 
+/* v8 ignore next 2 */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars -- This symbol should only be used for RemedaTypeError
+const RemedaErrorSymbol = Symbol("RemedaError");
+
+/**
+ * Used for reporting type errors in a more useful way than `never`. Use
+ * numbers for things that should never happen.
+ */
+export type RemedaTypeError<
+  FunctionName extends string,
+  Message extends string | number,
+> = Message extends string
+  ? Tagged<
+      typeof RemedaErrorSymbol,
+      `RemedaTypeError(${FunctionName}): ${Message}.`
+    >
+  : RemedaTypeError<
+      FunctionName,
+      `Internal error ${Message}. Please open a Remeda GitHub issue.`
+    >;
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars -- We want to confine the typing to a specific symbol
 declare const TAG_NAME_BRANDED_RETURN: unique symbol;
 
@@ -255,9 +277,9 @@ export type NTuple<
 > = Result["length"] extends N ? Result : NTuple<T, N, [...Result, T]>;
 
 /**
- * Takes an array and returns the types that make up it's parts. The suffix is
- * anything before the rest parameter (if any), the prefix is anything after the
- * rest parameter (if any), and the item is the type of the rest parameter.
+ * Takes an array and returns the types that make up its parts. The prefix is
+ * anything before the rest parameter (if any), the suffix is anything after
+ * the rest parameter (if any), and the item is the type of the rest parameter.
  *
  * The output could be used to reconstruct the input: `[
  *   ...TupleParts<T>["prefix"],
@@ -273,13 +295,66 @@ export type TupleParts<
   ? TupleParts<Tail, [...Prefix, Head], Suffix>
   : T extends readonly [...infer Head, infer Tail]
     ? TupleParts<Head, Prefix, [Tail, ...Suffix]>
-    : T extends ReadonlyArray<infer Item>
-      ? {
-          prefix: Prefix;
-          item: Item;
-          suffix: Suffix;
-        }
-      : never;
+    : // We need to distinguish between e.g. [number? ...Array<string>] and
+      // Array<number | string>.
+      IsTupleRestOnly<T> extends false
+      ? // This is the [number? ...Array<string>] case.
+        T extends readonly [(infer MaybeHead)?, ...infer Tail]
+        ? TupleParts<Tail, [...Prefix, MaybeHead?], Suffix>
+        : never
+      : // This is the Array<number | string> case.
+        T extends ReadonlyArray<infer Item>
+        ? {
+            prefix: Prefix;
+            item: Item;
+            suffix: Suffix;
+          }
+        : never;
+/** Helper type for `TupleParts`. Checks if T = ReadonlyArray<U> for some U. */
+type IsTupleRestOnly<T> = T extends readonly []
+  ? true
+  : T extends readonly [unknown?, ...infer Tail]
+    ? IsEqual<Readonly<T>, Readonly<Tail>>
+    : false;
+
+/** The union of all possible ways to write a tuple as [...left, ...right]. */
+export type TupleSplits<Tuple extends IterableContainer> =
+  // Use a distributive conditional type, in case T is a union:
+  Tuple extends infer T
+    ? TupleParts<T> extends {
+        prefix: infer Prefix extends ReadonlyArray<unknown>;
+        item: infer Item;
+        suffix: infer Suffix extends ReadonlyArray<unknown>;
+      }
+      ? // Three cases: split is in the prefix, in the item, or in the suffix.
+        | FixedTupleSplits<Prefix, [...CoercedArray<Item>, ...Suffix]>
+          | {
+              left: [...Prefix, ...CoercedArray<Item>];
+              right: [...CoercedArray<Item>, ...Suffix];
+            }
+          | (FixedTupleSplits<Suffix> extends infer U
+              ? U extends {
+                  left: infer L extends ReadonlyArray<unknown>;
+                  right: infer R;
+                }
+                ? { left: [...Prefix, ...CoercedArray<Item>, ...L]; right: R }
+                : never
+              : never)
+      : never
+    : never;
+/** Helper type for `TupleSplits`, for tuples without rest params. */
+type FixedTupleSplits<
+  L extends IterableContainer,
+  R extends IterableContainer = [],
+> =
+  | { left: L; right: R }
+  | (L extends readonly []
+      ? never
+      : L extends readonly [...infer LHead, infer LTail]
+        ? FixedTupleSplits<LHead, [LTail, ...R]>
+        : L extends readonly [...infer LHead, (infer LTail)?]
+          ? FixedTupleSplits<LHead, [LTail?, ...R]>
+          : never);
 
 /**
  * `never[]` and `[]` are not the same type, and in some cases they aren't
diff --git a/src/median.ts b/src/median.ts
new file mode 100644
index 0000000..17036e2
--- /dev/null
+++ b/src/median.ts
@@ -0,0 +1,78 @@
+import type { IterableContainer } from "./internal/types";
+import { purry } from "./purry";
+
+type Median<T extends IterableContainer<number>> =
+  | (T extends readonly [] ? never : number)
+  | (T extends readonly [unknown, ...Array<unknown>] ? never : undefined);
+
+/**
+ * Returns the median of the elements of an array.
+ *
+ * Only `number` arrays are supported, as `bigint` is unable to represent fractional values.
+ *
+ * IMPORTANT: The result for empty arrays would be `undefined`, regardless of
+ * the type of the array. This approach improves type-checking and ensures that
+ * cases where `NaN` might occur are handled properly. To avoid adding this to
+ * the return type for cases where the array is known to be non-empty you can use
+ * `hasAtLeast` or `isEmpty` to guard against this case.
+ *
+ * @param data - The array of numbers.
+ * @signature
+ *   R.median(data);
+ * @example
+ *   R.pipe([6, 10, 11], R.median()); // => 10
+ *   R.median([]); // => undefined
+ * @dataFirst
+ * @category Number
+ */
+export function median<T extends IterableContainer<number>>(data: T): Median<T>;
+
+/**
+ * Returns the median of the elements of an array.
+ *
+ * Only `number` arrays are supported, as `bigint` is unable to represent fractional values.
+ *
+ * IMPORTANT: The result for empty arrays would be `undefined`, regardless of
+ * the type of the array. This approach improves type-checking and ensures that
+ * cases where `NaN` might occur are handled properly. To avoid adding this to
+ * the return type for cases where the array is known to be non-empty you can use
+ * `hasAtLeast` or `isEmpty` to guard against this case.
+ *
+ * @signature
+ *   R.median()(data);
+ * @example
+ *   R.pipe([6, 10, 11], R.median()); // => 10
+ *   R.pipe([], R.median()); // => undefined
+ * @dataLast
+ * @category Number
+ */
+export function median(): <T extends IterableContainer<number>>(
+  data: T,
+) => Median<T>;
+
+export function median(...args: ReadonlyArray<unknown>): unknown {
+  return purry(medianImplementation, args);
+}
+
+const numberComparator = (a: number, b: number): number => a - b;
+
+function medianImplementation<T extends IterableContainer<number>>(
+  data: T,
+): T[number] | undefined {
+  if (data.length === 0) {
+    return undefined;
+  }
+
+  // TODO [2025-05-01]: When node 18 reaches end-of-life bump target lib to ES2023+ and use `Array.prototype.toSorted` here.
+  const sortedData = [...data].sort(numberComparator);
+
+  // For odd length, return the middle element
+  if (sortedData.length % 2 !== 0) {
+    return sortedData[(sortedData.length - 1) / 2];
+  }
+
+  // For even length, return the mean of the two middle elements
+  const middleIndex = sortedData.length / 2;
+
+  return (sortedData[middleIndex]! + sortedData[middleIndex - 1]!) / 2;
+}
diff --git a/src/partialBind.ts b/src/partialBind.ts
new file mode 100644
index 0000000..aa28f7a
--- /dev/null
+++ b/src/partialBind.ts
@@ -0,0 +1,77 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return --
+ * Function inference doesn't work when `unknown` is used as the parameters
+ * generic type, it **has** to be `any`.
+ */
+import type {
+  IterableContainer,
+  RemedaTypeError,
+  TupleSplits,
+} from "./internal/types";
+
+type PartialBindError<Message extends string | number> = RemedaTypeError<
+  "partialBind",
+  Message
+>;
+
+type TuplePrefix<T extends IterableContainer> = TupleSplits<T>["left"];
+
+type RemovePrefix<
+  T extends IterableContainer,
+  Prefix extends TuplePrefix<T>,
+> = Prefix extends readonly []
+  ? T
+  : T extends readonly [infer THead, ...infer TRest]
+    ? Prefix extends readonly [infer _PrefixHead, ...infer PrefixRest]
+      ? // PrefixHead extends THead.
+        RemovePrefix<TRest, PrefixRest>
+      : // Prefix (as a whole) extends ReadonlyArray<THead>.
+        // Prefix could possibly be empty, so this has to be THead?.
+        [THead?, ...RemovePrefix<TRest, Prefix>]
+    : // T has an optional or rest parameter last. If T is a parameter list,
+      // this can only happen if we have optional arguments or a rest param;
+      // both cases are similar.
+      T extends readonly [(infer _THead)?, ...infer TRest]
+      ? Prefix extends readonly [infer _PrefixHead, ...infer PrefixRest]
+        ? // PrefixHead extends THead.
+          RemovePrefix<TRest, PrefixRest>
+        : // Prefix (as a whole) extends [THead?, ...TRest].
+          TRest
+      : // We got passed a parameter list that isn't what we expected; this is
+        // an internal error.
+        PartialBindError<1>;
+
+/**
+ * Creates a function that calls `func` with `partial` put before the arguments
+ * it receives.
+ *
+ * Can be thought of as "freezing" some portion of a function's arguments,
+ * resulting in a new function with a simplified signature.
+ *
+ * @param func - The function to wrap.
+ * @param partial - The arguments to put before.
+ * @returns A partially bound function.
+ * @signature
+ *    R.partialBind(func, partial)
+ * @example
+ *    const fn = (x, y, z) => `${x}, ${y}, and ${z}`
+ *    const partialFn = R.partialBind(fn, [1, 2])
+ *    partialFn(3) // => 1, 2, and 3
+ *
+ *    const logWithPrefix = R.partialBind(console.log, ["[prefix]"])
+ *    logWithPrefix("hello") // => "[prefix] hello"
+ * @dataFirst
+ * @category Function
+ * @see partialLastBind
+ */
+export function partialBind<
+  F extends (...args: any) => any,
+  PrefixArgs extends TuplePrefix<Parameters<F>>,
+  RemovedPrefix extends RemovePrefix<Parameters<F>, PrefixArgs>,
+>(
+  func: F,
+  ...partial: PrefixArgs
+): (
+  ...rest: RemovedPrefix extends IterableContainer ? RemovedPrefix : never
+) => ReturnType<F> {
+  return (...rest) => func(...partial, ...rest);
+}
diff --git a/src/partialLastBind.ts b/src/partialLastBind.ts
new file mode 100644
index 0000000..285e34b
--- /dev/null
+++ b/src/partialLastBind.ts
@@ -0,0 +1,86 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return --
+ * Function inference doesn't work when `unknown` is used as the parameters
+ * generic type, it **has** to be `any`.
+ */
+import type {
+  IterableContainer,
+  RemedaTypeError,
+  TupleSplits,
+} from "./internal/types";
+
+type PartialLastBindError<Message extends string | number> = RemedaTypeError<
+  "partialLastBind",
+  Message
+>;
+
+type TupleSuffix<T extends IterableContainer> = TupleSplits<T>["right"];
+
+type RemoveSuffix<
+  T extends IterableContainer,
+  Suffix extends TupleSuffix<T>,
+> = Suffix extends readonly []
+  ? T
+  : T extends readonly [...infer TRest, infer TLast]
+    ? Suffix extends readonly [...infer SuffixRest, infer _SuffixLast]
+      ? // SuffixLast extends TLast.
+        RemoveSuffix<TRest, SuffixRest>
+      : // Suffix (as a whole) extends ReadonlyArray<TLast>.
+        // Suffix could possibly be empty, so this has to be TLast?.
+        [...RemoveSuffix<TRest, Suffix>, TLast?]
+    : // T has an optional or rest parameter last. If T is a parameter list,
+      // this can only happen if we have optional arguments or a rest param;
+      // both cases are similar.
+      T extends readonly [...infer TRest, (infer _TLast)?]
+      ? Suffix extends readonly [...infer SuffixRest, infer _SuffixLast]
+        ? // SuffixLast extends TLast.
+          RemoveSuffix<TRest, SuffixRest>
+        : // Suffix (as a whole) extends [...TRest, TLast?].
+          TRest
+      : // We got passed a parameter list that isn't what we expected; this
+        // is an internal error.
+        PartialLastBindError<1>;
+
+/**
+ * Creates a function that calls `func` with `partial` put after the arguments
+ * it receives. Note that this doesn't support functions with both optional
+ * and rest parameters.
+ *
+ * Can be thought of as "freezing" some portion of a function's arguments,
+ * resulting in a new function with a simplified signature.
+ *
+ * Useful for converting a data-first function to a data-last one.
+ *
+ * @param func - The function to wrap.
+ * @param partial - The arguments to put after.
+ * @returns A partially bound function.
+ * @signature
+ *    R.partialLastBind(func, partial)
+ * @example
+ *    const fn = (x, y, z) => `${x}, ${y}, and ${z}`
+ *    const partialFn = R.partialLastBind(fn, [2, 3])
+ *    partialFn(1) // => 1, 2, and 3
+ *
+ *    const parseBinary = R.partialLastBind(parseInt, ["2"])
+ *    parseBinary("101") // => 5
+ *
+ *    R.pipe(
+ *      { a: 1 },
+ *      // instead of (arg) => JSON.stringify(arg, null, 2)
+ *      R.partialLastBind(JSON.stringify, [null, 2]),
+ *    ) // => '{\n  "a": 1\n}'
+ * @dataFirst
+ * @category Function
+ * @see partialBind
+ */
+export function partialLastBind<
+  F extends (...args: any) => any,
+  SuffixArgs extends TupleSuffix<Parameters<F>>,
+  RemovedSuffix extends RemoveSuffix<Parameters<F>, SuffixArgs>,
+>(
+  func: F,
+  ...partial: SuffixArgs
+): (
+  ...rest: RemovedSuffix extends IterableContainer ? RemovedSuffix : never
+) => ReturnType<F> {
+  return (...rest) => func(...rest, ...partial);
+}


Test patch:
diff --git a/src/internal/types.test-d.ts b/src/internal/types.test-d.ts
index 91b7937..eec5a01 100644
--- a/src/internal/types.test-d.ts
+++ b/src/internal/types.test-d.ts
@@ -7,6 +7,7 @@ import type {
   IterableContainer,
   NTuple,
   TupleParts,
+  TupleSplits,
 } from "./types";
 
 declare const SymbolFoo: unique symbol;
@@ -384,6 +385,16 @@ describe("TupleParts", () => {
         suffix: [string];
       }>();
     });
+
+    test("array with optional prefixes", () => {
+      const data = [1, "a"] as [number, string?, number?, ...Array<boolean>];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [number, string?, number?];
+        item: boolean;
+        suffix: [];
+      }>();
+    });
   });
 
   describe("readonly", () => {
@@ -446,6 +457,21 @@ describe("TupleParts", () => {
         suffix: [string];
       }>();
     });
+
+    test("array with optional prefixes", () => {
+      const data = [1, "a"] as readonly [
+        number,
+        string?,
+        number?,
+        ...Array<boolean>,
+      ];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [number, string?, number?];
+        item: boolean;
+        suffix: [];
+      }>();
+    });
   });
 
   describe("unions", () => {
@@ -469,6 +495,229 @@ describe("TupleParts", () => {
   });
 });
 
+describe("TupleSplits", () => {
+  describe("mutable", () => {
+    test("empty array", () => {
+      const data = [] as [];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        left: [];
+        right: [];
+      }>();
+    });
+
+    test("regular array", () => {
+      const data = [] as Array<number>;
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: Array<number>;
+          }
+        | {
+            left: Array<number>;
+            right: Array<number>;
+          }
+        | {
+            left: Array<number>;
+            right: [];
+          }
+      >();
+    });
+
+    test("fixed tuple", () => {
+      const data = [1, 2, 3] as [1, 2, 3];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [1, 2, 3];
+            right: [];
+          }
+        | {
+            left: [1, 2];
+            right: [3];
+          }
+        | {
+            left: [1];
+            right: [2, 3];
+          }
+        | {
+            left: [];
+            right: [1, 2, 3];
+          }
+      >();
+    });
+
+    test("array with prefix", () => {
+      const data = ["a"] as [string, ...Array<boolean>];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: [string, ...Array<boolean>];
+          }
+        | {
+            left: [string];
+            right: Array<boolean>;
+          }
+        | {
+            left: [string, ...Array<boolean>];
+            right: Array<boolean>;
+          }
+        | {
+            left: [string, ...Array<boolean>];
+            right: [];
+          }
+      >();
+    });
+
+    test("array with suffix", () => {
+      const data = ["a"] as [...Array<boolean>, string];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: [...Array<boolean>, string];
+          }
+        | {
+            left: Array<boolean>;
+            right: [...Array<boolean>, string];
+          }
+        | {
+            left: Array<boolean>;
+            right: [string];
+          }
+        | {
+            left: [...Array<boolean>, string];
+            right: [];
+          }
+      >();
+    });
+
+    test("array with prefix and suffix", () => {
+      const data = [1, "a"] as [number, ...Array<boolean>, string];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: [number, ...Array<boolean>, string];
+          }
+        | {
+            left: [number];
+            right: [...Array<boolean>, string];
+          }
+        | {
+            left: [number, ...Array<boolean>];
+            right: [...Array<boolean>, string];
+          }
+        | {
+            left: [number, ...Array<boolean>];
+            right: [string];
+          }
+        | {
+            left: [number, ...Array<boolean>, string];
+            right: [];
+          }
+      >();
+    });
+
+    test("array with optional prefixes", () => {
+      const data = [1, "a"] as [number, string?, number?, ...Array<boolean>];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: [number, string?, number?, ...Array<boolean>];
+          }
+        | {
+            left: [number];
+            right: [string?, number?, ...Array<boolean>];
+          }
+        | {
+            left: [number, string?];
+            right: [number?, ...Array<boolean>];
+          }
+        | {
+            left: [number, string?, number?];
+            right: [...Array<boolean>];
+          }
+        | {
+            left: [number, string?, number?, ...Array<boolean>];
+            right: Array<boolean>;
+          }
+        | {
+            left: [number, string?, number?, ...Array<boolean>];
+            right: [];
+          }
+      >();
+    });
+  });
+
+  describe("unions", () => {
+    test("union of arrays", () => {
+      const data = [] as Array<boolean> | Array<number>;
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: Array<boolean>;
+          }
+        | {
+            left: Array<boolean>;
+            right: Array<boolean>;
+          }
+        | {
+            left: Array<boolean>;
+            right: [];
+          }
+        | {
+            left: [];
+            right: Array<number>;
+          }
+        | {
+            left: Array<number>;
+            right: Array<number>;
+          }
+        | {
+            left: Array<number>;
+            right: [];
+          }
+      >();
+    });
+
+    test("mixed unions", () => {
+      const data = [] as Array<boolean> | [number, string];
+      const result = tupleSplits(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            left: [];
+            right: Array<boolean>;
+          }
+        | {
+            left: Array<boolean>;
+            right: Array<boolean>;
+          }
+        | {
+            left: Array<boolean>;
+            right: [];
+          }
+        | {
+            left: [];
+            right: [number, string];
+          }
+        | {
+            left: [number];
+            right: [string];
+          }
+        | {
+            left: [number, string];
+            right: [];
+          }
+      >();
+    });
+  });
+});
+
 describe("DeDupped", () => {
   describe("mutable", () => {
     test("empty array", () => {
@@ -568,4 +817,6 @@ declare function nTuple<T, N extends number>(x: T, n: N): NTuple<T, N>;
 
 declare function tupleParts<T>(x: T): TupleParts<T>;
 
+declare function tupleSplits<T extends IterableContainer>(x: T): TupleSplits<T>;
+
 declare function deduped<T extends IterableContainer>(data: T): Deduped<T>;
diff --git a/src/median.test-d.ts b/src/median.test-d.ts
new file mode 100644
index 0000000..2fa86fd
--- /dev/null
+++ b/src/median.test-d.ts
@@ -0,0 +1,51 @@
+import { median } from "./median";
+import { pipe } from "./pipe";
+
+test("empty arrays", () => {
+  const result = median([] as const);
+  expectTypeOf(result).toEqualTypeOf<undefined>();
+});
+
+describe("dataFirst", () => {
+  test("arbitrary arrays", () => {
+    const result = median([] as Array<number>);
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+
+  test("arbitrary readonly arrays", () => {
+    const result = median([] as ReadonlyArray<number>);
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+
+  test("arbitrary non-empty arrays", () => {
+    const result = median([1, 2] as [number, ...Array<number>]);
+    expectTypeOf(result).toEqualTypeOf<number>();
+  });
+
+  test("consts", () => {
+    const result = median([1, 2, 3] as const);
+    expectTypeOf(result).toEqualTypeOf<number>();
+  });
+
+  test("fixed-size tuples", () => {
+    const result = median([1, 2] as [number, number]);
+    expectTypeOf(result).toEqualTypeOf<number>();
+  });
+});
+
+describe("dataLast", () => {
+  test("numbers", () => {
+    const result = pipe([1, 2, 3] as const, median());
+    expectTypeOf(result).toEqualTypeOf<number>();
+  });
+
+  test("arbitrary number arrays", () => {
+    const result = pipe([] as Array<number>, median());
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+
+  test("arbitrary readonly number arrays", () => {
+    const result = pipe([] as ReadonlyArray<number>, median());
+    expectTypeOf(result).toEqualTypeOf<number | undefined>();
+  });
+});
diff --git a/src/median.test.ts b/src/median.test.ts
new file mode 100644
index 0000000..ea43428
--- /dev/null
+++ b/src/median.test.ts
@@ -0,0 +1,32 @@
+import { median } from "./median";
+import { pipe } from "./pipe";
+
+describe("dataFirst", () => {
+  test("arrays of odd length", () => {
+    expect(median([6, 10, 11])).toBe(10);
+  });
+
+  test("arrays of even length", () => {
+    expect(median([1, 2, 3, 9])).toBe(2.5);
+    expect(median([-1, 0, 1, 10])).toBe(0.5);
+  });
+
+  it("should return undefined for an empty array", () => {
+    expect(median([])).toBeUndefined();
+  });
+});
+
+describe("dataLast", () => {
+  test("arrays of odd length", () => {
+    expect(pipe([6, 10, 11], median())).toBe(10);
+  });
+
+  test("arrays of even length", () => {
+    expect(pipe([1, 2, 3, 9], median())).toBe(2.5);
+    expect(pipe([-1, 0, 1, 10], median())).toBe(0.5);
+  });
+
+  it("should return undefined for an empty array", () => {
+    expect(pipe([], median())).toBeUndefined();
+  });
+});
diff --git a/src/partialBind.test-d.ts b/src/partialBind.test-d.ts
new file mode 100644
index 0000000..ebca6cb
--- /dev/null
+++ b/src/partialBind.test-d.ts
@@ -0,0 +1,190 @@
+/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types, unicorn/consistent-function-scoping */
+import { partialBind } from "./partialBind";
+
+describe("simple case (all required, no rest params)", () => {
+  const fn = (x: number, y: number, z: number | string): string =>
+    `${x}, ${y}, and ${z}`;
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialBind(fn)).toEqualTypeOf<
+      (x: number, y: number, z: number | string) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialBind(fn, 1)).toEqualTypeOf<
+      (y: number, z: number | string) => string
+    >();
+  });
+
+  test("should correctly type all partial args", () => {
+    expectTypeOf(partialBind(fn, 1, 2, "a")).toEqualTypeOf<() => string>();
+  });
+
+  test("should not accept wrong arg type", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, "a");
+  });
+
+  test("should not accept too many args", () => {
+    // @ts-expect-error [ts2345] - too many args
+    partialBind(fn, 1, 2, 3, 4);
+  });
+
+  test("should not accept array typed partial", () => {
+    // @ts-expect-error [ts2345] - don't know how many args are bound
+    partialBind(fn, ...([] as Array<number>));
+  });
+
+  test("should not accept tuple typed partial with suffix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, ...([1, "a"] as [...Array<number>, string]));
+  });
+});
+
+describe("optional params", () => {
+  const fn = (x: string, y = 123, z = true): string => `${x}, ${y}, and ${z}`;
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialBind(fn)).toEqualTypeOf<
+      (x: string, y?: number, z?: boolean) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialBind(fn, "hello")).toEqualTypeOf<
+      (y?: number, z?: boolean) => string
+    >();
+  });
+
+  test("should correctly type 2 partial args", () => {
+    expectTypeOf(partialBind(fn, "hello", undefined)).toEqualTypeOf<
+      (z?: boolean) => string
+    >();
+  });
+
+  test("should correctly type all partial args", () => {
+    expectTypeOf(partialBind(fn, "hello", undefined, false)).toEqualTypeOf<
+      () => string
+    >();
+  });
+});
+
+describe("simple rest param case", () => {
+  const fn = (...parts: Array<string>): string => parts.join("");
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialBind(fn)).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialBind(fn, "hello")).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should not accept wrong arg type", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, 1);
+  });
+
+  test("should accept tuple typed partial arg", () => {
+    expectTypeOf(partialBind(fn, ...([] as Array<string>))).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should accept tuple typed partial arg with prefix", () => {
+    expectTypeOf(
+      partialBind(fn, ...(["hello"] as [string, ...Array<string>])),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+
+  test("should accept tuple typed partial arg with prefix and suffix", () => {
+    expectTypeOf(
+      partialBind(
+        fn,
+        ...(["hello", "world"] as [string, ...Array<string>, string]),
+      ),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+
+  test("should not accept tuple typed partial arg with incorrect prefix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, ...([1, "hello"] as [number?, ...Array<string>]));
+  });
+
+  test("should not accept tuple typed partial arg with incorrect suffix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, ...(["hello", 1] as [...Array<string>, number]));
+  });
+});
+
+describe("optional and rest param case", () => {
+  const fn = (x: string, y = 123, ...parts: Array<string>): string =>
+    `${x}, ${y}, and ${parts.join("")}`;
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialBind(fn)).toEqualTypeOf<
+      (x: string, y?: number, ...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialBind(fn, "hello")).toEqualTypeOf<
+      (y?: number, ...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should correctly type 2 partial args", () => {
+    expectTypeOf(partialBind(fn, "hello", undefined)).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should correctly type 3 partial args", () => {
+    expectTypeOf(partialBind(fn, "hello", undefined, "world")).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should not accept wrong required arg type", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialBind(fn, 1);
+  });
+
+  test("should not accept wrong optional arg type", () => {
+    // @ts-expect-error [ts2345] - This is correct; "hello" would be the second argument of fn.
+    partialBind(fn, "hello", "world");
+  });
+
+  test("should not accept wrong rest arg type", () => {
+    // @ts-expect-error [ts2345] - wrong rest arg type
+    partialBind(fn, "hello", 123, 1);
+  });
+
+  test("should not accept incorrect tuple typed partial arg", () => {
+    // @ts-expect-error [ts2345] - doesn't match optional
+    partialBind(fn, ...([] as Array<string>));
+  });
+
+  test("should accept correct tuple typed partial arg", () => {
+    expectTypeOf(
+      partialBind(fn, ...(["hello"] as [string, number?, ...Array<string>])),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+});
+
+describe("KNOWN ISSUES", () => {
+  test("does not support readonly rest params", () => {
+    const fn = (...parts: ReadonlyArray<string>): string => parts.join("");
+
+    // @ts-expect-error [ts2345]: blocked on https://github.com/microsoft/TypeScript/issues/37193
+    expectTypeOf(partialBind(fn)).toEqualTypeOf<
+      // @ts-expect-error [ts2344]: blocked on https://github.com/microsoft/TypeScript/issues/37193
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+});
diff --git a/src/partialBind.test.ts b/src/partialBind.test.ts
new file mode 100644
index 0000000..0d61cc2
--- /dev/null
+++ b/src/partialBind.test.ts
@@ -0,0 +1,15 @@
+import { partialBind } from "./partialBind";
+
+const fn = (x: number, y: number, z: number): string => `${x}, ${y}, and ${z}`;
+
+test("should partially apply 0 args", () => {
+  expect(partialBind(fn)(1, 2, 3)).toBe(fn(1, 2, 3));
+});
+
+test("should partially apply 1 arg", () => {
+  expect(partialBind(fn, 1)(2, 3)).toBe(fn(1, 2, 3));
+});
+
+test("should partially apply all args", () => {
+  expect(partialBind(fn, 1, 2, 3)()).toBe(fn(1, 2, 3));
+});
diff --git a/src/partialLastBind.test-d.ts b/src/partialLastBind.test-d.ts
new file mode 100644
index 0000000..2d056ed
--- /dev/null
+++ b/src/partialLastBind.test-d.ts
@@ -0,0 +1,146 @@
+/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types, unicorn/consistent-function-scoping */
+import { partialLastBind } from "./partialLastBind";
+
+describe("simple case (all required, no rest params)", () => {
+  const fn = (x: number, y: number, z: number | string): string =>
+    `${x}, ${y}, and ${z}`;
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialLastBind(fn)).toEqualTypeOf<
+      (x: number, y: number, z: number | string) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialLastBind(fn, 3)).toEqualTypeOf<
+      (x: number, y: number) => string
+    >();
+  });
+
+  test("should correctly type all partial args", () => {
+    expectTypeOf(partialLastBind(fn, 1, 2, "c")).toEqualTypeOf<() => string>();
+  });
+
+  test("should not accept wrong arg type", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialLastBind(fn, "b", "c");
+  });
+
+  test("should not accept too many args", () => {
+    // @ts-expect-error [ts2345] - too many args
+    partialLastBind(fn, 1, 2, 3, 4);
+  });
+
+  test("should not accept array typed partial", () => {
+    // @ts-expect-error [ts2345] - don't know how many args are bound
+    partialLastBind(fn, ...([] as Array<number>));
+  });
+
+  test("should not accept tuple typed partial with prefix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialLastBind(fn, ...(["a", 1] as [string, ...Array<number>]));
+  });
+});
+
+describe("optional params", () => {
+  const fn = (x: string, y = 123, z = true): string => `${x}, ${y}, and ${z}`;
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialLastBind(fn)).toEqualTypeOf<
+      (x: string, y?: number, z?: boolean) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialLastBind(fn, false)).toEqualTypeOf<
+      (x: string, y?: number) => string
+    >();
+  });
+
+  test("should correctly type 2 partial args", () => {
+    expectTypeOf(partialLastBind(fn, undefined, false)).toEqualTypeOf<
+      (x: string) => string
+    >();
+  });
+
+  test("should correctly type all partial args", () => {
+    expectTypeOf(partialLastBind(fn, "hello", undefined, false)).toEqualTypeOf<
+      () => string
+    >();
+  });
+});
+
+describe("simple rest param case", () => {
+  const fn = (...parts: Array<string>): string => parts.join("");
+
+  test("should correctly type 0 partial args", () => {
+    expectTypeOf(partialLastBind(fn)).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should correctly type 1 partial arg", () => {
+    expectTypeOf(partialLastBind(fn, "hello")).toEqualTypeOf<
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  test("should not accept wrong arg type", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialLastBind(fn, 1);
+  });
+
+  test("should accept tuple typed partial arg", () => {
+    expectTypeOf(
+      partialLastBind(fn, ...([] as [...Array<string>])),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+
+  test("should accept tuple typed partial arg with prefix", () => {
+    expectTypeOf(
+      partialLastBind(fn, ...(["hello"] as [string, ...Array<string>])),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+
+  test("should accept tuple typed partial arg with prefix and suffix", () => {
+    expectTypeOf(
+      partialLastBind(
+        fn,
+        ...(["hello", "world"] as [string, ...Array<string>, string]),
+      ),
+    ).toEqualTypeOf<(...parts: ReadonlyArray<string>) => string>();
+  });
+
+  test("should not accept tuple typed partial arg with incorrect prefix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialLastBind(fn, ...([1, "hello"] as [number?, ...Array<string>]));
+  });
+
+  test("should not accept tuple typed partial arg with incorrect suffix", () => {
+    // @ts-expect-error [ts2345] - wrong arg type
+    partialLastBind(fn, ...(["hello", 1] as [...Array<string>, number]));
+  });
+});
+
+describe("KNOWN ISSUES", () => {
+  test("does not support readonly rest params", () => {
+    const fn = (...parts: ReadonlyArray<string>): string => parts.join("");
+
+    // @ts-expect-error [ts2345]: blocked on https://github.com/microsoft/TypeScript/issues/37193
+    expectTypeOf(partialLastBind(fn)).toEqualTypeOf<
+      // @ts-expect-error [ts2344]: blocked on https://github.com/microsoft/TypeScript/issues/37193
+      (...parts: ReadonlyArray<string>) => string
+    >();
+  });
+
+  describe("does not support optional AND rest params", () => {
+    const fn = (x: string, y = 123, ...parts: Array<string>): string =>
+      `${x}, ${y}, and ${parts.join("")}`;
+
+    expectTypeOf(partialLastBind(fn, "hello")).toEqualTypeOf<
+      // @ts-expect-error [ts2344]: I don't think this is possible on the type-level?
+      // We don't know whether "hello" is in x or parts.
+      (x: string, y?: number, ...parts: ReadonlyArray<string>) => string
+    >();
+  });
+});
diff --git a/src/partialLastBind.test.ts b/src/partialLastBind.test.ts
new file mode 100644
index 0000000..1fe7c56
--- /dev/null
+++ b/src/partialLastBind.test.ts
@@ -0,0 +1,15 @@
+import { partialLastBind } from "./partialLastBind";
+
+const fn = (x: number, y: number, z: number): string => `${x}, ${y}, and ${z}`;
+
+test("should partially apply 0 args", () => {
+  expect(partialLastBind(fn)(1, 2, 3)).toBe(fn(1, 2, 3));
+});
+
+test("should partially apply 1 arg", () => {
+  expect(partialLastBind(fn, 3)(1, 2)).toBe(fn(1, 2, 3));
+});
+
+test("should partially apply all args", () => {
+  expect(partialLastBind(fn, 1, 2, 3)()).toBe(fn(1, 2, 3));
+});