Skip to content

fix(compat/unionWith): match lodash comparator argument order - #2114

Merged
raon0211 merged 3 commits into
toss:mainfrom
ssi02014:fix/unionWith
Sep 30, 2026
Merged

raon0211 merged 3 commits into
toss:mainfrom
ssi02014:fix/unionWith

Conversation

@ssi02014

@ssi02014 ssi02014 commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

es-toolkit/compat's unionWith passes the comparator's two arguments in the opposite order from Lodash.

Lodash invokes the comparator as comparator(candidate, kept). compat forwarded the comparator straight to es-toolkit's uniqWith, which invokes it as areItemsEqual(kept, candidate).

A symmetric comparator such as isEqual cannot observe the difference, which is why every existing test passed. An asymmetric one — any comparator where comparator(a, b) !== comparator(b, a) — gets the wrong answer, and unionWith can emit duplicate values, the one thing it exists to prevent.

The difference

// Verified against `lodash@4.18.1`.
import { unionWith } from 'lodash';
import { unionWith as unionWithCompat } from 'es-toolkit/compat';

const greaterThan = (a: number, b: number) => a > b;

unionWith([1, 2, 3], [2, 3, 4], greaterThan); //       Lodash: [1]
unionWithCompat([1, 2, 3], [2, 3, 4], greaterThan); // as-is: es-toolkit/compat: [1, 2, 3, 3, 4]  ← `3` is emitted twice

unionWith([2, 4], [1, 2], greaterThan); //             Lodash: [2, 1]
unionWithCompat([2, 4], [1, 2], greaterThan); // as-is: es-toolkit/compat: [2, 4]

unionWith([3, 1, 2], [2, 6], greaterThan); //          Lodash: [3, 1]
unionWithCompat([3, 1, 2], [2, 6], greaterThan); // as-is: es-toolkit/compat: [3, 6]

The uniqWith function in es-toolkit/compat already resolves this issue by calling the comparator in the same order as Lodash.

import { uniqWith as uniqWithToolkit } from '../../array/uniqWith.ts';

export function uniqWith<T>(arr: ArrayLike<T> | null | undefined, comparator?: Comparator<T>): T[] {
  // ...
  return uniqWithToolkit(Array.from(arr), (kept, candidate) => comparator(candidate, kept));
}

Type Improvements

isArrayLikeObject is declared as value is object & { length: number }. Because JavaScript functions are objects with a numeric length property (representing the number of parameters), comparator function types like (a: T, b: T) => boolean satisfy this type predicate just like an ArrayLike<T> does.

function sum(value1, value2) {
  return value1 + value2;
}

console.log(Object.getOwnPropertyNames(sum));
// Output: ['length', 'name', 'prototype']

console.log(sum.length);
// 2 -> The number of parameters defined in the function

as-is

export function unionWith<T>(
  ...values: Array<ArrayLike<T> | null | undefined | ((a: T, b: T) => boolean)>
): T[] {
  const lastValue = last(values);
  const flattened = flattenArrayLike(values as Array<ArrayLike<T>>);

  if (isArrayLikeObject(lastValue) || lastValue == null) {
    lastValue; // ArrayLike<T> | ((a: T, b: T) => boolean) | null | undefined
    return uniq(flattened);
  }

  lastValue; // never

  return uniqWith(flattened, lastValue); // compiles, because `never` fits any parameter
}

Consequently, using isArrayLikeObject for type narrowing causes the false branch to strip away the function type along with array-likes, collapsing lastValue's type to never.

to-be

export function unionWith<T>(...values: Array<ArrayLike<T> | null | undefined | ((a: T, b: T) => boolean)>): T[] {
  const lastValue = last(values);
  const flattened = flattenArrayLike(values as Array<ArrayLike<T>>);

  if (typeof lastValue !== 'function') {
    lastValue; // ArrayLike<T> | null | undefined
    return uniq(flattened);
  }

  lastValue; // (a: T, b: T) => boolean
  return uniqWith(flattened, (kept, candidate) => lastValue(candidate, kept));
}

Narrowing on typeof lastValue !== 'function' instead gives lastValue its real type — (a: T, b: T) => boolean — so the comparator is genuinely type-checked at the call site from now on.

@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
es-toolkit Ready Ready Preview Sep 29, 2026 6:26am UTC

Request Review

@ssi02014 ssi02014 changed the title fix(compat/unionWith): stop emitting duplicates for an asymmetric comparator fix(compat/unionWith): match lodash comparator argument order Sep 21, 2026
Comment on lines +104 to +107
// `es-toolkit`'s `uniqWith` invokes the comparator as `(kept, candidate)`, but
// lodash documents and invokes it as `(candidate, kept)`. Swap the arguments so
// that asymmetric comparators behave the same as in lodash.
return uniqWith(flattened, (kept, candidate) => lastValue(candidate, kept));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just like with compat/uniqWith, I added comments

export function uniqWith<T>(arr: ArrayLike<T> | null | undefined, comparator?: Comparator<T>): T[] {
  if (!isArrayLike(arr)) {
    return [];
  }

  if (typeof comparator !== 'function') {
    return uniqToolkit(Array.from(arr));
  }

  // `es-toolkit`'s `uniqWith` invokes the comparator as `(kept, candidate)`, but
  // lodash documents and invokes it as `(candidate, kept)`. Swap the arguments so
  // that asymmetric comparators behave the same as in lodash.
  return uniqWithToolkit(Array.from(arr), (kept, candidate) => comparator(candidate, kept));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#2115

Once the pull request mentioned above is merged, it is recommended that you switch from using uniq/uniqWith to using compat/uniq and compat/uniqWith.

@raon0211
raon0211 merged commit 644cfd8 into toss:main Sep 30, 2026
12 checks passed
@ssi02014
ssi02014 deleted the fix/unionWith branch September 30, 2026 02:16

This branch was successfully deployed

1 active deployment
Preview — 81d2b499 Deployed Sep 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants