Skip to content

fix(is-matching): accept a top level matcher when the input is an object - #369

Open
MFA-G wants to merge 3 commits into
gvergnaud:mainfrom
MFA-G:fix/is-matching-top-level-matcher
Open

MFA-G wants to merge 3 commits into
gvergnaud:mainfrom
MFA-G:fix/is-matching-top-level-matcher

Conversation

@MFA-G

@MFA-G MFA-G commented Sep 12, 2026 •

Copy link
Copy Markdown

Fixes #336

Problem

isMatching(P.instanceOf(Text), document.createTextNode('123')) fails to type check:

Argument of type 'Chainable<GuardP<unknown, Text>>' is not assignable to
parameter of type 'KnownPattern<Text> & UnknownProperties'.
  Index signature for type 'string' is missing in type 'Matcher<unknown, Text, "default", None, Text> & ...'

The two-argument overload of isMatching constrains the pattern with:

type PatternConstraint<T> = T extends readonly any[]
  ? P.Pattern<T>
  : T extends object
  ? P.Pattern<T> & UnknownProperties
  : P.Pattern<T>;

The & UnknownProperties part exists so object patterns can target properties the
input type does not declare (covered by the existing "should allow targetting
unknown properties"
test). But P.Pattern<T> is a union whose members include
PatternMatcher<T>, and the intersection applies to every member — including
the matcher one. Matchers have no index signature, so any top level matcher is
rejected whenever the input happens to be an object. This affects
P.instanceOf(...), P.any, P.when(...), P.not(...), and so on.

Fix

Distribute over the pattern union and intersect UnknownProperties only with the
members that are not matchers:

type WithUnknownProperties<p> = p extends AnyMatcher ? p : p & UnknownProperties;

Object patterns keep their unknown-property escape hatch, and top level matchers
are accepted again.

Note on the @ts-expect-error move

PatternConstraint is now a distributive conditional type, so TypeScript reports
the invalid-pattern error on the offending property rather than on the whole object
literal. The error is still raised — the @ts-expect-error in
"should reject invalid pattern when two parameters are passed" just moved one line
in to stay attached to it. Removing the directive entirely makes that test fail, so
the rejection behaviour is unchanged.

Validation

  • npx jest — 48 suites, 454 tests, all passing (14 in is-matching.test.ts, up from 13).
  • tsc --strict --noEmit clean.
  • The issue's reproduction, plus narrowing and the existing unknown-property pattern, type check against the patched source; the same file reports 3 errors on main.
  • Formatted with the repo's Prettier config.

New test should accept a top level matcher when the input is an object covers
P.instanceOf, P.any, P.when and P.not at the top level, with an Equal
assertion that narrowing still produces the expected type.


Summary by cubic

Fixes #336 — isMatching now accepts top-level matchers (P.instanceOf, P.any, P.when, P.not) when the input is an object. The old constraint intersected UnknownProperties with the entire pattern union, rejecting matchers for lacking an index signature; it now applies only to non-matcher members, preserving unknown-property support for object patterns.

The @ts-expect-error in the invalid-pattern test moves one line down because the error now surfaces on the property; rejection behavior is unchanged. The new test covers all four matchers with the input widened to a union, and includes an Equal assertion that the guard removes the other union member.

Written for commit 01faa63. Summary will update on new commits.

Review in cubic

`isMatching` intersected `UnknownProperties` with the whole `P.Pattern<T>`
union to let object patterns target properties the input type does not
declare. That intersection also reached the matcher member of the union,
so a top level matcher such as `P.instanceOf(Text)` was rejected because
it has no index signature.

Distribute over the pattern union and only intersect `UnknownProperties`
with the non-matcher members.

Fixes gvergnaud#336

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread tests/is-matching.test.ts Outdated
@MFA-G

MFA-G commented Sep 12, 2026

Copy link
Copy Markdown
Author

Thanks — valid point, fixed in 2b9e4c0.

The assertion was indeed vacuous: const input = new Container('hello') is already exactly Container, so Expect<Equal<typeof input, Container>> held whether or not P.instanceOf narrowed anything.

input is now typed as a union (Container | Box), so the Equal check only passes if the guard actually narrows it down to Container. Verified it pins the behaviour: swapping the guard to P.instanceOf(Box) makes tsc fail with Type 'false' does not satisfy the constraint 'true', which it did not before.

I used a second class rather than unknown on purpose — with unknown the pattern argument is no longer checked against PatternConstraint<T> for an object input, which is the very constraint this PR changes, so the test would stop covering the regression path.

tsc --noEmit -p tsconfig.json clean, jest tests/is-matching.test.ts 14/14 passing, prettier clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread tests/is-matching.test.ts Outdated
@MFA-G

MFA-G commented Sep 12, 2026

Copy link
Copy Markdown
Author

Good catch again — the annotation form was still vacuous, confirmed and fixed in 01faa63.

Two separate problems were hiding each other:

  1. const input: Container | Box = new Container('hello') does not stop control-flow analysis: TS narrows the declaration to Container right away, so the assertion passed without isMatching doing anything. Switched to new Container('hello') as Container | Box, which matches the satisfies ... as ... idiom already used above in this file and keeps the declared type wide.

  2. Once the input was genuinely a union, Equal<typeof input, Container> turned out to be the wrong assertion: the guard narrows to Container | (Box & Container), not to bare Container. That is the normal shape for value is T & ... predicates. The assertion is now Equal<Exclude<typeof input, Container>, never>, i.e. "no non-Container member survives the guard".

Verified it is no longer vacuous: swapping the guard to P.instanceOf(Box) makes the suite fail with TS2344: Type 'false' does not satisfy the constraint 'true' (plus TS2339 on .value), which it did not before. npx jest is green: 48 suites / 454 tests, and prettier --check passes.

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.

P.instanceOf(Text) not working with isMatching

1 participant