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
{{ message }}
Repository navigation
Set.has() should accept any value, and could be a type predicate #60547
* @returns a boolean indicating whether an element with the specified value exists in the Set or not.
*/
has(value: T): boolean;
I believe
Set<T>.has(value: T): boolean
should become
Set<T>.has(value: unknown): value is T
This applies for other methods as well, such as Map<K, V>.has(key: K): boolean, as well as other ES standards.
Essentially, it's not an error to pass an arbitrary value to Set.has(). It doesn't lead to a crash, exception, or error of any sort, even when we pass weird values like {}, [], or NaN, so the typechecker shouldn't stop us from doing it. We simply get a true if the value is in the Set, or a false if the value is not. And since the Set can only contain values of type T, the boolean returned by Set.has() also becomes proof that value is of type T. This makes it an ideal candidate for a type predicate.
This would immediately fix several regressions in our codebase that we saw when we upgraded TypeScript from 5.3 to 5.5. TypeScript's inferred type predicates are amazing, but they narrowed the types of our Sets and caused several of our Set.has() method calls to become errors. The sample code is a simplified example of what we've found in the wild in our codebase.
Sample Code
declarefunctiondefaultAttrs(subtype: string): string[]declarefunctiongetAttrs(): string[]typeMyAttribute= `_${string}`
functionisMyAttribute(key: string): key is MyAttribute{returnkey.startsWith('_')}exportfunctionmyAttrs(subtype: string){constdefaults=defaultAttrs(subtype)returnnewSet(Object.keys(defaults).filter((key)=>{returnisMyAttribute(key)}),)}functionattrInSet(subtype: string){constmyAttrSet=myAttrs(subtype)for(constattrofgetAttrs()){// Argument of type 'string' is not assignable to parameter of type '`_${string}`'.ts(2345)if(myAttrSet.has(attr)){returntrue}}returnfalse}
changed the title [-]`Set.has()` should be an inferred type predicate[/-][+]`Set.has()` should accept any value, and could be a type predicate[/+]on Nov 21, 2024
Basically the same as #26255; we don't agree that making arrayOfDogs.includes(cat) legal or setOfCats.has(dog) is a good idea. You can add an overload to the global type via interface augmentation (note that the reverse is not true; if we changed this to never be an error, no one could opt in to the current behavior) to get the requested behavior.
we don't agree that making arrayOfDogs.includes(cat) legal or setOfCats.has(dog) is a good idea.
Well, Cat and Dog are disjoint types. From your discussions in the linked issues, it's clear you've seen that the real problem is when the types are not disjoint. When setOfCats.has(cat) leads to a type error, simply because setOfCats has been refined to Set<MaineCoon | Tabby | ... | ScottishFold>. To fix this error, our options are to unsafely cast setOfCats to a Set<Cat>, or to pass the cat value through a gauntlet of type refinements until it's finally worthy of the .has() method.
But isn't unknown a member of super T? This seems like allowing arg: unknown with extra steps. If a generic has no upper bound, the typechecker will always be able to resolve it to the top type.
To get the best of both worlds, I think you need some way of enforcing non-disjointness. After creating the super keyword, we did not use the super keyword in Hack's C\contains() method. Instead, we added <<__NonDisjoint>> constraints to the generics, which enforce that T1 and T2 are not disjoint:
While not completely ideal, a type that can mostly fit the utility of <<__NonDisjoint>> in regular TypeScript code does exist:
// `[Extract<T, U>, any] extends [U, Extract<T, U>]` simultaneously checks if `Extract<T, U>` extends `U` and// also that `Extract<T, U>` is not `never`.// Then `U extends T ? T : U` is a useful fallback to allow stuff like `unknown` as `Extract<T, U>` will still leave `unknown` which will rarely extend `U`.exporttypeOverlapsWith<T,U>=[Extract<T,U>,any]extends[U,Extract<T,U>] ? T : (UextendsT ? T : U);declare global {interfaceSet<T>{has<constV>(value: OverlapsWith<V,T>): boolean;}}consts=newSet([[1,2,3],[4],[5,6]]);// Works whether this overload is added or not.s.has([1,3]);s.has(Object.assign({},[1,3],{prop: 123}))// Always erroring as it should.s.has(["foo","bar"]);s.has([1,"foo"]asconst);// Newly functionings.has(Math.random() ? [1,3] : ["foo","bar"]);// This newly functions as its type `(string | number)[]` at runtime _could_ be all numbers.s.has([1,"foo"]);constx: unknown=["foo","bar"];s.has(x);// Note that this example is misleading:consty: number[]|string[]=["foo","bar"];s.has(y);// Errors but only because if you check the type of `y` is now `string[]` despite the explicit type annotation.
It's a little less implicit but it should have the same power as <<__NonDisjoint>> does ultimately. Or at least I believe it will, I'm admittedly unfamiliar.
I think it becoming a built-in type would be ideal for better errors. Plus I doubt that the TypeScript team would want to add this conditional type to the library files today, the requirement of making it generic is a bit strange at a glance and the errors can be a bit subpar in some cases.
Yeah, super isn't quite the right thing here. Instead of non-disjoint we call it "comparable" which basically covers the same concept. It's the type relation that covers whether x === y is allowed and would ideally be something you could use in type-land
Reacted by LukeAbby, Jeff Hemphill and lionel-rowe
For completeness, here's a playground example showing that the typechecker is more forgiving towards === in a for loop than it is to array.includes():
// Good! (Type error in the right place with a really useful error message)functionarrayIncludesLoopDisjoint<T1,T2>(array: T2[],value: T1): boolean{for(constxofarray){// This comparison appears to be unintentional because the types 'T2' and 'T1' have no overlap.(2367)if(x===value){returntrue;}}returnfalse;}// Good! (No type error)functionarrayIncludesLoopNonDisjoint<T1,T2extendsT1>(array: T2[],value: T1): boolean{for(constxofarray){if(x===value){returntrue;}}returnfalse;}// Makes me sad... (Type error, even though the equivalent code above typechecks. Error is kind of cryptic.)functionarrayIncludesBuiltin<T1,T2extendsT1>(array: T2[],value: T1): boolean{// Argument of type 'T1' is not assignable to parameter of type 'T2'.// 'T2' could be instantiated with an arbitrary type which could be unrelated to 'T1'.(2345)returnarray.includes(value);}
we don't agree that making arrayOfDogs.includes(cat) legal or setOfCats.has(dog) is a good idea
It's more like:
arrayOfDogs.includes(whatTheHeckIsThis)
It's a more recent concern as typescript got more powerful. Especially as const. as const is very nice to get way better code completion as your sets of values can be autosuggested and don't boil down to string. But I'd still like to compare user input against it. E.g. why is this ok:
Activity
[-]Turn `Set.has()` into an inferred type predicate[/-][+]`Set.has()` should be an inferred type predicate[/+][-]`Set.has()` should be an inferred type predicate[/-][+]`Set.has()` should accept any value, and could be a type predicate[/+]RyanCavanaugh commentedon Nov 21, 2024
Basically the same as #26255; we don't agree that making
arrayOfDogs.includes(cat)legal orsetOfCats.has(dog)is a good idea. You can add an overload to the global type via interface augmentation (note that the reverse is not true; if we changed this to never be an error, no one could opt in to the current behavior) to get the requested behavior.jcalz commentedon Nov 21, 2024
Also see #51678 and issues linked from there
jthemphill commentedon Nov 21, 2024
Thank you for the quick response!
Well,
CatandDogare disjoint types. From your discussions in the linked issues, it's clear you've seen that the real problem is when the types are not disjoint. WhensetOfCats.has(cat)leads to a type error, simply becausesetOfCatshas been refined toSet<MaineCoon | Tabby | ... | ScottishFold>. To fix this error, our options are to unsafely castsetOfCatsto aSet<Cat>, or to pass thecatvalue through a gauntlet of type refinements until it's finally worthy of the.has()method.But isn't
unknowna member ofsuper T? This seems like allowingarg: unknownwith extra steps. If a generic has no upper bound, the typechecker will always be able to resolve it to the top type.To get the best of both worlds, I think you need some way of enforcing non-disjointness. After creating the
superkeyword, we did not use thesuperkeyword in Hack'sC\contains()method. Instead, we added<<__NonDisjoint>>constraints to the generics, which enforce thatT1andT2are not disjoint:https://github.com/facebook/hhvm/blob/9591b86b9eff17f35bb7d5eb029ca13ee6757bff/hphp/hsl/src/c/introspect.php#L41-L54
TypeScript may have a more elegant way of expressing this, using conditional types.
Sorry, yes. I didn't realize that type guards work both ways.
LukeAbby commentedon Nov 21, 2024
While not completely ideal, a type that can mostly fit the utility of
<<__NonDisjoint>>in regular TypeScript code does exist:It's a little less implicit but it should have the same power as
<<__NonDisjoint>>does ultimately. Or at least I believe it will, I'm admittedly unfamiliar.I think it becoming a built-in type would be ideal for better errors. Plus I doubt that the TypeScript team would want to add this conditional type to the library files today, the requirement of making it generic is a bit strange at a glance and the errors can be a bit subpar in some cases.
RyanCavanaugh commentedon Nov 21, 2024
Yeah,
superisn't quite the right thing here. Instead of non-disjoint we call it "comparable" which basically covers the same concept. It's the type relation that covers whetherx === yis allowed and would ideally be something you could use in type-landjthemphill commentedon Nov 21, 2024
Yes! We are on the same page!
For completeness, here's a playground example showing that the typechecker is more forgiving towards
===in aforloop than it is toarray.includes():sod commentedon Nov 23, 2024
It's more like:
It's a more recent concern as typescript got more powerful. Especially
as const.as constis very nice to get way better code completion as your sets of values can be autosuggested and don't boil down tostring. But I'd still like to compare user input against it. E.g. why is this ok:but this isn't:
playground
typescript-bot commentedon Nov 26, 2024
This issue has been marked as "Duplicate" and has seen no recent activity. It has been automatically closed for house-keeping purposes.