Skip to content

fix(compat): skip null/undefined iteratee values in maxBy/minBy - #1927

Merged
raon0211 merged 2 commits into
toss:mainfrom
Kropiunig:fix/maxby-minby-null-undefined
Jul 28, 2026
Merged

raon0211 merged 2 commits into
toss:mainfrom
Kropiunig:fix/maxby-minby-null-undefined

Conversation

@Kropiunig

Copy link
Copy Markdown
Contributor

Summary

compat's maxBy/minBy diverge from lodash (and from their own sibling max/min) when the iteratee yields null or undefined for elements — e.g. a missing/optional key.

They only skip NaN:

if (Number.isNaN(current)) {
  continue;
}

but max/min skip current == null (and symbols) as well. Two problems follow:

  1. max/min is initialised to undefined and reused as the "not yet seen" sentinel (max === undefined). An undefined iteratee result sets the sentinel back to undefined, so it never flips and every later element overwrites the result — the function returns the last element instead of undefined.
  2. A null result is coerced to 0 by the > / < comparison, so it can be picked as the extremum.

Reproduction

import { maxBy, minBy } from 'es-toolkit/compat';
import _ from 'lodash';

maxBy([{ a: 1 }, { a: 2 }], 'b');        // es-toolkit: { a: 2 }   lodash: undefined
maxBy([{ a: null }, { a: null }], 'a');  // es-toolkit: { a: null } lodash: undefined
minBy([5, undefined, 3, null], x => x);  // es-toolkit: null        lodash: 3

Fix

Mirror the guard already used by max/min so null, undefined, and symbol values are skipped. With those values skipped, the existing === undefined sentinel becomes reliable again (it can only ever be set from a real comparable value).

if (current == null || Number.isNaN(current) || typeof current === 'symbol') {
  continue;
}

Verification

  • All existing maxBy/minBy specs still pass (Date, ±Infinity, string iteratees, NaN skipping, single/large arrays).
  • Added regression tests covering missing keys and mixed null/undefined/numeric values; they fail on main and pass with the fix.
  • Differentially fuzz-tested the patched functions against lodash over several thousand combinations of undefined/null/NaN/±Infinity/number/string values (property, function, and identity iteratees) with zero divergences.

`maxBy`/`minBy` only skipped `NaN` iteratee results, unlike lodash (and the
sibling `max`/`min`, which skip `current == null`). Because `max`/`min` is
initialised to `undefined` and also used as the "not yet seen" sentinel, an
`undefined` iteratee result (e.g. a missing key) set the sentinel to
`undefined`, so `max === undefined` stayed true and every later element
overwrote the result — returning the last element instead of `undefined`.
A `null` result was coerced to `0` by the `>`/`<` comparison and could be
selected as the extremum.

Mirror the guard already used by `max`/`min` so `null`, `undefined` and
symbol values are skipped, restoring lodash parity:

  maxBy([{ a: 1 }, { a: 2 }], 'b')            // was { a: 2 } -> now undefined
  minBy([5, undefined, 3, null], x => x)       // was null    -> now 3

Adds regression tests for both functions.
@Kropiunig
Kropiunig requested a review from raon0211 as a code owner July 20, 2026 16:24
Copilot AI review requested due to automatic review settings July 20, 2026 16:24
@Kropiunig
Kropiunig requested a review from dayongkr as a code owner July 20, 2026 16:24
@vercel

vercel Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
es-toolkit Ready Ready Preview, Comment Jul 22, 2026 9:34am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes Lodash-compatibility issues in es-toolkit/compat’s maxBy/minBy when an iteratee produces null or undefined, aligning their skip-logic with the existing compat max/min guard (and Lodash’s typical extremum behavior).

Changes:

  • Update maxBy/minBy to skip null, undefined, NaN, and symbol iteratee results before comparing.
  • Add regression tests ensuring nullish iteratee results don’t reset the “unset” sentinel state and don’t get coerced into comparable numbers.
  • Add tests asserting undefined is returned when no comparable iteratee output exists.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/compat/math/minBy.ts Skips null/undefined/NaN/symbol iteratee outputs before updating the current minimum.
src/compat/math/minBy.spec.ts Adds regression coverage for nullish iteratee outputs and “no comparable values” returning undefined.
src/compat/math/maxBy.ts Skips null/undefined/NaN/symbol iteratee outputs before updating the current maximum.
src/compat/math/maxBy.spec.ts Adds regression coverage for nullish iteratee outputs and “no comparable values” returning undefined.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Antoliny0919 Antoliny0919 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice catch @Kropiunig !

It looks like there were a few compatibility behaviors that were missing!

The current implementation already looks really good, but I'd like to suggest a few improvements.

Comment thread src/compat/math/maxBy.ts
const current = getValue(element, i, array);

if (Number.isNaN(current)) {
if (current == null || Number.isNaN(current) || typeof current === 'symbol') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In Lodash, an error is only thrown when a Symbol appears after the first element. My guess is that this is actually an implementation bug in Lodash.

const sym = Symbol('x');
console.log(lodash.maxBy([{ a: 1 }, { a: sym }], 'a'));  // Error !!!

While compat should match Lodash's behavior as closely as possible, do you think it would be reasonable to make an exception for this case 🤔

Comment thread src/compat/math/minBy.ts
const current = getValue(element, i, array);

if (Number.isNaN(current)) {
if (current == null || Number.isNaN(current) || typeof current === 'symbol') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be great to add some test cases for Symbol as well! (same as maxBy)

Comment thread src/compat/math/maxBy.spec.ts Outdated
});

it('should skip null and undefined values, matching lodash', () => {
expect(maxBy([{ a: undefined }, { a: 5 }, { a: null }], 'a')).toEqual({ a: 5 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(maxBy([{ a: undefined }, { a: 5 }, { a: null }], 'a')).toEqual({ a: 5 });
expect(maxBy([{ a: undefined }, { a: -5 }, { a: null }], 'a')).toEqual({ a: -5 });

This test case covers the current change well.
However, it also passes on the previous implementation.

I think that's because it compares positive numbers with null and undefined.
What do you think about using negative numbers instead?
With negative values, the previous implementation would behave differently, so selecting the negative number would verify that the fix is actually working.

The maxBy null/undefined test used positive values, so `null` (coerced to
0) never beat the real maximum and the test passed on the pre-fix code too.
Switch maxBy to negative values so the assertion actually fails on the old
NaN-only guard. minBy keeps positive values, which is the mirror case that
already fails on the old code (0 wrongly wins as the minimum).

Add Symbol coverage to both specs, matching the skip-and-don't-throw
behaviour of the fix.
@Kropiunig

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review!

Applied your negative-value suggestion to the maxBy test — with a negative maximum, null (coerced to 0) would wrongly win on the old code, so the assertion now genuinely fails before the fix and passes after. For minBy I kept positive values, since that is the mirror case where 0 wrongly wins as the minimum; that test already failed on the old code. I also added Symbol coverage to both specs (I confirmed each new test fails on the pre-fix guard and passes now).

On the Symbol design question: I left the current skip-and-do-not-throw behavior in place. lodash only throws when a Symbol appears after the first element, which is position-dependent and looks like a bug, and never throwing feels safer for compat consumers who pass in mixed data. That said, this is your call — happy to switch to strict lodash parity and replicate the throw if you would rather match it exactly.

@raon0211 raon0211 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the catch! Let's stick with the current implementation for simplicity.

@raon0211
raon0211 merged commit 91c5f74 into toss:main Jul 28, 2026
12 checks passed
racgoo pushed a commit to racgoo/es-toolkit that referenced this pull request Jul 28, 2026
…#1927)

* fix(compat): skip null/undefined iteratee values in maxBy/minBy

`maxBy`/`minBy` only skipped `NaN` iteratee results, unlike lodash (and the
sibling `max`/`min`, which skip `current == null`). Because `max`/`min` is
initialised to `undefined` and also used as the "not yet seen" sentinel, an
`undefined` iteratee result (e.g. a missing key) set the sentinel to
`undefined`, so `max === undefined` stayed true and every later element
overwrote the result — returning the last element instead of `undefined`.
A `null` result was coerced to `0` by the `>`/`<` comparison and could be
selected as the extremum.

Mirror the guard already used by `max`/`min` so `null`, `undefined` and
symbol values are skipped, restoring lodash parity:

  maxBy([{ a: 1 }, { a: 2 }], 'b')            // was { a: 2 } -> now undefined
  minBy([5, undefined, 3, null], x => x)       // was null    -> now 3

Adds regression tests for both functions.

* test(compat): use negative values and add Symbol cases for maxBy/minBy

The maxBy null/undefined test used positive values, so `null` (coerced to
0) never beat the real maximum and the test passed on the pre-fix code too.
Switch maxBy to negative values so the assertion actually fails on the old
NaN-only guard. minBy keeps positive values, which is the mirror case that
already fails on the old code (0 wrongly wins as the minimum).

Add Symbol coverage to both specs, matching the skip-and-don't-throw
behaviour of the fix.
gwennlbh pushed a commit to cigaleapp/cigale that referenced this pull request Sep 8, 2026
##### [v1.52.0](https://github.com/toss/es-toolkit/blob/HEAD/CHANGELOG.md#Version-v1520)

Released on August 28th, 2026.

- Added the `es-toolkit/iterator` entrypoint, a lazy iterator module with `chunk`,
  `count`, `dropWhile`, `head`, `iterate`, `partition`, `range`, `scan`,
  `takeWhile`, `uniqBy`, and `zip` (\[[#1815](toss/es-toolkit#1815)]), plus `cartesianProduct` (\[[#2031](toss/es-toolkit#2031)]).
  The same functions are also available from `es-toolkit/fp/iterator`.

- Added `defer` and `deferAsync` to `es-toolkit/util`. (\[[#2032](toss/es-toolkit#2032)])

- Added `flowAsync` to `es-toolkit/fp`, a promise-aware left-to-right composition. (\[[#2033](toss/es-toolkit#2033)])

- Added the `EmptyObject`, `IsEqual`, `JSONValue`, `Primitive`, `SetOptional`,
  `SetRequired`, and `UnknownRecord` types. (\[[#2044](toss/es-toolkit#2044)])

- Fixed `merge` to infer the deep merged return type, and exported the `Merge` type. (\[[#1959](toss/es-toolkit#1959)])

- Fixed `omitBy` and `pickBy` to type numeric callback keys as strings, and
  exported the `ObjectKeys` type. (\[[#2037](toss/es-toolkit#2037)])

- Fixed `attempt` and `attemptAsync` to default their error type parameter to `unknown`. (\[[#2052](toss/es-toolkit#2052)])

The following bring `es-toolkit/compat` closer to Lodash. Behavior for the affected
edge cases now matches Lodash, so results may differ if you relied on the previous output.

- Fixed `compat/debounce` to skip the `maxWait` invocation when both `leading` and
  `trailing` are `false`. (\[[#1678](toss/es-toolkit#1678)])

- Fixed `compat/toString` to convert objects with a custom `valueOf`. (\[[#2034](toss/es-toolkit#2034)])

- Fixed `compat/mergeWith` to assign `null` values in array sources. (\[[#1929](toss/es-toolkit#1929)])

- Fixed `compat/has` and `compat/hasIn` to normalize the `-0` path key. (\[[#2035](toss/es-toolkit#2035)])

- Fixed `compat/differenceWith` and `compat/without` to normalize `-0` to `0`. (\[[#2049](toss/es-toolkit#2049)])

- Fixed `compat/findLastIndex` to convert `fromIndex` to an integer. (\[[#2047](toss/es-toolkit#2047)])

- Fixed `compat/xorWith` to match Lodash when no comparator is given. (\[[#2057](toss/es-toolkit#2057)])

- Covered the new iterator entrypoints in the dist and browser compatibility
  checks. (\[[#2030](toss/es-toolkit#2030)])

- Made the agent skills portable when installed on their own. (\[[#2054](toss/es-toolkit#2054)])

- Fixed documentation typos and the Deno install commands. (\[[#2062](toss/es-toolkit#2062)])
##### [v1.51.0](https://github.com/toss/es-toolkit/blob/HEAD/CHANGELOG.md#Version-v1510)

Released on August 17th, 2026.

- Added the `es-toolkit/bigint` entrypoint: `bigint` counterparts of the math
  functions (`sum`, `sumBy`, `max`, `min`, `maxBy`, `minBy`, `clamp`, `inRange`,
  `median`, `medianBy`, `percentile`, `range`, `rangeRight`), kept separate so
  the `number` implementations stay unchanged. (\[[#1966](toss/es-toolkit#1966)])

- Added `dedent`, which removes common leading whitespace from template strings. (\[[#1679](toss/es-toolkit#1679)])

- Added `deepFreeze` for recursively freezing objects. (\[[#1680](toss/es-toolkit#1680)])

- Added `mapValuesAsync` and `mapKeysAsync`. (\[[#1544](toss/es-toolkit#1544)])

- Added `toPascalCaseKeys`, `toKebabCaseKeys`, and `toConstantCaseKeys`,
  completing the object key casing family. (\[[#1984](toss/es-toolkit#1984)])

- Exported the `ToCamelCaseKeys`, `ToPascalCaseKeys`, `ToSnakeCaseKeys`,
  `ToKebabCaseKeys`, and `ToConstantCaseKeys` types. (\[[#1651](toss/es-toolkit#1651)])

- Added a `preserveArrays` option to `flattenObject`. (\[[#974](toss/es-toolkit#974)])

- Fixed `retry` to pass the last error to the `delay` callback (\[[#1759](toss/es-toolkit#1759)]) and to
  throw the last attempt's error without applying a final delay (\[[#1901](toss/es-toolkit#1901)]).

- Fixed `mergeWith` and `toMerged` to handle mixed array/object types
  consistently with `merge`. (\[[#2026](toss/es-toolkit#2026)])

- Fixed `pullAt` to remove the correct elements with negative indices. (\[[#1613](toss/es-toolkit#1613)])

- Fixed `at`, `pullAt`, `orderBy`, and `sortBy` to accept readonly arrays. (\[[#2016](toss/es-toolkit#2016)])

- Moved `limitAsync` from the array to the promise category. (\[[#2022](toss/es-toolkit#2022)])

- Included the NOTICE file in the published npm and JSR packages. (\[[#2020](toss/es-toolkit#2020)])

The following bring `es-toolkit/compat` closer to Lodash. Behavior for the affected
edge cases now matches Lodash, so results may differ if you relied on the previous output.

- Added `entries` and `entriesIn`. (\[[#1874](toss/es-toolkit#1874)])

- Fixed `compat/isMatch` and `compat/isMatchWith` to handle object source
  patterns against primitive and non-plain-object targets like Lodash. (\[[#1432](toss/es-toolkit#1432)])

- Fixed `compat/set` to block `constructor.prototype` paths, preventing
  prototype pollution. (\[[#1904](toss/es-toolkit#1904)])

- Fixed `compat/find` to respect array-like `length` (\[[#1958](toss/es-toolkit#1958)]) and to convert
  `fromIndex` to an integer (\[[#1961](toss/es-toolkit#1961)]), and `compat/indexOf` to convert
  `fromIndex` to an integer when searching for `NaN` (\[[#1951](toss/es-toolkit#1951)]).

- Fixed `compat/orderBy` to resolve unresolved deep paths and nullish elements
  to `undefined` (\[[#2010](toss/es-toolkit#2010)]), to exclude the `length` property from array-like
  values (\[[#2002](toss/es-toolkit#2002)]), and to read the criterion property from primitive values (\[[#1903](toss/es-toolkit#1903)]).

- Fixed `compat/toPath` to split unquoted dotted keys inside brackets. (\[[#1899](toss/es-toolkit#1899)])

- Fixed `compat/omit` to treat a key array as a single deep path. (\[[#1902](toss/es-toolkit#1902)])

- Fixed `compat/map` to make the iteratee optional with an identity default. (\[[#1928](toss/es-toolkit#1928)])

- Fixed `compat/size` to count combined Unicode symbols as single characters. (\[[#1900](toss/es-toolkit#1900)])

- Fixed `compat/intersectionBy` to support number, object, and nullish iteratee
  shorthands (\[[#1993](toss/es-toolkit#1993)]), and `compat/intersectionWith` to apply the comparator
  when deduplicating the first array (\[[#1953](toss/es-toolkit#1953)]).

- Fixed `compat/difference`, `compat/differenceBy`, `compat/uniqBy`, and
  `compat/unionBy` to normalize `-0` to `0`. (\[[#2007](toss/es-toolkit#2007)])

- Fixed `compat/maxBy` and `compat/minBy` to skip nullish iteratee values (\[[#1927](toss/es-toolkit#1927)]),
  and `compat/meanBy` to skip `undefined` values when averaging (\[[#1978](toss/es-toolkit#1978)]).

- Fixed `compat/some`, `compat/template` (\[[#2009](toss/es-toolkit#2009)]), and `compat/fill` (\[[#1952](toss/es-toolkit#1952)])
  to gate guard handling with `isIterateeCall`.

- Fixed `compat/iteratee` to treat a boolean as a property shorthand. (\[[#1941](toss/es-toolkit#1941)])

- Fixed `compat/reverse` to support array-like objects and `arguments`. (\[[#2008](toss/es-toolkit#2008)])

- Fixed `compat/toArray` to convert non-array-like iterables. (\[[#1999](toss/es-toolkit#1999)])

- Fixed `compat/toString` to render nested nullish values like Lodash. (\[[#1992](toss/es-toolkit#1992)])

- Fixed `compat/zipObject` to treat `null` keys and values as empty arrays. (\[[#2005](toss/es-toolkit#2005)])

- Fixed `compat/sampleSize` to clamp the default size to the collection length. (\[[#2003](toss/es-toolkit#2003)])

- Fixed `compat/takeWhile`, `compat/takeRightWhile`, and `compat/uniqBy` to
  support string inputs. (\[[#2004](toss/es-toolkit#2004)])

- Fixed `compat/chunk` to return an empty array for an empty collection with an
  `Infinity` size. (\[[#1995](toss/es-toolkit#1995)])

- Fixed `compat/lastIndexOf` to read holes in sparse arrays as `undefined`. (\[[#1977](toss/es-toolkit#1977)])

- Fixed `compat/last` to return `undefined` for empty array-like inputs. (\[[#1954](toss/es-toolkit#1954)])

- Fixed `compat/pullAll` to return the array as-is when `values` is nullish. (\[[#1962](toss/es-toolkit#1962)])

- Added a browser compatibility test suite. (\[[#2012](toss/es-toolkit#2012)])

- Improved documentation, JSDoc accuracy, tests, and internal refactoring across
  `compat` and the docs site. (\[[#1622](toss/es-toolkit#1622)], \[[#1789](toss/es-toolkit#1789)], \[[#1824](toss/es-toolkit#1824)], \[[#1844](toss/es-toolkit#1844)], \[[#1849](toss/es-toolkit#1849)],
  \[[#1854](toss/es-toolkit#1854)], \[[#1855](toss/es-toolkit#1855)], \[[#1859](toss/es-toolkit#1859)], \[[#1860](toss/es-toolkit#1860)], \[[#1868](toss/es-toolkit#1868)], \[[#1871](toss/es-toolkit#1871)], \[[#1887](toss/es-toolkit#1887)], \[[#1898](toss/es-toolkit#1898)],
  \[[#1933](toss/es-toolkit#1933)], \[[#1939](toss/es-toolkit#1939)], \[[#1947](toss/es-toolkit#1947)], \[[#1950](toss/es-toolkit#1950)], \[[#1955](toss/es-toolkit#1955)], \[[#1956](toss/es-toolkit#1956)], \[[#1957](toss/es-toolkit#1957)], \[[#1960](toss/es-toolkit#1960)],
  \[[#1963](toss/es-toolkit#1963)], \[[#1964](toss/es-toolkit#1964)], \[[#1968](toss/es-toolkit#1968)], \[[#1969](toss/es-toolkit#1969)], \[[#1970](toss/es-toolkit#1970)], \[[#1972](toss/es-toolkit#1972)], \[[#1979](toss/es-toolkit#1979)], \[[#1985](toss/es-toolkit#1985)],
  \[[#2000](toss/es-toolkit#2000)], \[[#2001](toss/es-toolkit#2001)], \[[#2021](toss/es-toolkit#2021)], \[[#2025](toss/es-toolkit#2025)], \[[#2027](toss/es-toolkit#2027)])

We sincerely thank [@Antoliny0919](https://github.com/Antoliny0919), [@D-Sketon](https://github.com/D-Sketon), [@kojesung](https://github.com/kojesung), [@chuenchen309](https://github.com/chuenchen309),
[@mariazuheros](https://github.com/mariazuheros), [@DongEun02](https://github.com/DongEun02), [@raon0211](https://github.com/raon0211), [@sukvvon](https://github.com/sukvvon), [@racgoo](https://github.com/racgoo), [@dayongkr](https://github.com/dayongkr), [@Kropiunig](https://github.com/Kropiunig),
[@ssi02014](https://github.com/ssi02014), [@sarathfrancis90](https://github.com/sarathfrancis90), [@mym0404](https://github.com/mym0404), [@mlnwns](https://github.com/mlnwns), [@kdelay](https://github.com/kdelay), [@gwagjiug](https://github.com/gwagjiug), [@dohwi](https://github.com/dohwi),
[@blro0319](https://github.com/blro0319), [@benedictleejh](https://github.com/benedictleejh), [@amir-rezaei](https://github.com/amir-rezaei), [@ianzone](https://github.com/ianzone), [@sen2y](https://github.com/sen2y), [@rossyman](https://github.com/rossyman),
[@eunwoo-levi](https://github.com/eunwoo-levi), [@ramong26](https://github.com/ramong26), [@Gdm0714](https://github.com/Gdm0714), and everyone else who contributed.
We appreciate your great efforts!

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — f3105405 Deployed Jul 22, 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.

4 participants