Repository: remeda/remeda
Pull request #442 (2023-12-15 13:30:02)

feat: Add takeLastWhile function

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

Latest deployment of this branch, based on commit 2a155d82027ff84eccc4da09e893bc620e4282da:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/442?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 [(`88656cf`)](https://app.codecov.io/gh/remeda/remeda/commit/88656cf1b92dd5a596db112d5c2db01b044b77d5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 98.24% compared to head [(`2a155d8`)](https://app.codecov.io/gh/remeda/remeda/pull/442?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 98.25%.


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


```diff
@@            Coverage Diff             @@
##           master     #442      +/-   ##
==========================================
+ Coverage   98.24%   98.25%   +0.01%     
==========================================
  Files         112      113       +1     
  Lines        7018     7064      +46     
  Branches      652      657       +5     
==========================================
+ Hits         6895     6941      +46     
  Misses        120      120              
  Partials        3        3              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/442?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/.eslintrc.js b/.eslintrc.js
index 278c74d..dee5faf 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -64,5 +64,12 @@ module.exports = {
         node: true,
       },
     },
+    {
+      files: ['src/type-fest/**.ts'],
+      rules: {
+        // Type-fest uses a lot of "banned" types...
+        '@typescript-eslint/ban-types': 'off',
+      },
+    },
   ],
 };
diff --git a/mapping.md b/mapping.md
index 2fc78ee..95edbb0 100644
--- a/mapping.md
+++ b/mapping.md
@@ -9,13 +9,14 @@ documentation when migrating._
 | Remeda              | Lodash              | Ramda               |
 | ------------------- | ------------------- | ------------------- |
 | `addProp`           | `set`               | `set`               |
-| `allPass`           | `-`                 | `allPass`           |
-| `anyPass`           | `-`                 | `anyPass`           |
-| `chunk`             | `chunk`             | `-`                 |
+| `allPass`           |                     | `allPass`           |
+| `anyPass`           |                     | `anyPass`           |
+| `chunk`             | `chunk`             |                     |
 | `clamp`             | `clamp`             | `clamp`             |
 | `clone`             | `cloneDeep`         | `clone`             |
 | `concat`            | `concat`            | `concat`            |
-| `createPipe`        | `-`                 | `-`                 |
+| `createPipe`        |                     |                     |
+| `debounce`          | `debounce`          |                     |
 | `difference`        | `difference`        | `difference`        |
 | `differenceWith`    | `differenceWith`    | `differenceWith`    |
 | `drop`              | `drop`              | `drop`              |
@@ -44,14 +45,15 @@ documentation when migrating._
 | `last`              | `last`              | `last`              |
 | `length`            | `size`              | `length`            |
 | `map`               | `map`               | `map`               |
-| `mapKeys`           | `mapKeys`           | `-`                 |
-| `meanBy`            | `meanBy`            | `-`                 |
+| `mapKeys`           | `mapKeys`           |                     |
+| `meanBy`            | `meanBy`            |                     |
 | `merge`             | `assign`            | `merge`             |
 | `mergeAll`          | `assign`            | `mergeAll`          |
+| `mergeDeep`         |                     | `mergeDeepRight`    |
 | `noop`              | `noop`              | `-`                 |
 | `objOf`             | `-`                 | `objOf`             |
 | `omit`              | `omit`              | `omit`              |
-| `omitBy`            | `omitBy`            | `-`                 |
+| `omitBy`            | `omitBy`            |                     |
 | `once`              | `once`              | `once`              |
 | `partition`         | `partition`         | `partition`         |
 | `pathOr`            | `get`               | `pathOr`            |
@@ -59,24 +61,24 @@ documentation when migrating._
 | `pickBy`            | `pickBy`            | `pickBy`            |
 | `pipe`              | `flow`              | `pipe`              |
 | `prop`              | `get`               | `prop`              |
-| `purry`             | `-`                 | `-`                 |
-| `randomString`      | `-`                 | `-`                 |
+| `purry`             |                     |                     |
+| `randomString`      |                     |                     |
 | `range`             | `range`             | `range`             |
 | `reduce`            | `reduce`            | `reduce`            |
 | `reject`            | `reject`            | `reject`            |
-| `splice`            | `-`                 | `remove`            |
 | `reverse`           | `reverse`           | `reverse`           |
 | `sample`            | `sampleSize`\*      |                     |
 | `set`               | `set`               | `assoc`             |
 | `setPath`           | `set`               | `assocPath`         |
-| `shuffle`           | `shuffle`           | `-`                 |
-| `sort`              | `-`                 | `sort`              |
-| `sortBy`            | `orderBy`           | `-`                 |
+| `shuffle`           | `shuffle`           |                     |
+| `sort`              |                     | `sort`              |
+| `sortBy`            | `orderBy`           |                     |
 | `sortBy`            | `sortBy`            | `sortBy`            |
 | `sortedIndex`       | `sortedIndex`       |                     |
 | `sortedIndexBy`     | `sortedIndexBy`     |                     |
 | `sortedLastIndex`   | `sortedLastIndex`   |                     |
 | `sortedLastIndexBy` | `sortedLastIndexBy` |                     |
+| `splice`            | `-`                 | `remove`            |
 | `splitAt`           | `-`                 | `splitAt`           |
 | `splitWhen`         | `-`                 | `splitWhen`         |
 | `stringToPath`      | `toPath`            | `-`                 |
@@ -84,11 +86,12 @@ documentation when migrating._
 | `swapIndices`       | `-`                 | `swap`              |
 | `swapProps`         | `-`                 | `swap`              |
 | `take`              | `take`              | `take`              |
+| `takeLastWhile`     | `takeRightWhile`    | `takeLastWhile`     |
 | `takeWhile`         | `takeWhile`         | `takeWhile`         |
 | `tap`               | `tap`               | `tap`               |
 | `times`             | `times`             | `times`             |
 | `toPairs`           | `toPairs`           | `toPairs`           |
-| `type`              | `-`                 | `type`              |
+| `type`              |                     | `type`              |
 | `uniq`              | `uniq`              | `uniq`              |
 | `uniqBy`            | `uniqBy`            | `uniqBy`            |
 | `uniqWith`          | `uniqWith`          | `uniqWith`          |
@@ -108,11 +111,11 @@ you are relying on when migrating._
 | Lodash     | Ramda    | TypeScript                    |
 | ---------- | -------- | ----------------------------- |
 | `add`      | `add`    | `(x, y) => x + y`             |
-| `-`        | `append` | `(arr, val) => [...arr, val]` |
+|            | `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)`        |
+|            | `path`   | `a?.b?.c`                     |
+|            | `propEq` | `x => x.a === value`          |
+|            | `propOr` | `x => x.a ?? defaultValue`    |
+| `words`    |          | `str => str.split(/\s+/)`     |
+| `compact`  |          | `R.filter(R.isTruthy)`        |
diff --git a/src/debounce.ts b/src/debounce.ts
new file mode 100644
index 0000000..622aa48
--- /dev/null
+++ b/src/debounce.ts
@@ -0,0 +1,271 @@
+type Debouncer<
+  F extends (...args: any) => unknown,
+  IsNullable extends boolean = true,
+> = {
+  /**
+   * Invoke the debounced function.
+   * @param args - same as the args for the debounced function.
+   * @returns - the last computed value of the debounced function with the
+   * latest args provided to it. If `timing` does not include `leading` then the
+   * the function would return `undefined` until the first cool-down period is
+   * over, otherwise the function would always return the return type of the
+   * debounced function.
+   */
+  readonly call: (
+    ...args: Parameters<F>
+  ) => ReturnType<F> | (true extends IsNullable ? undefined : never);
+
+  /**
+   * Cancels any debounced functions without calling them, effectively resetting
+   * the debouncer to the same state it is when initially created.
+   */
+  readonly cancel: () => void;
+
+  /**
+   * Similar to `cancel`, but would also trigger the `trailing` invocation if
+   * the debouncer would run one at the end of the cool-down period.
+   */
+  readonly flush: () => ReturnType<F> | undefined;
+
+  /**
+   * Is `true` when there is an active cool-down period currently debouncing
+   * invocations.
+   */
+  readonly isPending: boolean;
+
+  /**
+   * The last computed value of the debounced function.
+   */
+  readonly cachedValue: ReturnType<F> | undefined;
+};
+
+type DebounceOptions = {
+  readonly waitMs?: number;
+  readonly maxWaitMs?: number;
+};
+
+/**
+ * Wraps `func` with a debouncer object that "debounces" (delays) invocations of the function during a defined cool-down period (`waitMs`). It can be configured to invoke the function either at the start of the cool-down period, the end of it, or at both ends (`timing`).
+ * It can also be configured to allow invocations during the cool-down period (`maxWaitMs`).
+ * It stores the latest call's arguments so they could be used at the end of the cool-down period when invoking `func` (if configured to invoke the function at the end of the cool-down period).
+ * It stores the value returned by `func` whenever its invoked. This value is returned on every call, and is accessible via the `cachedValue` property of the debouncer. Its important to note that the value might be different from the value that would be returned from running `func` with the current arguments as it is a cached value from a previous invocation.
+ * **Important**: The cool-down period defines the minimum between two invocations, and not the maximum. The period will be **extended** each time a call is made until a full cool-down period has elapsed without any additional calls.
+ * @param func The function to debounce, the returned `call` function will have
+ * the exact same signature.
+ * @param options An object allowing further customization of the debouncer:
+ * - `timing?: 'leading' | 'trailing' |'both'`. The default is `'trailing'`.
+ *   `leading` would result in the function being invoked at the start of the
+ *   cool-down period; `trailing` would result in the function being invoked at
+ *   the end of the cool-down period (using the args from the last call to the
+ *   debouncer). When `both` is selected the `trailing` invocation would only
+ *   take place if there were more than one call to the debouncer during the
+ *   cool-down period. **DEFAULT: 'trailing'**
+ * - `waitMs?: number`. The length of the cool-down period in milliseconds. The
+ *   debouncer would wait until this amount of time has passed without **any**
+ *   additional calls to the debouncer before triggering the end-of-cool-down-
+ *   period event. When this happens, the function would be invoked (if `timing`
+ *   isn't `'leading'`) and the debouncer state would be reset. **DEFAULT: 0**
+ * - `maxWaitMs?: number`. The length of time since a debounced call (a call
+ *   that the debouncer prevented from being invoked) was made until it would be
+ *   invoked. Because the debouncer can be continually triggered and thus never
+ *   reach the end of the cool-down period, this allows the function to still
+ *   be invoked occasionally. IMPORTANT: This param is ignored when `timing` is
+ *   `'leading'`.
+ * @returns a debouncer object. The main function is `call`. In addition to it
+ * the debouncer comes with the following additional functions and properties:
+ * - `cancel` method to cancel delayed `func` invocations
+ * - `flush` method to end the cool-down period immediately.
+ * - `cachedValue` the latest return value of an invocation (if one occurred).
+ * - `isPending` flag to check if there is an inflight cool-down window.
+ * @signature
+ *   R.debounce(func, options);
+ * @example
+ *   const debouncer = debounce(identity, { timing: 'trailing', waitMs: 1000 });
+ *   const result1 = debouncer.call(1); // => undefined
+ *   const result2 = debouncer.call(2); // => undefined
+ *   // after 1 second
+ *   const result3 = debouncer.call(3); // => 2
+ *   // after 1 second
+ *   debouncer.cachedValue; // => 3
+ * @dataFirst
+ * @category Function
+ * @see https://css-tricks.com/debouncing-throttling-explained-examples/
+ */
+export function debounce<F extends (...args: any) => any>(
+  func: F,
+  options: { readonly timing?: 'trailing' } & DebounceOptions
+): Debouncer<F>;
+export function debounce<F extends (...args: any) => any>(
+  func: F,
+  options:
+    | ({ readonly timing: 'leading' } & Omit<DebounceOptions, 'maxWaitMs'>)
+    | ({ readonly timing: 'both' } & DebounceOptions)
+): Debouncer<F, false /* call CAN'T return null */>;
+
+export function debounce<F extends (...args: any) => any>(
+  func: F,
+  {
+    waitMs,
+    timing = 'trailing',
+    maxWaitMs,
+  }: DebounceOptions & {
+    readonly timing?: 'leading' | 'both' | 'trailing';
+  }
+): Debouncer<F> {
+  if (maxWaitMs !== undefined && waitMs !== undefined && maxWaitMs < waitMs) {
+    throw new Error(
+      `debounce: maxWaitMs (${maxWaitMs}) cannot be less than waitMs (${waitMs})`
+    );
+  }
+
+  // All these are part of the debouncer runtime state:
+
+  // The timeout is the main object we use to tell if there's an active cool-
+  // down period or not.
+  let coolDownTimeoutId: number | undefined;
+
+  // We use an additional timeout to track how long the last debounced call is
+  // waiting.
+  let maxWaitTimeoutId: number | undefined;
+
+  // For 'trailing' invocations we need to keep the args around until we
+  // actually invoke the function.
+  let latestCallArgs: Parameters<F> | undefined;
+
+  // To make any value of the debounced function we need to be able to return a
+  // value. For any invocation except the first one when 'leading' is enabled we
+  // will return this cached value.
+  let result: ReturnType<F> | undefined;
+
+  const handleInvoke = () => {
+    if (latestCallArgs === undefined) {
+      // This should never happen! It means we forgot to clear a timeout!
+      return;
+    }
+
+    if (maxWaitTimeoutId !== undefined) {
+      // We are invoking the function so the wait is over...
+      const timeoutId = maxWaitTimeoutId;
+      maxWaitTimeoutId = undefined;
+      clearTimeout(timeoutId);
+    }
+
+    const args = latestCallArgs;
+    // Make sure the args aren't accidentally used again, this is mainly
+    // relevant for the check above where we'll fail a subsequent call to
+    // 'trailingEdge'.
+    latestCallArgs = undefined;
+
+    // Invoke the function and store the results locally.
+    result = func(...args);
+  };
+
+  const handleCoolDownEnd = () => {
+    if (coolDownTimeoutId === undefined) {
+      // It's rare to get here, it should only happen when `flush` is called
+      // when the cool-down window isn't active.
+      return;
+    }
+
+    // Make sure there are no more timers running.
+    const timeoutId = coolDownTimeoutId;
+    coolDownTimeoutId = undefined;
+    clearTimeout(timeoutId);
+    // Then reset state so a new cool-down window can begin on the next call.
+
+    if (latestCallArgs !== undefined) {
+      // If we have a debounced call waiting to be invoked at the end of the
+      // cool-down period we need to invoke it now.
+      handleInvoke();
+    }
+  };
+
+  const handleDebouncedCall = (args: Parameters<F>) => {
+    // We save the latest call args so that (if and) when we invoke the function
+    // in the future, we have args to invoke it with.
+    latestCallArgs = args;
+
+    if (maxWaitMs !== undefined && maxWaitTimeoutId === undefined) {
+      // We only need to start the maxWait timeout once, on the first debounced
+      // call that is now being delayed.
+      maxWaitTimeoutId = setTimeout(handleInvoke, maxWaitMs);
+    }
+  };
+
+  return {
+    call: (...args) => {
+      if (coolDownTimeoutId === undefined) {
+        // This call is starting a new cool-down window!
+
+        if (timing === 'trailing') {
+          // Only when the timing is "trailing" is the first call "debounced".
+          handleDebouncedCall(args);
+        } else {
+          // Otherwise for "leading" and "both" the first call is actually
+          // called directly and not via a timeout.
+          result = func(...args);
+        }
+      } else {
+        // There's an inflight cool-down window.
+
+        if (timing !== 'leading') {
+          // When the timing is 'leading' all following calls are just ignored
+          // until the cool-down period ends. But for the other timings the call
+          // is "debounced".
+          handleDebouncedCall(args);
+        }
+
+        // The current timeout is no longer relevant because we need to wait the
+        // full `waitMs` time from this call.
+        const timeoutId = coolDownTimeoutId;
+        coolDownTimeoutId = undefined;
+        clearTimeout(timeoutId);
+      }
+
+      coolDownTimeoutId = setTimeout(
+        handleCoolDownEnd,
+        // If waitMs is not defined but maxWaitMs *is* it means the user is only
+        // interested in the leaky-bucket nature of the debouncer which is
+        // achieved by setting waitMs === maxWaitMs. If both are not defined we
+        // default to 0 which would wait until the end of the execution frame.
+        waitMs ?? maxWaitMs ?? 0
+      );
+
+      // Return the last computed result while we "debounce" further calls.
+      return result;
+    },
+
+    cancel: () => {
+      // Reset all "in-flight" state of the debouncer. Notice that we keep the
+      // cached value!
+
+      if (coolDownTimeoutId !== undefined) {
+        const timeoutId = coolDownTimeoutId;
+        coolDownTimeoutId = undefined;
+        clearTimeout(timeoutId);
+      }
+
+      if (maxWaitTimeoutId !== undefined) {
+        const timeoutId = maxWaitTimeoutId;
+        maxWaitTimeoutId = undefined;
+        clearTimeout(timeoutId);
+      }
+
+      latestCallArgs = undefined;
+    },
+
+    flush: () => {
+      // Flush is just a manual way to trigger the end of the cool-down window.
+      handleCoolDownEnd();
+      return result;
+    },
+
+    get isPending() {
+      return coolDownTimeoutId !== undefined;
+    },
+
+    get cachedValue() {
+      return result;
+    },
+  };
+}
diff --git a/src/index.ts b/src/index.ts
index 767b592..721eecc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ export * from './compact';
 export * from './concat';
 export * from './countBy';
 export * from './createPipe';
+export * from './debounce';
 export * from './difference';
 export * from './differenceWith';
 export * from './drop';
@@ -60,6 +61,7 @@ export * from './maxBy';
 export * from './meanBy';
 export * from './merge';
 export * from './mergeAll';
+export * from './mergeDeep';
 export * from './minBy';
 export * from './noop';
 export * from './objOf';
@@ -98,6 +100,7 @@ export * from './sumBy';
 export * from './swapIndices';
 export * from './swapProps';
 export * from './take';
+export * from './takeLastWhile';
 export * from './takeWhile';
 export * from './tap';
 export * from './times';
diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts
new file mode 100644
index 0000000..e22b69c
--- /dev/null
+++ b/src/mergeDeep.ts
@@ -0,0 +1,95 @@
+import { purry } from './purry';
+import { MergeDeep } from './type-fest/merge-deep';
+
+/**
+ * Merges the `source` object into the `destination` object. The merge is similar to performing `{ ...destination, ... source }` (where disjoint values from each object would be copied as-is, and for any overlapping props the value from `source` would be used); But for *each prop* (`p`), if **both** `destination` and `source` have a **plain-object** as a value, the value would be taken as the result of recursively deepMerging them (`result.p === deepMerge(destination.p, source.p)`).
+ *
+ * @param destination - The object to merge into. In general, this object would have it's values overridden.
+ * @param source - The object to merge from. In general, shared keys would be taken from this object.
+ * @returns - The merged object.
+ * @signature
+ *    R.mergeDeep(destination, source)
+ * @example
+ *    R.mergeDeep({ foo: 'bar', x: 1 }, { foo: 'baz', y: 2 }) // => { foo: 'baz', x: 1, y: 2 }
+ * @dataFirst
+ * @category Object
+ */
+export function mergeDeep<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(destination: Destination, source: Source): MergeDeep<Destination, Source>;
+
+/**
+ * Merges the `source` object into the `destination` object. The merge is similar to performing `{ ...destination, ... source }` (where disjoint values from each object would be copied as-is, and for any overlapping props the value from `source` would be used); But for *each prop* (`p`), if **both** `destination` and `source` have a **plain-object** as a value, the value would be taken as the result of recursively deepMerging them (`result.p === deepMerge(destination.p, source.p)`).
+ *
+ * @param destination - The object to merge into. In general, this object would have it's values overridden.
+ * @param source - The object to merge from. In general, shared keys would be taken from this object.
+ * @returns - The merged object.
+ * @signature
+ *    R.mergeDeep(source)(destination)
+ * @example
+ *    R.pipe(
+ *      { foo: 'bar', x: 1 },
+ *      R.mergeDeep({ foo: 'baz', y: 2 }),
+ *    );  // => { foo: 'baz', x: 1, y: 2 }
+ * @dataLast
+ * @category Object
+ */
+export function mergeDeep<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(source: Source): (target: Destination) => MergeDeep<Destination, Source>;
+
+export function mergeDeep() {
+  return purry(mergeDeepImplementation, arguments);
+}
+
+function mergeDeepImplementation<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(destination: Destination, source: Source) {
+  // At this point the output is already merged, simply not deeply merged.
+  const output = { ...destination, ...source } as Record<
+    keyof Destination | keyof Source,
+    unknown
+  >;
+
+  // now just scan the output and look for values that should have been deep-
+  // merged
+  for (const key in source) {
+    if (!(key in destination)) {
+      // They don't share this key.
+      continue;
+    }
+
+    const destinationValue = destination[key];
+    if (!isRecord(destinationValue)) {
+      // The value in destination is not a mergable object so the value from
+      // source (which was already copied in the shallow merge) would be used
+      // as-is.
+      continue;
+    }
+
+    const sourceValue = source[key];
+    if (!isRecord(sourceValue)) {
+      // The value in source is not a mergable object either, so it will
+      // override the object in destination.
+      continue;
+    }
+
+    // Both destination and source have a mergable object for this key, so we
+    // recursively merge them.
+    output[key] = mergeDeepImplementation(destinationValue, sourceValue);
+  }
+
+  return output;
+}
+
+// TODO: Replace this with a call to `isPlainObject` once PR #436 ships.
+function isRecord(object: unknown): object is Record<string, unknown> {
+  return (
+    typeof object === 'object' &&
+    object !== null &&
+    Object.getPrototypeOf(object) === Object.prototype
+  );
+}
diff --git a/src/takeLastWhile.ts b/src/takeLastWhile.ts
new file mode 100644
index 0000000..25b49b7
--- /dev/null
+++ b/src/takeLastWhile.ts
@@ -0,0 +1,51 @@
+import { purry } from './purry';
+
+/**
+ * Returns elements from the end of the array until the predicate returns false.
+ * The returned elements will be in the same order as in the original array.
+ *
+ * @param data the array
+ * @param predicate the predicate
+ * @signature
+ *    R.takeLastWhile(data, predicate)
+ * @example
+ *    R.takeLastWhile([1, 2, 10, 3, 4, 5], x => x < 10) // => [3, 4, 5]
+ * @dataFirst
+ * @category Array
+ */
+export function takeLastWhile<T>(
+  data: ReadonlyArray<T>,
+  predicate: (item: T) => boolean
+): Array<T>;
+
+/**
+ * Returns elements from the end of the array until the predicate returns false.
+ * The returned elements will be in the same order as in the original array.
+ *
+ * @param predicate the predicate
+ * @signature
+ *    R.takeLastWhile(predicate)(data)
+ * @example
+ *    R.pipe([1, 2, 10, 3, 4, 5], R.takeLastWhile(x => x < 10))  // => [3, 4, 5]
+ * @dataLast
+ * @category Array
+ */
+export function takeLastWhile<T>(
+  predicate: (item: T) => boolean
+): (data: ReadonlyArray<T>) => Array<T>;
+
+export function takeLastWhile() {
+  return purry(_takeLastWhile, arguments);
+}
+
+function _takeLastWhile<T>(
+  data: ReadonlyArray<T>,
+  predicate: (item: T) => boolean
+): Array<T> {
+  for (let i = data.length - 1; i >= 0; i--) {
+    if (!predicate(data[i])) {
+      return data.slice(i + 1);
+    }
+  }
+  return data.slice();
+}
diff --git a/src/type-fest/README.md b/src/type-fest/README.md
new file mode 100644
index 0000000..7e1a488
--- /dev/null
+++ b/src/type-fest/README.md
@@ -0,0 +1,6 @@
+ALL FILES IN THIS FOLDER WERE ORIGINALLY COPIED FROM TYPE-FEST
+https://github.com/sindresorhus/type-fest
+
+We use a lot of types from type-fest but we can't include it in our project because it is an ESM only module, which also uses modern (ESNext) APIs.
+
+The files have been modified to remove stuff we don't need, remove documentation, and include changes to styling and formatting.
diff --git a/src/type-fest/conditional-simplify.ts b/src/type-fest/conditional-simplify.ts
new file mode 100644
index 0000000..5efcc04
--- /dev/null
+++ b/src/type-fest/conditional-simplify.ts
@@ -0,0 +1,15 @@
+export type ConditionalSimplifyDeep<
+  Type,
+  ExcludeType = never,
+  IncludeType = unknown,
+> = Type extends ExcludeType
+  ? Type
+  : Type extends IncludeType
+    ? {
+        [TypeKey in keyof Type]: ConditionalSimplifyDeep<
+          Type[TypeKey],
+          ExcludeType,
+          IncludeType
+        >;
+      }
+    : Type;
diff --git a/src/type-fest/enforce-optional.ts b/src/type-fest/enforce-optional.ts
new file mode 100644
index 0000000..cc1ac82
--- /dev/null
+++ b/src/type-fest/enforce-optional.ts
@@ -0,0 +1,29 @@
+import type { Simplify } from './simplify';
+
+// Returns `never` if the key is optional otherwise return the key type.
+type RequiredFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
+  ? Type[Key] extends undefined
+    ? Key
+    : never
+  : Key;
+
+// Returns `never` if the key is required otherwise return the key type.
+type OptionalFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
+  ? Type[Key] extends undefined
+    ? never
+    : Key
+  : never;
+
+export type EnforceOptional<ObjectType> = Simplify<
+  {
+    [Key in keyof ObjectType as RequiredFilter<
+      ObjectType,
+      Key
+    >]: ObjectType[Key];
+  } & {
+    [Key in keyof ObjectType as OptionalFilter<ObjectType, Key>]?: Exclude<
+      ObjectType[Key],
+      undefined
+    >;
+  }
+>;
diff --git a/src/type-fest/merge-deep.ts b/src/type-fest/merge-deep.ts
new file mode 100644
index 0000000..32fef65
--- /dev/null
+++ b/src/type-fest/merge-deep.ts
@@ -0,0 +1,90 @@
+import type { ConditionalSimplifyDeep } from './conditional-simplify';
+import type { Merge } from './merge';
+import type { OmitIndexSignature } from './omit-index-signature';
+import type { PickIndexSignature } from './pick-index-signature';
+
+type UnknownRecord = Record<string, unknown>;
+
+/**
+Deeply simplifies an object excluding iterables and functions. Used internally to improve the UX and accept both interfaces and type aliases as inputs.
+*/
+type SimplifyDeep<Type> = ConditionalSimplifyDeep<
+  Type,
+  Function | Iterable<unknown>,
+  object
+>;
+
+/**
+Try to merge two record properties or return the source property value, preserving `undefined` properties values in both cases.
+*/
+type MergeDeepRecordProperty<Destination, Source> = undefined extends Source
+  ?
+      | MergeDeepOrReturn<
+          Source,
+          Exclude<Destination, undefined>,
+          Exclude<Source, undefined>
+        >
+      | undefined
+  : MergeDeepOrReturn<Source, Destination, Source>;
+
+/**
+Walk through the union of the keys of the two objects and test in which object the properties are defined.
+Rules:
+1. If the source does not contain the key, the value of the destination is returned.
+2. If the source contains the key and the destination does not contain the key, the value of the source is returned.
+3. If both contain the key, try to merge according to the chosen {@link MergeDeepOptions options} or return the source if unable to merge.
+*/
+type DoMergeDeepRecord<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = {
+  // Case in rule 1: The destination contains the key but the source doesn't.
+  [Key in keyof Destination as Key extends keyof Source
+    ? never
+    : Key]: Destination[Key];
+} & {
+  // Case in rule 2: The source contains the key but the destination doesn't.
+  [Key in keyof Source as Key extends keyof Destination
+    ? never
+    : Key]: Source[Key];
+} & {
+  // Case in rule 3: Both the source and the destination contain the key.
+  [Key in keyof Source as Key extends keyof Destination
+    ? Key
+    : never]: MergeDeepRecordProperty<
+    Key extends keyof Destination ? Destination[Key] : never,
+    Source[Key]
+  >;
+};
+
+/**
+Wrapper around {@link DoMergeDeepRecord} which preserves index signatures.
+*/
+type MergeDeepRecord<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = DoMergeDeepRecord<
+  OmitIndexSignature<Destination>,
+  OmitIndexSignature<Source>
+> &
+  Merge<PickIndexSignature<Destination>, PickIndexSignature<Source>>;
+
+/**
+Try to merge two objects or two arrays/tuples recursively into a new type or return the default value.
+*/
+type MergeDeepOrReturn<DefaultType, Destination, Source> = SimplifyDeep<
+  [undefined] extends [Destination | Source]
+    ? DefaultType
+    : Destination extends UnknownRecord
+      ? Source extends UnknownRecord
+        ? MergeDeepRecord<Destination, Source>
+        : DefaultType
+      : DefaultType
+>;
+
+export type MergeDeep<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = SimplifyDeep<
+  MergeDeepRecord<SimplifyDeep<Destination>, SimplifyDeep<Source>>
+>;
diff --git a/src/type-fest/merge.ts b/src/type-fest/merge.ts
new file mode 100644
index 0000000..50c0ddd
--- /dev/null
+++ b/src/type-fest/merge.ts
@@ -0,0 +1,15 @@
+import type { EnforceOptional } from './enforce-optional';
+import type { OmitIndexSignature } from './omit-index-signature';
+import type { PickIndexSignature } from './pick-index-signature';
+
+// Merges two objects without worrying about index signatures.
+type SimpleMerge<Destination, Source> = {
+  [Key in keyof Destination as Key extends keyof Source
+    ? never
+    : Key]: Destination[Key];
+} & Source;
+
+export type Merge<Destination, Source> = EnforceOptional<
+  SimpleMerge<PickIndexSignature<Destination>, PickIndexSignature<Source>> &
+    SimpleMerge<OmitIndexSignature<Destination>, OmitIndexSignature<Source>>
+>;
diff --git a/src/type-fest/omit-index-signature.ts b/src/type-fest/omit-index-signature.ts
new file mode 100644
index 0000000..d81d386
--- /dev/null
+++ b/src/type-fest/omit-index-signature.ts
@@ -0,0 +1,5 @@
+export type OmitIndexSignature<ObjectType> = {
+  [KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
+    ? never
+    : KeyType]: ObjectType[KeyType];
+};
diff --git a/src/type-fest/pick-index-signature.ts b/src/type-fest/pick-index-signature.ts
new file mode 100644
index 0000000..c5fc69f
--- /dev/null
+++ b/src/type-fest/pick-index-signature.ts
@@ -0,0 +1,5 @@
+export type PickIndexSignature<ObjectType> = {
+  [KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
+    ? KeyType
+    : never]: ObjectType[KeyType];
+};
diff --git a/src/type-fest/simplify.ts b/src/type-fest/simplify.ts
new file mode 100644
index 0000000..fd1a5e3
--- /dev/null
+++ b/src/type-fest/simplify.ts
@@ -0,0 +1,1 @@
+export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
diff --git a/tsconfig.json b/tsconfig.json
index 6fa6668..265720e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,7 +17,7 @@
     "noEmit": true,
 
     // LANGUAGE and ENVIRONMENT
-    "lib": ["ES6", "ES2017.Object"],
+    "lib": ["ES6", "ES2017.Object", "DOM", "DOM.Iterable"],
     // TODO: The recommended target is ES2015 (ES6). Node10 supports es2018 and
     // the vast majority of browsers are probably above that too already. It's
     // most likely safe to up this value, and it would probably result in a


Test patch:
diff --git a/src/debounce.test.ts b/src/debounce.test.ts
new file mode 100644
index 0000000..ae12cb0
--- /dev/null
+++ b/src/debounce.test.ts
@@ -0,0 +1,457 @@
+import { debounce } from './debounce';
+import { identity } from './identity';
+
+describe('Main functionality', () => {
+  it('should debounce a function', async () => {
+    const mockFn = vi.fn(identity);
+
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+
+    expect([
+      debouncer.call('a'),
+      debouncer.call('b'),
+      debouncer.call('c'),
+    ]).toEqual([undefined, undefined, undefined]);
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(128);
+
+    expect(mockFn).toBeCalledTimes(1);
+    expect([
+      debouncer.call('d'),
+      debouncer.call('e'),
+      debouncer.call('f'),
+    ]).toEqual(['c', 'c', 'c']);
+    expect(mockFn).toBeCalledTimes(1);
+
+    await sleep(256);
+
+    expect(mockFn).toBeCalledTimes(2);
+  });
+
+  it('subsequent debounced calls return the last `func` result', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    debouncer.call('a');
+
+    await sleep(64);
+    expect(debouncer.call('b')).toEqual('a');
+
+    await sleep(128);
+    expect(debouncer.call('c')).toEqual('b');
+  });
+
+  it('should not immediately call `func` when `wait` is `0`', async () => {
+    const mockFn = vi.fn();
+
+    const debouncer = debounce(mockFn, {});
+
+    debouncer.call();
+    debouncer.call();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(5);
+    expect(mockFn).toBeCalledTimes(1);
+  });
+
+  it('should apply default options', async () => {
+    const mockFn = vi.fn();
+
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+
+    debouncer.call();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(64);
+    expect(mockFn).toBeCalledTimes(1);
+  });
+
+  it('should support a `leading` option', async () => {
+    const leadingMockFn = vi.fn();
+    const bothMockFn = vi.fn();
+
+    const withLeading = debounce(leadingMockFn, {
+      waitMs: 32,
+      timing: 'leading',
+    });
+
+    withLeading.call();
+    expect(leadingMockFn).toBeCalledTimes(1);
+
+    const withLeadingAndTrailing = debounce(bothMockFn, {
+      waitMs: 32,
+      timing: 'both',
+    });
+
+    withLeadingAndTrailing.call();
+    withLeadingAndTrailing.call();
+    expect(bothMockFn).toBeCalledTimes(1);
+
+    await sleep(64);
+    expect(bothMockFn).toBeCalledTimes(2);
+
+    withLeading.call();
+    expect(leadingMockFn).toBeCalledTimes(2);
+  });
+
+  it('subsequent leading debounced calls return the last `func` result', async () => {
+    const debouncer = debounce(identity, { waitMs: 32, timing: 'leading' });
+
+    expect([debouncer.call('a'), debouncer.call('b')]).toEqual(['a', 'a']);
+
+    await sleep(64);
+    expect([debouncer.call('c'), debouncer.call('d')]).toEqual(['c', 'c']);
+  });
+
+  it('should support a `trailing` option', async () => {
+    const mockFn = vi.fn();
+
+    const withTrailing = debounce(mockFn, { waitMs: 32, timing: 'trailing' });
+
+    withTrailing.call();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(64);
+    expect(mockFn).toBeCalledTimes(1);
+  });
+});
+
+describe('Optional param maxWaitMs', () => {
+  it('should support a `maxWait` option', async () => {
+    const mockFn = vi.fn(identity);
+
+    const debouncer = debounce(mockFn, { waitMs: 32, maxWaitMs: 64 });
+
+    debouncer.call('a');
+    debouncer.call('b');
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(128);
+    expect(mockFn).toBeCalledTimes(1);
+    debouncer.call('c');
+    debouncer.call('d');
+    expect(mockFn).toBeCalledTimes(1);
+
+    await sleep(256);
+    expect(mockFn).toBeCalledTimes(2);
+  });
+
+  it('should support `maxWait` in a tight loop', async () => {
+    const withMockFn = vi.fn();
+    const withoutMockFn = vi.fn();
+
+    const withMaxWait = debounce(withMockFn, { waitMs: 32, maxWaitMs: 128 });
+    const withoutMaxWait = debounce(withoutMockFn, { waitMs: 96 });
+
+    const start = Date.now();
+    while (Date.now() - start < 320) {
+      withMaxWait.call();
+      withoutMaxWait.call();
+    }
+
+    await sleep(1);
+    expect(withoutMockFn).toBeCalledTimes(0);
+    expect(withMockFn).not.toBeCalledTimes(0);
+  });
+
+  it('should queue a trailing call for subsequent debounced calls after `maxWait`', async () => {
+    const mockFn = vi.fn();
+
+    const debouncer = debounce(mockFn, { waitMs: 200, maxWaitMs: 200 });
+
+    debouncer.call();
+
+    setTimeout(() => {
+      debouncer.call();
+    }, 190);
+    setTimeout(() => {
+      debouncer.call();
+    }, 200);
+    setTimeout(() => {
+      debouncer.call();
+    }, 210);
+
+    await sleep(500);
+    expect(mockFn).toBeCalledTimes(2);
+  });
+
+  it('should cancel `maxDelayed` when `delayed` is invoked', async () => {
+    const mockFn = vi.fn();
+
+    const debouncer = debounce(mockFn, { waitMs: 32, maxWaitMs: 64 });
+
+    debouncer.call();
+
+    await sleep(128);
+    debouncer.call();
+    expect(mockFn).toBeCalledTimes(1);
+
+    await sleep(192);
+    expect(mockFn).toBeCalledTimes(2);
+  });
+
+  it('works like a leaky bucket when only maxWaitMs is set', async () => {
+    const mockFn = vi.fn();
+
+    const debouncer = debounce(mockFn, { maxWaitMs: 32 });
+
+    debouncer.call();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(16);
+
+    // Without maxWaitMs this call would cause the actual invocation to be
+    // postponed for a full window.
+    debouncer.call();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(17);
+    expect(mockFn).toBeCalledTimes(1);
+  });
+});
+
+describe('Additional functionality', () => {
+  it('can cancel before the timer starts', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(() => debouncer.cancel()).not.toThrow();
+
+    expect(debouncer.call('hello')).toBeUndefined();
+    await sleep(32);
+
+    expect(debouncer.call('world')).toEqual('hello');
+  });
+
+  it('can cancel the timer', async () => {
+    const data = 'Hello, World!';
+    const mockFn = vi.fn(() => data);
+    const debouncer = debounce(mockFn, { waitMs: 32 });
+
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(1);
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toBeCalledTimes(0);
+    debouncer.cancel();
+
+    await sleep(32);
+    expect(debouncer.call()).toBeUndefined();
+    expect(mockFn).toBeCalledTimes(0);
+
+    await sleep(32);
+    expect(debouncer.call()).toEqual(data);
+    expect(mockFn).toBeCalledTimes(1);
+  });
+
+  it('can cancel after the timer ends', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(debouncer.call('hello')).toBeUndefined();
+    await sleep(32);
+
+    expect(debouncer.call('world')).toEqual('hello');
+    expect(() => debouncer.cancel()).not.toThrow();
+  });
+
+  it('can cancel maxWait timer', async () => {
+    const debouncer = debounce(identity, { waitMs: 16, maxWaitMs: 32 });
+    expect(debouncer.call('hello')).toBeUndefined();
+
+    await sleep(1);
+    debouncer.cancel();
+
+    await sleep(32);
+    expect(debouncer.call('world')).toBeUndefined();
+  });
+
+  it('can return a cached value', () => {
+    const debouncer = debounce(identity, { timing: 'leading', waitMs: 32 });
+    expect(debouncer.cachedValue).toBeUndefined();
+    expect(debouncer.call('hello')).toEqual('hello');
+    expect(debouncer.cachedValue).toEqual('hello');
+  });
+
+  it('can check for inflight timers (trailing)', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(debouncer.isPending).toEqual(false);
+
+    expect(debouncer.call('hello')).toBeUndefined();
+    expect(debouncer.isPending).toEqual(true);
+
+    await sleep(1);
+    expect(debouncer.isPending).toEqual(true);
+
+    await sleep(32);
+    expect(debouncer.isPending).toEqual(false);
+  });
+
+  it('can check for inflight timers (trailing)', async () => {
+    const debouncer = debounce(identity, { timing: 'leading', waitMs: 32 });
+    expect(debouncer.isPending).toEqual(false);
+
+    expect(debouncer.call('hello')).toEqual('hello');
+    expect(debouncer.isPending).toEqual(true);
+
+    await sleep(1);
+    expect(debouncer.isPending).toEqual(true);
+
+    await sleep(32);
+    expect(debouncer.isPending).toEqual(false);
+  });
+
+  it('can flush before a cool-down', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(debouncer.flush()).toBeUndefined();
+
+    expect(debouncer.call('hello')).toBeUndefined();
+    await sleep(32);
+
+    expect(debouncer.call('world')).toEqual('hello');
+  });
+
+  it('can flush during a cool-down', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(debouncer.call('hello')).toBeUndefined();
+
+    await sleep(1);
+    expect(debouncer.call('world')).toBeUndefined();
+
+    await sleep(1);
+    expect(debouncer.flush()).toEqual('world');
+  });
+
+  it('can flush after a cool-down', async () => {
+    const debouncer = debounce(identity, { waitMs: 32 });
+    expect(debouncer.call('hello')).toBeUndefined();
+
+    await sleep(32);
+    expect(debouncer.flush()).toEqual('hello');
+  });
+});
+
+describe('errors', () => {
+  it('prevents maxWaitMs to be less then waitMs', () => {
+    expect(() => debounce(identity, { waitMs: 32, maxWaitMs: 16 })).toThrow();
+  });
+});
+
+describe('typing', () => {
+  it("returns undefined on 'trailing' timing", () => {
+    const debouncer = debounce(() => 'Hello, World!', {
+      waitMs: 32,
+      timing: 'trailing',
+    });
+    const result = debouncer.call();
+    expectTypeOf(result).toEqualTypeOf<string | undefined>();
+  });
+
+  it("doesn't return undefined on 'leading' timing", () => {
+    const debouncer = debounce(() => 'Hello, World!', {
+      waitMs: 32,
+      timing: 'leading',
+    });
+    const result = debouncer.call();
+    expectTypeOf(result).toEqualTypeOf<string>();
+  });
+
+  it("doesn't return undefined on 'both' timing", () => {
+    const debouncer = debounce(() => 'Hello, World!', {
+      waitMs: 32,
+      timing: 'both',
+    });
+    const result = debouncer.call();
+    expectTypeOf(result).toEqualTypeOf<string>();
+  });
+
+  test('argument typing to be good (all required)', () => {
+    const debouncer = debounce(
+      (a: string, b: number, c: boolean) => `${a}${b}${c}`,
+      {}
+    );
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 0.
+    debouncer.call();
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 1.
+    debouncer.call('a');
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 2.
+    debouncer.call('a', 1);
+
+    // @ts-expect-error [ts2354]: boolean instead of string
+    debouncer.call(true, 1, true);
+
+    // All good
+    debouncer.call('a', 1, true);
+  });
+
+  test('argument typing to be good (with optional)', () => {
+    const debouncer = debounce(
+      (a: string, b?: number, c?: boolean) => `${a}${b}${c}`,
+      {}
+    );
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 1.
+    debouncer.call();
+
+    // @ts-expect-error [ts2354]: boolean instead of string
+    debouncer.call(true, 1, true);
+
+    // All good
+    debouncer.call('a');
+    debouncer.call('a', 1);
+    debouncer.call('a', 1, true);
+  });
+
+  test('argument typing to be good (with defaults)', () => {
+    const debouncer = debounce(
+      (a: string, b: number = 2, c: boolean = true) => `${a}${b}${c}`,
+      {}
+    );
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 1.
+    debouncer.call();
+
+    // @ts-expect-error [ts2354]: boolean instead of string
+    debouncer.call(true, 1, true);
+
+    // All good
+    debouncer.call('a');
+    debouncer.call('a', 1);
+    debouncer.call('a', 1, true);
+  });
+
+  test('argument typing to be good (with rest param)', async () => {
+    const debouncer = debounce(
+      (a: string, ...flags: ReadonlyArray<boolean>) =>
+        `${a}${flags.map(flag => (flag ? 'y' : 'n')).join()}`,
+      { timing: 'leading' }
+    );
+    // @ts-expect-error [ts2554]: Expected 3 arguments, but got 1.
+    debouncer.call();
+
+    // @ts-expect-error [ts2354]: boolean instead of string
+    debouncer.call(true);
+
+    // @ts-expect-error [ts2354]: string instead of boolean
+    debouncer.call('a', 'b');
+
+    // @ts-expect-error [ts2354]: boolean instead of string
+    debouncer.call(true, 'b');
+
+    // All good
+    debouncer.call('a');
+    debouncer.call('a', true);
+    debouncer.call('a', true, false);
+
+    await sleep(64);
+
+    expect(
+      debouncer.call('a', true, true, false, false, true, false, true)
+    ).toEqual('ay,y,n,n,y,n,y');
+  });
+
+  it("doesn't accept maxWaitMs when timing is 'leading'", () => {
+    debounce(identity, { timing: 'trailing', maxWaitMs: 32 });
+    debounce(identity, { timing: 'both', maxWaitMs: 32 });
+    // @ts-expect-error [ts2769]: maxWaitMs not supported!
+    debounce(identity, { timing: 'leading', maxWaitMs: 32 });
+  });
+});
+
+async function sleep(ms: number): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, ms));
+}
diff --git a/src/mergeDeep.test.ts b/src/mergeDeep.test.ts
new file mode 100644
index 0000000..c3291a3
--- /dev/null
+++ b/src/mergeDeep.test.ts
@@ -0,0 +1,134 @@
+import { mergeDeep } from './mergeDeep';
+
+const NOOP = () => {
+  /* do nothing */
+};
+
+describe('runtime (dataFirst)', () => {
+  test('should merge objects', () => {
+    const a = { foo: 'baz', x: 1 };
+    const b = { foo: 'bar', y: 2 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 'bar', x: 1, y: 2 });
+  });
+
+  test('should merge nested objects', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: { qux: 'quux' } };
+    expect(mergeDeep(a, b)).toEqual({ foo: { bar: 'baz', qux: 'quux' } });
+  });
+
+  test('should not merge object and array', () => {
+    const a = { foo: ['qux'] };
+    const b = { foo: { bar: 'baz' } };
+    expect(mergeDeep(a, b)).toEqual({ foo: { bar: 'baz' } });
+  });
+
+  test('should not merge array and object', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: ['qux'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['qux'] });
+  });
+
+  it('should not merge arrays', () => {
+    const a = { foo: ['bar'] };
+    const b = { foo: ['baz'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['baz'] });
+  });
+
+  it('should merge different types', () => {
+    const a = { foo: 'bar' };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+  });
+
+  it('should work with weird object types, null', () => {
+    const a = { foo: null };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: null });
+  });
+
+  it('should work with weird object types, functions', () => {
+    const a = { foo: NOOP };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: NOOP });
+  });
+
+  it('should work with weird object types, date', () => {
+    const a = { foo: new Date(1337) };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: new Date(1337) });
+  });
+
+  it("doesn't spread arrays", () => {
+    const a = { foo: ['bar'] };
+    const b = { foo: ['baz'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['baz'] });
+  });
+
+  it("doesn't recurse into arrays", () => {
+    const a = { foo: [{ bar: 'baz' }] };
+    const b = { foo: [{ bar: 'hello, world' }] };
+    expect(mergeDeep(a, b)).toEqual({ foo: [{ bar: 'hello, world' }] });
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  test('should merge objects', () => {
+    const a = { foo: 'baz', x: 1 };
+    const b = { foo: 'bar', y: 2 };
+    expect(mergeDeep(b)(a)).toEqual({ foo: 'bar', x: 1, y: 2 });
+  });
+});
+
+describe('typing', () => {
+  it('trivially merges disjoint objects', () => {
+    const a = { foo: 'bar' };
+    const b = { bar: 'baz' };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string; bar: string }>();
+  });
+
+  it('merges fully overlapping types', () => {
+    const a = { foo: 'bar' };
+    const b = { foo: 'baz' };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string }>();
+  });
+
+  it('merges semi-overlapping types', () => {
+    const a = { foo: 'bar', x: 1 };
+    const b = { foo: 'baz', y: 2 };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string; x: number; y: number }>();
+  });
+
+  it('deeply merges', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: { qux: 'quux' } };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: { bar: string; qux: string } }>();
+  });
+
+  it('overrides types', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: 'qux' };
+    expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
+    expectTypeOf(mergeDeep(b, a)).toEqualTypeOf<typeof a>();
+  });
+
+  it("doesn't spread arrays", () => {
+    const a = { foo: ['bar'] as const };
+    const b = { foo: ['baz'] as const };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: readonly ['baz'] }>();
+  });
+
+  it("doesn't recurse into arrays", () => {
+    const a = { foo: [{ bar: 'baz' }] };
+    const b = { foo: [{ bar: 'hello, world' }] };
+    expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
+  });
+});
diff --git a/src/takeLastWhile.test.ts b/src/takeLastWhile.test.ts
new file mode 100644
index 0000000..4171b7e
--- /dev/null
+++ b/src/takeLastWhile.test.ts
@@ -0,0 +1,62 @@
+import { pipe } from './pipe';
+import { takeLastWhile } from './takeLastWhile';
+
+describe('data first', () => {
+  it('should return items after the last predicate failure', () => {
+    expect(takeLastWhile([1, 2, 3, 4], n => n !== 2)).toStrictEqual([3, 4]);
+  });
+
+  it('should return an empty array when the last item fails the predicate', () => {
+    expect(takeLastWhile([1, 2, 3, 4], n => n !== 4)).toStrictEqual([]);
+  });
+
+  it('should return an empty array when an empty array is passed', () => {
+    expect(takeLastWhile([], n => n > 0)).toStrictEqual([]);
+  });
+
+  it('should return a copy of the original array when all items pass the predicate', () => {
+    const data = [1, 2, 3, 4];
+    const result = takeLastWhile(data, n => n > 0);
+    expect(result).toStrictEqual(data);
+    expect(result).not.toBe(data);
+  });
+});
+
+describe('data last', () => {
+  it('should return items after the last predicate failure', () => {
+    expect(
+      pipe(
+        [1, 2, 3, 4],
+        takeLastWhile(n => n !== 2)
+      )
+    ).toStrictEqual([3, 4]);
+  });
+
+  it('should return an empty array when the last item fails the predicate', () => {
+    expect(
+      pipe(
+        [1, 2, 3, 4],
+        takeLastWhile(n => n !== 4)
+      )
+    ).toStrictEqual([]);
+  });
+
+  it('should return an empty array when an empty array is passed', () => {
+    expect(
+      pipe(
+        [],
+        takeLastWhile(n => n > 0)
+      )
+    ).toStrictEqual([]);
+  });
+
+  it('should return a copy of the original array when all items pass the predicate', () => {
+    const data = [1, 2, 3, 4];
+    const result = pipe(
+      data,
+      takeLastWhile(n => n > 0)
+    );
+    expect(result).toStrictEqual(data);
+    expect(result).not.toBe(data);
+  });
+});