Skip to content

fix(isMatch): handle primitive targets with object source patterns correctly - #1432

Merged
raon0211 merged 2 commits into
toss:mainfrom
sen2y:fix/isMatch-nested-undefined-handling
Aug 17, 2026
Merged

raon0211 merged 2 commits into
toss:mainfrom
sen2y:fix/isMatch-nested-undefined-handling

Conversation

@sen2y

@sen2y sen2y commented Oct 1, 2025

Copy link
Copy Markdown
Contributor

Fix: Correct isMatch primitive target vs object pattern matching

Fixes #1399


🐛 Problem

Two incorrect matches versus Lodash were identified.

Bug 1: Top-level primitive target vs object source (with undefined)

Case:

isMatch('bar', { anyKey: undefined });

Observed behavior before fix:

  • Lodash: false ✅
  • ES-Toolkit (before): true ❌

Root cause:

  • Accessing a non-existent key on a primitive target yields undefined:
'bar'['anyKey']; // -> undefined
source['anyKey']; // -> undefined

These two undefined values accidentally compare as equal, yielding a false positive.

Bug 2: Nested level primitive vs empty object

Case:

isMatch({ value: 'bar' }, { value: {} });

Observed behavior before fix:

  • Lodash: false ✅
  • ES-Toolkit (before): true ❌

Why it is confusing:

  • Top-level: isMatch('bar', {}) should be true (empty object = no constraints)
  • Nested: isMatch('bar', {}) must be false (type mismatch vs object pattern)

Lodash intent:

  • Top-level empty object is permissive (matches anything)
  • Nested empty object still enforces object type (primitive must not match object)

ES-Toolkit previously treated empty objects as truthy matches at all levels.


🔧 Solution

  1. Reject object-pattern matching against primitive targets
// If source expects object keys, target must be an object
if (!isObject(target)) {
  return false;
}

Fixes Bug 1:

isMatch('bar', { anyKey: undefined }); // false ✅
isMatch(123, { x: 1 }); // false ✅
  1. Distinguish top-level vs nested level for empty-object source using stack size
if (keys.length === 0) {
  // Only at nested levels, primitives must not match {}
  if (stack && stack.size > 0 && !isObject(target)) {
    return false;
  }
  return true;
}

Rationale:

  • At the top level (stack.size === 0), {} means "no constraints" -> allow any target
  • At nested levels (stack.size > 0), {} still implies object type -> primitives must fail

Implementation detail:

  • isMatchWith creates a new stack (new Map()) at the entry point.
  • On recursion, objects are pushed to the stack (stack.set(...)), so stack.size naturally reflects depth.

🧪 Tests

Added to src/compat/predicate/isMatch.spec.ts.

  1. Primitive target with object source (Bug 1)
it('returns false when target is primitive and source has object keys (Issue #1399)', () => {
  expect(isMatch('bar', { anyKey: undefined })).toBe(false);
  expect(isMatch(123, { anyKey: undefined })).toBe(false);
  expect(isMatch(true, { anyKey: undefined })).toBe(false);
});
  1. Nested empty object matching (Bug 2)
it('returns false when nested target is primitive and source is empty object (Issue #1399)', () => {
  expect(isMatch({ value: 'bar' }, { value: {} })).toBe(false);
  expect(isMatch({ value: 123 }, { value: {} })).toBe(false);
  expect(isMatch({ value: true }, { value: {} })).toBe(false);
});

Result:

  • ✅ 25/25 tests passed
  • ✅ No type errors

📊 Summary of Changes

Modified files:

  • src/compat/predicate/isMatchWith.ts (logic updates)
  • src/compat/predicate/isMatch.spec.ts (new test cases)

Code added (conceptually):

// 1) Empty-object handling by level (top-level vs nested)
if (keys.length === 0) {
  if (stack && stack.size > 0 && !isObject(target)) {
    return false;
  }
  return true;
}

// 2) Primitive target cannot match object pattern
if (!isObject(target)) {
  return false;
}

Only a few lines, but fully aligns with Lodash behavior.


🧭 Behavior vs Lodash

Before (incorrect):

isMatch('bar', { anyKey: undefined }); // ES-Toolkit: true ❌, Lodash: false
isMatch({ value: 'bar' }, { value: {} }); // ES-Toolkit: true ❌, Lodash: false

After (fixed):

isMatch('bar', { anyKey: undefined }); // ES-Toolkit: false ✅, Lodash: false
isMatch({ value: 'bar' }, { value: {} }); // ES-Toolkit: false ✅, Lodash: false
isMatch('bar', {}); // ES-Toolkit: true  ✅, Lodash: true

✅ Checklist

@vercel

vercel Bot commented Oct 1, 2025 •

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 Aug 17, 2026 12:49am

Request Review

Comment thread src/compat/predicate/isMatchWith.ts Resolved
Comment thread src/compat/predicate/isMatchWith.ts Resolved
@raon0211

Copy link
Copy Markdown
Collaborator

Thanks for your work! Let me refactor this a little bit after merging.

@sen2y

sen2y commented Oct 29, 2025 •

Copy link
Copy Markdown
Contributor Author

Thanks for your work! Let me refactor this a little bit after merging.

Thanks for reviewing and refining the code! 🙌
It’s much clearer thanks to your help.

While working on this fix, I also discovered some behavior differences between Lodash and compat.
I opened an issue to discuss potential improvements and would love your thoughts when you have a moment: #1504

Thanks again — please let me know if there's anything else I can help with before merging! 😊

…rimitive and non-plain-object targets

At nested levels, lodash rejects plain-object source patterns (including
empty ones) when the target is not a plain object-like value, using a tag
comparison. At the top level, lodash wraps primitive targets with Object()
so that patterns like { length: 3 } can match 'bar'.

Align isMatchWith with both behaviors:

- isMatch({ value: 'bar' }, { value: {} }) now returns false (was true)
- isMatch({ a: [] }, { a: {} }) now returns false (was true)
- isMatch('bar', { length: 3 }) now returns true (was false)
- isMatch('bar', { anyKey: undefined }) still returns false

Co-authored-by: sen2y <pse6704@sookmyung.ac.kr>
@raon0211
raon0211 force-pushed the fix/isMatch-nested-undefined-handling branch from a8332ab to 38bbd75 Compare August 17, 2026 00:39
raon0211
raon0211 previously approved these changes Aug 17, 2026

@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!

@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!

@raon0211
raon0211 merged commit 4c94c3d into toss:main Aug 17, 2026
12 checks passed
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 — 60c70fa1 Deployed Aug 17, 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.

isMatch from es-toolkit/compat does not behave like lodash-es/isMatch

2 participants