Skip to content

Conversation

@gibson042
Copy link
Member

@gibson042 gibson042 commented Feb 3, 2026

Fixes #3072
Extends #3053 for avoiding known fast-check failures

Description

Add support for M.choose(keyName, { keyValue1: restPatt, keyValue2: restPatt, … }). This differs from the M.choose(keyName, [{ [keyName]: keyValue1, … }, { [keyName]: keyValue2, … }, …]) suggestion of #3072 in a handful of ways:

  • Using a record rather than an array avoids repeating the discriminator key name and inherently avoids non-disjoint sub-patterns.
  • Matching is more efficient, checking at most one sub-pattern after finding it by direct lookup.
  • Error messages are either directly associated with the literal discriminator value used to select the sub-pattern, or else indicate failure to identify one.
  • The sub-patterns theirselves are not required to be records; e.g. { keyValueN: M.recordOf(M.string(), M.any() } is just fine.
  • The specimen can only match if it is a CopyRecord, because otherwise it can't have the discriminator field.
  • There is no escape hatch to recover looser key matching in which e.g. the discriminator value is itself matched by non-static pattern (but since we don't have e.g. starts-with or regular expression patterns anyway, this seems like not much of a loss).
  • discriminated more accurately communicates these constraints than choose, and if we ever want to relax them, that would be the time for a more generic name. feat(patterns): Add M.choose #3073 (comment)

For reference, Zod calls this discriminatedUnion and its API is similar to the original suggestion—defined from a string key name and an array of disjoint object patterns, each of which must define the corresponding key with a value that is either a literal or a string enumeration and does not match any value that would also be matched by another pattern in the array, which I think means it's actually slightly weaker than what we have here (because our sub-patterns are not limited to object matchers and can be e.g. M.or(…)/M.splitRecord(…)/etc. for covering non-trivial combinations of fields).

Security Considerations

None known.

Scaling Considerations

As mentioned above, this is faster than M.or because it never considers more than one sub-pattern.

Documentation Considerations

Hopefully covered by JSDoc.

Testing Considerations

Covered by new unit tests.

Compatibility Considerations

I don't think matchers can ever expand beyond their initial shape, so if this is accepted then it will always have its current limitations (specifically, match:choose matchers cannot grow support for non-static key matching because matchers theirselves are passable and the shape introduced here has no way to express it—but future changes could still update M.choose(…) to return instances of a new type).

Upgrade Considerations

Changes are purely additive, so I think the only possible upgrade issue would be if new code passes a match:choose instance to old code that tries to match against it. I'm not aware of any such old code that cannot itself be upgraded.

@changeset-bot
Copy link

changeset-bot bot commented Feb 3, 2026

⚠️ No Changeset found

Latest commit: 890c9d4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@gibson042 gibson042 changed the title feat(patterns): Add M.discriminated feat(patterns): Add M.discriminated Feb 3, 2026
Comment on lines +1662 to +1664
const { [keyName]: _, ...rest } = specimen;
const subPatt = patts[keyValue];
return confirmNestedMatches(freeze(rest), subPatt, label, reject);
Copy link
Contributor

Choose a reason for hiding this comment

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

rest is constructed by destructuring. The Jessie Must freeze API Surface Before Use don't seem to capture this case.

Copy link
Contributor

Choose a reason for hiding this comment

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

The specimen must already be passable and therefore hardened. Given that, only the top level of rest is not frozen. Everything deeper is frozen. Therefore, the freeze here means the code does uphold the purpose "Must freeze API Surface Before Use". The lack is on the Jessie side, where we don't have any syntactic machinery to recognize that this code conforms.

Copy link
Contributor

Choose a reason for hiding this comment

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

yes, this code conforms.

My comment aims to say: the Jessie rules don't seem to say that you need a harden(...) here.

Copy link
Contributor

@dckc dckc left a comment

Choose a reason for hiding this comment

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

the feature looks nice,
but the upgrade/compatibility considerations make my head hurt

Copy link
Contributor

@erights erights left a comment

Choose a reason for hiding this comment

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

discriminated more accurately communicates these constraints than choose, and if we ever want to relax them, that would be the time for a more generic name.

I technically agree with the premise but disagree on the conclusion. "discriminated" imposes more pain from its verbosity than it saves from its slightly more precise meaning.

I like "choose". But if we want something else, please something short. Once this exists, if the name is short, it may be used a lot.

The original "choose" suggestion was never implemented, so there's no conflict using "choose".

@erights
Copy link
Contributor

erights commented Feb 6, 2026

the feature looks nice, but the upgrade/compatibility considerations make my head hurt

@dckc please expand? I don't see it.

@kriskowal
Copy link
Member

Brevity is the soul etc. I would go with M.choose, noting that we might also someday do discriminated unions based on the label of a tag which needs to fit the system of names.

@gibson042 gibson042 force-pushed the gibson-3072-discriminated-matcher branch from 2243aaf to f44f256 Compare February 6, 2026 18:23
@gibson042
Copy link
Member Author

gibson042 commented Feb 6, 2026

The original "choose" suggestion was never implemented, so there's no conflict using "choose".

Right, but I believe the original suggestion was also more powerful than what is being added here (and at any rate, more powerful approaches that iterate an array in some way rather than indexing into a record are certainly possible). @erights @kriskowal are we comfortable with burning the "match:choose" tag on this narrow functionality? Note that future updates to M.choose which return a different tag than we start with are possible, but probably ill-advised, so if we ever want such extension we'll need a different name.

And regardless, note that this PR currently builds upon #3053, which still needs approval.

@gibson042 gibson042 force-pushed the gibson-3046-narrow-rankcover branch from 5fd2b94 to 4a911fc Compare February 6, 2026 19:16
@gibson042 gibson042 force-pushed the gibson-3072-discriminated-matcher branch from 0a773b1 to 251a6c3 Compare February 6, 2026 19:22
@gibson042 gibson042 force-pushed the gibson-3072-discriminated-matcher branch from 251a6c3 to 4095f10 Compare February 6, 2026 19:23
@erights
Copy link
Contributor

erights commented Feb 6, 2026

are we comfortable with burning the "match:choose" tag on this narrow functionality?

I am. If we ever add the more general one, it will be used far less that this one and so can have a more awkward name.

And, I doubt we will ever add the more general one.

@gibson042
Copy link
Member Author

Done.

@gibson042
Copy link
Member Author

If you're happy with this PR, please also approve its #3053 foundation so I can merge that one first.

@dckc
Copy link
Contributor

dckc commented Feb 6, 2026

the feature looks nice, but the upgrade/compatibility considerations make my head hurt

@dckc please expand? I don't see it.

maybe just PTSD from the endo interfaces backwards-incompatible change... combined with recent experience where adding a field seemed innocent, until a consumer grabbed the new pattern before a producer got upgraded, and BLAMMO.

I don't have a theory for how the vocabulary of pattern matchers evolves through ALL layers of our system.

@gibson042
Copy link
Member Author

For reference, Zod calls this discriminatedUnion and its API is similar to the original suggestion—defined from a string key name and an array of disjoint object patterns, each of which must define the corresponding key with a value that is either a literal or a string enumeration and does not match any value that would also be matched by another pattern in the array, which I think means it's actually slightly weaker than what we have here (because our sub-patterns are not limited to object matchers and can be e.g. M.or(…) for covering non-trivial combinations of fields).

@gibson042 gibson042 changed the title feat(patterns): Add M.discriminated feat(patterns): Add M.choose Feb 8, 2026
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