You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add expect.satisfying(fn) as a new built-in asymmetric matcher. Wraps an arbitrary (value) => boolean predicate so it can be used anywhere an asymmetric matcher is accepted: toEqual, toHaveBeenCalledWith, objectContaining, whenCalledWith, etc.
Deferred follow-up from #16053. Listed there as "expect.predicate(fn) - function as asymmetric matcher."
Name chosen for parity with the existing -ing family: objectContaining, stringContaining, stringMatching, arrayContaining. Reads grammatically in assertions ("value satisfying predicate").
Alternatives considered: predicate, check, matching, fn. matching collides with stringMatching. predicate is technically accurate but less natural in prose. fn collides with jest.fn. Happy to swap if maintainers prefer.
Behavior
Match: predicate is invoked with the value. Return is coerced to boolean (truthy = match, falsy = no match). Same coercion semantics as the rest of the asymmetric matcher protocol.
Negation: expect.not.satisfying(fn) flips the boolean. Implemented as a mirrored Not class, same pattern as arrayContaining / objectContaining.
Async throws: Throws at match time with a clear message. Detected via Promise return from the predicate. The asymmetric matcher protocol is sync; silently accepting an async predicate would always pass (a Promise is truthy), which is the worst possible UX.
Errors thrown by predicate: open question, see Pitch section.
toString() output (shows up in failure diffs):
Satisfying[isPositive] // named function
Satisfying // anonymous arrow
Satisfying[positive number] // with explicit label (if Q1 lands)
NotSatisfying[...] // negated form
Output format matters for diff readability when multiple satisfying matchers are stacked inside an objectContaining or a whenCalledWith branch list.
TypeScript
T is inferred from the predicate's own parameter, so extracted predicates keep their types:
constisLong=(s: string)=>s.length>0;expect.satisfying(isLong);// T = string, inferredexpect.satisfying((n: number)=>n>0);// T = number, inferredexpect.satisfying(v=>!!v);// T = unknown
Gap: T will not flow in from anyhere
Contextual typing from toHaveBeenCalledWith does not flow into the predicate's parameter, because the matcher position is an AsymmetricMatcher | T union and TS cannot disambiguate to pull the real argument type through to the callback. Known limitation of AsymmetricMatcher being non-generic and structural. Phantom typing (e.g. AsymmetricMatcher & {__matches: T}) could explore this in a future RFC but is invasive and out of scope here.
Motivation
whenCalledWith(...) lets users route mock calls by argument shape. Equality checks plus all existing asymmetric matchers work. What doesn't work today: arbitrary predicates (range checks, cross-field invariants, computed conditions).
// Today: no built-in for predicate-style matching.// Either fall through and assert on the recorded calls afterwards, or roll a// custom matcher via expect.extend.fn.whenCalledWith(/* no way to perform an ad-hoc logic check inline */);// Wanted:fn.whenCalledWith(expect.satisfying(req=>req.amount>1000)).mockReturnValue('needs-approval');
The same gap exists in toEqual and toHaveBeenCalledWith, but whenCalledWith makes it most painful because branching by ad-hoc predicate is a primary use case for that API.
Addressing the "matchers belong in jest-extended" note
The form notes that new matchers typically don't make it to core and points at jest-extended. This proposal is different in two ways:
It's an asymmetric matcher, not a regular matcher.jest-extended is a collection of assertion-side matchers (expect(x).toBeArrayOfSize(n)). This proposal sits next to any / objectContaining / arrayContaining β the matcher-argument-position primitives. Those are core's job; jest-extended doesn't ship asymmetric matchers in that position.
It's a primitive that unlocks whenCalledWith (just merged in feat(jest-mock): add mock.whenCalledWith(...)Β #16053). Predicate routing was an explicit deferred follow-up. Without it, whenCalledWith ships with a noticeable gap for any mock test that needs to branch on a computed condition rather than a shape match.
So while I understand the "new matchers go to jest-extended" guidance, this one belongs alongside the existing asymmetric matchers in core. Happy to discuss if maintainers disagree.
Relationship to expect.extend
Users can already approximate this today:
expect.extend({matchesPredicate(received,fn){return{pass: fn(received),message: ()=>'...'};},});// then use anywhere:expect.matchesPredicate(isPositive);
The asymmetric form of expect.extend matchers does work in toEqual / toHaveBeenCalledWith / whenCalledWith. So this proposal does not add new capability.
What it adds:
Standardization. Every codebase that needs this rolls a slightly different version with a different name and format. One built-in fixes that.
Discoverability.expect.satisfying shows up next to any / objectContaining in autocomplete and docs. The extend workaround is folklore.
Zero setup. No registration, no name to invent, no separate file for expect.extend calls.
Consistent failure formatting. A built-in formats toString() the same way every time.
Calling this out explicitly so the "why not just expect.extend" question is preempted.
Open questions
Q1. Optional label argument. Should satisfying(fn, label?) accept an explicit description string used in toString() and failure diffs, or rely solely on fn.name?
Pro label: anonymous arrows print as Satisfying, which is useless when several are stacked. A label makes diffs readable.
Con label: no other built-in asymmetric matcher takes a label, so this would be the odd one out.
Recommendation: support the optional label. Other matchers self-describe from their arguments β StringContaining "foo", Any<String>, the regex literal inside StringMatching. A predicate function body is opaque; there's nothing meaningful to print without help. fn.name covers extracted named predicates but anonymous arrows have empty .name, and arrow source text is too noisy to dump into a diff. satisfying is uniquely opaque among matchers, which is what justifies giving it the only label argument.
Q2. Predicate throw behavior. When the predicate throws, do we propagate the error, or treat it as no-match with a warning?
Propagate: surfaces buggy predicates loudly. Matches "asymmetric matchers don't run in a try/catch elsewhere" pattern.
No-match + warn: more forgiving. Risks silent failures masking real bugs.
Recommendation: propagate.
Implementation plan
Lives entirely in expect. No changes to jest-mock or @jest/expect-utils types. The asymmetric matcher protocol is already in place and whenCalledWith already routes through it after #16053.
Phantom typing to flow T from outer matcher context into the predicate parameter.
Risks / concerns
API surface bloat. One additional matcher, low risk. Same shape as the eight existing ones.
Footgun potential if a user writes a side-effectful predicate. Predicates are user code; not Jest's job to police. Documented as "predicates should be pure."
Naming bikeshed. Acknowledged. Happy to swap; satisfying is my recommendation but not load-bearing.
π Feature Proposal
Add
expect.satisfying(fn)as a new built-in asymmetric matcher. Wraps an arbitrary(value) => booleanpredicate so it can be used anywhere an asymmetric matcher is accepted:toEqual,toHaveBeenCalledWith,objectContaining,whenCalledWith, etc.Deferred follow-up from #16053. Listed there as "expect.predicate(fn) - function as asymmetric matcher."
Proposed API
Name chosen for parity with the existing
-ingfamily:objectContaining,stringContaining,stringMatching,arrayContaining. Reads grammatically in assertions ("value satisfying predicate").Alternatives considered:
predicate,check,matching,fn.matchingcollides withstringMatching.predicateis technically accurate but less natural in prose.fncollides withjest.fn. Happy to swap if maintainers prefer.Behavior
Match: predicate is invoked with the value. Return is coerced to boolean (truthy = match, falsy = no match). Same coercion semantics as the rest of the asymmetric matcher protocol.
Negation:
expect.not.satisfying(fn)flips the boolean. Implemented as a mirroredNotclass, same pattern asarrayContaining/objectContaining.Async throws: Throws at match time with a clear message. Detected via
Promisereturn from the predicate. The asymmetric matcher protocol is sync; silently accepting an async predicate would always pass (a Promise is truthy), which is the worst possible UX.Errors thrown by predicate: open question, see Pitch section.
toString()output (shows up in failure diffs):Output format matters for diff readability when multiple
satisfyingmatchers are stacked inside anobjectContainingor awhenCalledWithbranch list.TypeScript
Tis inferred from the predicate's own parameter, so extracted predicates keep their types:Gap: T will not flow in from anyhere
Contextual typing from
toHaveBeenCalledWithdoes not flow into the predicate's parameter, because the matcher position is anAsymmetricMatcher | Tunion and TS cannot disambiguate to pull the real argument type through to the callback. Known limitation ofAsymmetricMatcherbeing non-generic and structural. Phantom typing (e.g.AsymmetricMatcher & {__matches: T}) could explore this in a future RFC but is invasive and out of scope here.Motivation
whenCalledWith(...)lets users route mock calls by argument shape. Equality checks plus all existing asymmetric matchers work. What doesn't work today: arbitrary predicates (range checks, cross-field invariants, computed conditions).The same gap exists in
toEqualandtoHaveBeenCalledWith, butwhenCalledWithmakes it most painful because branching by ad-hoc predicate is a primary use case for that API.This is pulling over jest-when's function matchers.
This is sugar, not a new capability. See Pitch for the relationship to
expect.extend.Example
Inline assertion:
Composed inside other matchers:
Mock argument routing (headline use case):
Negation:
Pitch
Addressing the "matchers belong in jest-extended" note
The form notes that new matchers typically don't make it to core and points at
jest-extended. This proposal is different in two ways:jest-extendedis a collection of assertion-side matchers (expect(x).toBeArrayOfSize(n)). This proposal sits next toany/objectContaining/arrayContainingβ the matcher-argument-position primitives. Those are core's job;jest-extendeddoesn't ship asymmetric matchers in that position.whenCalledWith(just merged in feat(jest-mock): addmock.whenCalledWith(...)Β #16053). Predicate routing was an explicit deferred follow-up. Without it,whenCalledWithships with a noticeable gap for any mock test that needs to branch on a computed condition rather than a shape match.So while I understand the "new matchers go to jest-extended" guidance, this one belongs alongside the existing asymmetric matchers in core. Happy to discuss if maintainers disagree.
Relationship to
expect.extendUsers can already approximate this today:
The asymmetric form of
expect.extendmatchers does work intoEqual/toHaveBeenCalledWith/whenCalledWith. So this proposal does not add new capability.What it adds:
expect.satisfyingshows up next toany/objectContainingin autocomplete and docs. Theextendworkaround is folklore.expect.extendcalls.toString()the same way every time.Calling this out explicitly so the "why not just
expect.extend" question is preempted.Open questions
Q1. Optional label argument. Should
satisfying(fn, label?)accept an explicit description string used intoString()and failure diffs, or rely solely onfn.name?Satisfying, which is useless when several are stacked. A label makes diffs readable.StringContaining "foo",Any<String>, the regex literal insideStringMatching. A predicate function body is opaque; there's nothing meaningful to print without help.fn.namecovers extracted named predicates but anonymous arrows have empty.name, and arrow source text is too noisy to dump into a diff.satisfyingis uniquely opaque among matchers, which is what justifies giving it the only label argument.Q2. Predicate throw behavior. When the predicate throws, do we propagate the error, or treat it as no-match with a warning?
Implementation plan
Lives entirely in
expect. No changes tojest-mockor@jest/expect-utilstypes. The asymmetric matcher protocol is already in place andwhenCalledWithalready routes through it after #16053.Out of scope
expect.addEqualityTesters(...)custom-tester gap inwhenCalledWith(documented in feat(jest-mock): addmock.whenCalledWith(...)Β #16053). Different problem.satisfyingdoes not close it.mock.whenCalledWith(...)Β #16053. Still deferred.mock.whenCalledWith(...)Β #16053 per maintainer guidance.Tfrom outer matcher context into the predicate parameter.Risks / concerns
satisfyingis my recommendation but not load-bearing.References
mock.whenCalledWith(...)Β #16053 βmockFn.whenCalledWith(...)(merged). Original deferred-item listing.mock.whenCalledWith(...)Β #16053 closed; predicate matching mentioned in the thread.