Skip to content

Conversation

@sen2y
Copy link
Contributor

@sen2y sen2y commented Oct 1, 2025

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
Copy link

vercel bot commented Oct 1, 2025

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

Project Deployment Preview Comments Updated (UTC)
es-toolkit Canceled Canceled Oct 24, 2025 4:42am

@raon0211
Copy link
Collaborator

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

@sen2y
Copy link
Contributor Author

sen2y commented Oct 29, 2025

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

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