Repository: remeda/remeda (ts)

Issue:
`debounce` utils
For example, I'd like to delay state update when input value changes,
have any plans to implement debounce utils?

Pull request description:
feat(debounce): add debounce function
Closes #419

Inspired by the lodash implementation (https://github.com/lodash/lodash/blob/main/src/debounce.ts).

Tests were copied from lodash too (https://github.com/lodash/lodash/blob/main/test/debounce.spec.js), but the latest version of their test has some issues, and I'm not sure their tests actually run (I didn't try).

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `mapping.md`

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions!
- `fix`: changes to function implementations that fix a bug where a function produces the wrong _runtime_ results.
- `type`: type-only changes (params, overloading, return types, etc...)
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


Patch:
diff --git a/mapping.md b/mapping.md
index 9f6b3b1..539c85e 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,15 +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`            |
@@ -60,8 +61,8 @@ 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`            |
@@ -69,9 +70,9 @@ documentation when migrating._
 | `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`     |                     |
@@ -89,7 +90,7 @@ documentation when migrating._
 | `tap`               | `tap`               | `tap`               |
 | `times`             | `times`             | `times`             |
 | `toPairs`           | `toPairs`           | `toPairs`           |
-| `type`              | `-`                 | `type`              |
+| `type`              |                     | `type`              |
 | `uniq`              | `uniq`              | `uniq`              |
 | `uniqBy`            | `uniqBy`            | `uniqBy`            |
 | `uniqWith`          | `uniqWith`          | `uniqWith`          |
@@ -109,11 +110,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 c50395c..fe90c87 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';
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));
+}