So I've noticed that the proposal comes with a new protocol for custom matchers not dissimilar from iterator protocol. This seems great, but I wonder if there is room for having a handful of built-in matchers for all of the primitives and the nullish types. I haven't seen any discussion around adding built-in matchers from any of the existing issues or the proposal itself.
This is partially motivated by the pattern matching typescript library ts-pattern, which has "wildcard" patterns for strings, numbers, booleans, and nullish values (null | undefined): https://github.com/gvergnaud/ts-pattern#__-wildcard
import { match, __ } from 'ts-pattern';
const input = true;
const output = match<string | number | boolean | null | undefined>(input)
.with(__.string, (str) => 'it is a string!') // str is `string`
.with(__.number, (num) => 'it is a number!') // num is `number`
.with(__.boolean, (bool) => 'it is a boolean!') // bool is `boolean`
.with(__.nullish, (none) => 'it is a null or undefined!') // none is `null | undefined`
.with(null, (n) => "it is null") // n is `null`
.with(undefined, (u) => "it is undefined") // n is `undefined`
// whoKnows is whatever the input is, which in this case is `string | number | boolean | null | undefined`
.with(__, (whoKnows) => 'it is some thing!')
.exhaustive(); // executes and in typescript ensures all branches explored at compile time
console.log(output);
// => 'it is a boolean!'
The role of these matchers is to match against all values that are one of these primitive values. I'm not sure where they should live. There is some discussion in the proposal for adding a nil pattern that matches any value, so perhaps there is room to extend _ with some additional symbols/keywords of some kind.
Alternatively maybe the matcher protocol could be added to the String, Number, Boolean, and BigInt constructors.
I do think it would be quite nice to have a built-in matcher for nullish values, and I think its already becoming common to treat null | undefined as equivalent after the very successful introduction of ?? nullish coalescing operator. Unfortunately there isn't a constructor for nullish values to add a matcher protocol to.
Using constructors that have builtin impls of the matcher protocol:
match (value) {
when ^String {...}
when ^Boolean {...}
when ^Number {...}
when ^BigInt {...}
}
OR
Something like the nil pattern (not suggesting this syntax, but something to the effect of):
match (value) {
when _string {...}
when _boolean {...}
when _number {...}
when _bigint {...}
when _nullish {...}
when _ {...}
}
If your goal is to only match types you could match (typeof value) { pretty easily - do you have a use case where you need to both match against the value and its type?
Using constructors is a complete non-starter. For one, it implies instanceof semantics, which are unreliable and must not be implied; but also, String is per-realm. There's no connection between one realm's String and another realm's string unless you convert the primitive to an Object inside the first realm first - at which point, it's not a string anymore.
If your goal is to only match types you could
match (typeof value) {pretty easily - do you have a use case where you need to both match against the value and its type?
Of course, match(typeof value) solves this trivial case (and is basically the same as switch(typeof value)). What I'm much more interested in is cases where you are mixing primitives with object types or where values nested within object types can be understood as a union of different primitives or object types.
Here is a very simple example using typescript notation. I have a variable with a nullable User object with an alias and a name. I want to get a string to display in a user interface. If the alias key exist and is a string then I want the alias, otherwise I want to fallback to the name. If I don't have a user object at all, I want to fallback to a default value.
type User = {
id: string;
name: string;
alias?: string | undefined; // the key is optional and even if present it might evaluate to `undefined`
}
declare const user: User | null
const name: string = match (user) {
// not quite sure if this is exactly how it would work within the do block in terms of the binding of `alias`
when ({alias: _string}) { alias }
when({ name }) { name }
otherwise { "unknown user" }
}
A more complex example might be a union of very different types that mixes primitives and object types. This is an entirely arbitrary example, but its not uncommon to have union of various types in real code.
type User =
| { id: string, name: string}
| { id: string, name: { name: string } }
| string
| number
| null
declare function getNameFromId(id: number): string;
declare const user: User;
// again I'm not sure about how binding works in this proposals, so taking a haphazard guess
const str = match( user ) {
when ( { name: _string } ) { name }
when ( { name: { name } ) { name }
when _string { user }
when _number { getNameFromId(user) }
otherwise { "unknown user" }
}
Using constructors is a complete non-starter. For one, it implies
instanceofsemantics, which are unreliable and must not be implied; but also,Stringis per-realm. There's no connection between one realm's String and another realm's string unless you convert the primitive to an Object inside the first realm first - at which point, it's not a string anymore.
I'm not specifically proposing that it must be the constructors - it just came to mind that it might be a suitable home. It was just a suggestion that came off the top of my head.
I'm not familiar with this area, but why would this imply instanceof semantics? Couldn't the matcher protocol for String just do typeof val === "string" etc? Is that not the same across realms? Maybe String is the wrong place for this kind of protocol for the matching primitives like string. Again, just trying to offer a home that doesn't litter patterns with more globals or keywords.
The two examples in ts-pattern:
import { match, __ } from 'ts-pattern';
const name: string = match(user) {
.with({ alias: __.string }, ({ alias }) => alias)
.with({ name: __ }, ({name}) => name)
.otherwise(() => "unknown user")
}
const name: string = match(user)
.with({ name: __.string }, ({ name }) => name)
.with({ name: { name: __ } }, ({ name: { name } }) => name)
.with(__.string, (name) => name)
.with(__.number, (id) => getNameFromId(id))
.otherwise(() => "unknown user")
Totally fair point that if you're checking the type of anything that's nested, there's nothing super easy.
You're totally right that we could define the matcher protocol for primitive constructors so this would work - realistically it'd check for the internal slot, so Object('foo') and 'foo' and new OtherRealmString('foo') would all match. This is worth further discussion.
The main downside I see is that it may imply an instanceof check, but nothing would stop user constructors from implementing the protocol in a similar way, so maybe having a brand check be the default would actually improve the ecosystem by establishing a primary convention of "not instanceof" :-D
I quite like this idea! Looking forward to discussing it in the next champions' call.
If I understand the custom matcher protocol proposal correctly, I feel like I can foresee the emergence of various pattern libraries that provide constructors for or objects that implement the matcher protocol. A lot of that is probably inevitable and desirable, but it might be nice if the primitive types are cared for in some way, which would probably met the needs of most users.
Happy to hear it might have some purchase :)
Yeah, I think it's probably reasonable to provide built-in brand-check-based custom matchers on the constructors for the built-ins.
Because yeah, if we don't do it (and do it right), then the standard userland response will be:
String[Symbol.matcher] = val=>(typeof val == "string" || val instanceof String);
match(val) {
when ^String -> ...;
}
and we're back in the "cross-realm String objects fail this check" world anyway.
I think being able to match the eight language types would be useful, but I'm very surprised that folks seem to expect "string" to mean the cross-language-type union of String and String Object.
There is a precedent for those unions (JSON.stringify). I'd have considered the conflation there a historical mistake; I'm glad that no subsequent language features repeated it. Lodash checks provide an example of popular userland code that's similar in practice, too. (Though it's not quite the same: they use heuristics which can be fooled with tailored objects that lack the brand).
Is this union behavior really what folks want? If so, why? I don't know of anything positive it enables, but the bugs are well-known.
I don't know if @tabatkins intended by their example that new String() should match a string matcher as well as string literals, but I completely agree, A string matcher should only match a string literal, not a string instance too. Same for all other "boxable" types.
@bathos there’s a number of places; including String.prototype.toString, which is called by virtually everything. It’s not a legacy pattern, it’s the way the language operates.
@theScottyJam if it uses the constructor and only matched the literal, it is broken and i would block this entire proposal myself.
there’s a number of places; including String.prototype.toString, which is called by virtually everything. It’s not a legacy pattern, it’s the way the language operates.
That’s not what I was referring to, though. ToString/ToPrimitive is ubiquitous and I’m certainly a fan! But treating String Object “as a string” where _other objects that implement toString or @@toPrimitive would not be_ happens only in JSON.stringify, which bypasses the normal conversion contract by sniffing for [[StringData]].
(monotone robot voice):
“beep boop converting Object 5433AYBK43 to String ... loading Protocol 1728X ...”
(mysterious shopkeeper “Jason Stringify” voice):
“My my, and what do we have we here? _*theatrical gasp*_ Ah! Child! ... what you hold in your left hand is none other than a _genuine String Object!_ Quite rare, quite valuable! Yes yes oh yes it’s true haven’t seen one of these in ages. Well! Perhaps we’ll do something _special_ today, then, mmm? _*unsettling wink*_”
String.prototype’s own methods (and String Exotic Object internal methods) accessing the slot directly isn’t bypassing anything because that’s the “end of the line”; it’s stuff outside it that normally respects the conversion contract agnostically. Ex:

I should add though that I’d be way way more comfortable w/ a “string type matcher” that captures String Object in its net if the bound value that gets through is the primitive string value rather than the object that was matched. The bit I think would be cursed is “use this to match strings!” → user can get an object on the rhs.
(The example earlier doesn’t show something “good” — but it does show one reason why getting a String Object where you expected a String Value _isn’t_ good imo. I suspect it’d not be intuitive to many people if the string-matcher could hand over an object sometimes. Picture library code using pattern matching to verify and handle API surface input, say; will they know to account for it?)
@ljharb - I'm also not sure why you would be against a matching function on the String class that only works with primitives - If the String class was supposed to favor boxed strings over primitives then I would expect functions like String.raw() or String.fromCharCode() to return a boxed string instance, which thankfully it does not - that would trip so many people up. Technically these functions have nothing to do with boxed strings, and yet we've placed them on the String class anyways. Why? Because in Javascript, we've been using the String class as a namespace for primitive string operations. Similarly, allowing a boxed string instance through the string matcher would likewise trip all sorts of people up, but it still makes sense to associate a primitive-only string matcher with the String namespace.
I guess I can see it getting a little weird if it became a common practice to do add a matcher symbol to classes, which would cause String to become an oddball where it's matcher does not match its own instances. Would you feel more comfortable if the String class itself wasn't the matcher, but some property on it? Like String.matcher? I could something like that helping to dis-associate the matching from the actual String class.
@bathos Anything in the entire spec that calls ToString on an argument is utilizing String.prototype.toString when given a boxed String object, which is "every single API that takes a string, without exception as far as I'm aware". I agree only JSON.stringify sniffs the slot, but toString is called on everything, and a String object's toString checks the slot. In other words, boxed string objects are always, without exception treated as strings in the spec, and this should be no difference.
@theScottyJam it would of course be implemented as a function under the matcher protocol on String - there's no other way it could work - the question is whether that function would return true for only typeof x === 'string' or also for "has [[StringData]]". It hurts nothing by choosing the latter, but choosing the former would hurt overall consistency of the language.
The String.matcher idea was that matcher was an object with a matcher protocol. So you would use it like this:
match (new String('Hello World!')) {
when (^String.matcher) { "It's a string" }
else { "Default case" } // This is what would match
}
According to MDN there are subtle differences between a string literal and a String instance, which is why it recommends never using String instances (see here). For example:
> eval(new String('2 + 2'))
[String: '2 + 2']
> eval('2 + 2')
4
However, this conversation is really about all primitives, string is just one scenario. Boolean is much worse. If we allow a Boolean matcher to match Boolean instances, then the following would incorrectly log "It was true".
console.log(match (new Boolean(false)) {
when (^Boolean as value) { value ? "It was true" : "It was false" }
else { 'Not a boolean' }
})
No, it wouldn’t - matching boolean instances would make it match false, since that’s what’s in the internal slot. However, if it only matches primitives, then Object(true) would hit the false path, which is absurd.
I'm a little confused. Make what match false? Isn't the whole point that you're arguing is that new Boolean(false) is supposed to match the Boolean matcher? And if so, we're going to then execute the first case, which will effectively execute as new Boolean(false) ? "It was true" : "It was false", and since a Boolean instance is an object, new Boolean(false) will be truthy, and "It was true" will be returned.
I know this example could have been replaced with a direct match against true and false, but that ternary was just supposed to represent more complicated logic that can go really wrong if a boolean instance gets into the match body. Let me expand the example just a little bit to show how weird a boolean object can be.
console.log(match (new Boolean(false)) {
when (^Boolean as value) {
if (value) f()
`Was f() called?: ${value}`
}
else { 'Not a boolean' }
})
In this example, our special Boolean object caused f() to be called, but then return the string "Was f() called?: false".
So yes, I'm also advocating that Object(false) goes into the else branch, because Object(false) behaves very differently from false, and users trying to pick out a boolean value will only expect primitives to come through, not these awkward objects that sometimes misbehave.
For me, it's OK to introduce (String, Boolean, ...)[Symbol.matchable], but I don't want it auto unwrap to match the real primitive match clauses.
No, it wouldn’t - matching boolean instances would make it match false, since that’s what’s in the internal slot.
This is what I was wondering about earlier ("[what] would be cursed is [if you] get an object on the rhs.") It sounds like that isn't what you had in mind - you want to actually pluck the [[Data]] values - and yeah, that seems much less worrisome to me.
I still don't understand the explanation for the value of conflating these objects with their primitive pals.
In other words, boxed string objects are always, without exception treated as strings in the spec, and this should be no difference.
If I call Reflect.isExtensible(new String('abc')), it's treated as an Object, right? If it weren't, it'd throw. If I write Object.setPrototypeOf(new String('abc'), null) + 'def', it's treated as an Object. If it weren't - if its still-present [[StringData]] mattered to + here - it wouldn't throw. But for that matter if I write new String('abc') + 'def' it's _also_ being treated as an Object. How could it be any other way? An Object is converted to string using ToString, not a specific implementation of toString.
I know we must be on the same page when it comes to nitty gritty "what the algorithms really do" sort of stuff - which probably means our surprisingly opposite conclusions must arise from something higher "up". Different ideas about what the (intended?) boundaries of certain abstractions are? I'm not 100% sure.
For sure [[StringData]] is the sole correct way to determine if a value is a String Object. The bit I don't know is why you'd want to, and in particular why you'd want to conflate it with String value. Regarding that you said:
[...] choosing [typeof x === 'string' semantics] would hurt overall consistency of the language
~JSON.stringify is unique not just because it's where [[StringData]] and co "leak", it's also the only place where that's done in a way that effectively amounts to something like "To do stringstuff, I'll specifically take either a String or a String Object, please!".~
~Nothing else ever does that: it's always either "I'll take a String Object ... OR ELSE!" (String.prototype.toString) or ToString-stylee ("I'm basically up for whatever. Numbers, booleans, hamburgers, big objects, small objects ... no guarantees but I'm willing to try turning anything into a string at least once.") In the latter cases, if you pass in Objects, if the Object happens to be a String Object, it's not treated any differently:~
'' + new String('x');
'' + { toString: () => 'x' };
'' + Object.assign(new String('y'), { [Symbol.toPrimitive]: () => 'x' });
...so I don't really get the consistency thing.
_This isn't true. S.p.toS actually does have the "Either" behavior, as do valueOf's. So much for assuming there weren't any "nitty gritty" behavior bits in play that I was missing. It still seems stretched to me but that is certainly a very strong kind of precedent to argue from._
Since the question of whether the value on the RHS would be the primitive or not was my chief concern, confirmation that it would be the primitive addresses the main reason I thought this was gonna be a dark path. I find the rest of the discussion v interesting obviously so most of this is curiousity now rather than concern.
but I don't want it auto unwrap to match the real primitive match clauses.
@Jack-Works - Could you clarify this? Maybe with an example? I'm not sure I'm catching on to what you're asking for.
Since the question of whether the value on the RHS would be the primitive or not was my chief concern, confirmation that it would be the primitive addresses the main reason I thought this was gonna be a dark path. I find the rest of the discussion v interesting obviously so most of this is curiousity now rather than concern.
While I think it's certainly an improvement if you can extract the matched value as a primitive, I think it'll still be a huge footgun. It would mean the following three things will behave the same _most_ of the time, but when a Boolean instance is passed in, only the first scenario would behave correctly, while the other two could lead to buggy code.
match (data) {
when (^Boolean with value) { <use value here> }
}
match (data) {
when (^Boolean as value) { <use value here> }
}
match (data) {
when (^Boolean) { <use data here> }
}
It's still better than nothing though, as the with version is the most proper way to construct the match. But, I wouldn't call the other versions "bad code", and I can see Boolean objects tripping things up.
From what I understand, doing new Boolean(false) shouldn't ever happen in userland code. Boxing is an artifact of a certain optimization, which gets leaked by the fact that we're capable of creating persistent boxes by newing these classes (something I wish was impossible to do). I would even argue that a Boolean object isn't a true boolean, it's just an exotic object that holds a true boolean internally, and it tries to mimic a boolean as close as possible, but it fails in important ways (which makes them pretty unusable), like the fact that new Boolean(false) is truthy. Making a matcher that excepts both primitives and boxes is making a matcher that accepts very similar but different things, most programmers will only support one type of thing (the primitives), and normally won't put the effort in to unwrap a box, because users shouldn't even be using those in the first place.
@Jack-Works - Could you clarify this? Maybe with an example? I'm not sure I'm catching on to what you're asking for.
This is OK (maybe)
match (new Boolean(false)) {
when ^Boolean as v -> console.log('matches!')
}
This is not OK
match (new Boolean(false)) {
when false -> console.log('matches!')
}
Alright, well if we can agree that new Boolean(...) should not actually be used in userland code (except for automatic boxing) (can we agree on this? MDN agrees), and if there's concern by both @ljharb and @Jack-Works over having a matcher on Boolean that only matches primitives and not these should-not-be-used Boolean instances, then maybe it would make more sense to move the matchers off of the class and onto their own namespace?
For example:
match (someValue) {
when (^matchers.boolean) { ... } // Only matches boolean primitives
}
I wouldn't want to have a Boolean matcher that only matches boxed instances and not primitives - that means we're just adding feature support for features that should not be used, and that's certainly going to trip people up. And I would prefer not to have a matcher that lets both things through for reasons explained previously - they're two different things that behave differently, let's not pretend they're the same.
ok lol i made a mistake earlier. Yes, ^Boolean would of course match true or false, but also Object(true) and Object(false).
Whether we agree that boxed primitives shouldn't be used in good code is largely irrelevant - we're not producing them, we're accepting them as input just in case, like the rest of the language does.
I completely agree that true or false only matches boolean primitives - you'd do when (true or false) if that's the semantic you want over when ^Boolean (which would also match the boxed forms).
then maybe it would make more sense to move the matchers off of the class and onto their own namespace?
I’d be in favor of this.
I think I understand much better now what @ljharb’s rationale is. Something like: “Boolean’s matcher should match the result of thisBooleanValue (thisStringValue, etc) any time it would not be abrupt” (in all such cases, whether or not it would be abrupt can be determined without side effects). As a method implemented on Boolean, or String, etc, this seems reasonable, and yeah ... probably the only reasonable behavior.
However I’d really like there to be a single unified way to match on language types directly — i.e. Type(x) semantics — and a distinct namespace for that purpose seems like a good solution since it’s not dipping its hands into the “boxes” API at all.
For that, I'd suggest building a Type function in userland that returns an object conforming to the desired matcher semantics, and then pursuing it in a follow-on proposal.
My concern (seemingly shared by scotty) is that the Type semantics are the ones that are safest and least surprising to use. They are the only semantics (with the one special exception) used _outside_ of the APIs associated with those value types. Baking in the technically consistent — but still novel, because it’s _distinguishing_ from outside — behavior first seems like paving the wrong road.
Whether we agree that boxed primitives shouldn't be used in good code is largely irrelevant - we're not producing them, we're accepting them as input just in case, like the rest of the language does.
@ljharb - it depends on if you consider a boxed boolean a true boolean or not. Apparently even Javascript's built-in APIs don't handle boxed booleans in an intuitive way.
> ['a', 'b'].filter(() => new Boolean(false))
[ 'a', 'b' ]
I see no reason why we can't just think of boxed booleans as a completely different (and useless) type from a boolean primitive. An API creator can just make their APIs only accept boolean primitives, boxed booleans would be considered "invalid input", and the API user would have to convert any boxed boolean to a primitive before calling the API functions. Just like how an API user might have to convert any other type to whatever type that particular API accepts. There's no reason to put the task on the API creators to auto-coerce boxed-booleans to primitives. If the end-user wants to use them, then they'll also be tasked with coercing them to the appropriate types that different APIs expect.
“Boolean’s matcher should match the result of thisBooleanValue (thisStringValue, etc) any time it would not be abrupt”
@bathos - could you explain what you mean by "abrupt"?
As for my suggestion of using a special global namespace (like Type) as a place to put matchers, it's a good compromise but isn't without problems that I want to point out.
The main problem is, how do you decide which matchers belong in the namespace and which ones don't? (I assume we'll just place primitive matchers on there, but I'll still bring up different options). No matter what, you can't place all of them in there, and having some matchers on class constructors and others on Type will just feel inconsistent to the end user.
Array for a matcher, but strings, booleans, and integer matchers will be found on this Type namespace. Why does this seeming inconsistency exist? Because this boxing feature that is supposed to be invisible to the end user is not, and it's affecting how we design our API.Type namespace? It's (luckily) impossible to create boxed instances of symbols that get passed around, so there's technically no need to place it on the Type namespace, but we can choose to do so anyway if it feels more consistent.So, while I would be happy if we agree on a compromise like this, I still feel like it'll be a subpar solution compared to just using the String constructor or String.matcher for a matcher that only matches primitives and has nothing to do with boxed String objects. This really shouldn't be considered any more inconsistent than String.raw() which only returns primitives and has nothing to do with boxed String objects - if we followed this same "inconsistency" logic, then we really should have placed String.raw() in a namespace of its own, outside the String class.
could you explain what you mean by "abrupt"?
That it wouldn’t throw a TypeError, i.e. that it’s like each of those thisThingValue ops except that at step 3 it’s “nope, didn’t match” instead of “throw”.
Most helpful comment
Yeah, I think it's probably reasonable to provide built-in brand-check-based custom matchers on the constructors for the built-ins.
Because yeah, if we don't do it (and do it right), then the standard userland response will be:
and we're back in the "cross-realm String objects fail this check" world anyway.