Repository navigation
Suggestion: treat in operator as type guard which asserts property existence #21732
Description
Activity
related discussion also in #10715 about type guards "evolving" the type of an expression.
This! Currently, we have the odd situation that when you write:
if ("assign" in Object) { Object.assign(target, ...args); }When the ES6 typings aren't loaded, it will narrow
Objectdown toneverin theifstatement!Greg Ros (@GregRos) please see #21517
Mohamed Hegazy (@mhegazy) Yup, I know. I was giving it as an example for what this suggestion would fix. I know why it's happening.
mattmccutchen commented
on Jul 17, 2018 ContributorMore actionsLet's add some other ways of asserting property existence from #25720 to this proposal:
let x: unknown; // All these should narrow x to {prop: unknown} (s/any/unknown/ from original proposal by Matt) "prop" in x; x.prop != null; x.prop !== undefined; typeof x.prop !== "undefined"; // typeof should work on properties of the unknown variable typeof x.prop === "string"; // should narrow x to {prop: string}
Reacted by Alec Larson, interphx, Matt Kantor, Micah Zoltu, Denis Sokolov, Sander Mol, Matt, Paul Merrill, Joe Barnett, Joshua Baker and 40 moreMatt McCutchen (@mattmccutchen) I think you'd want those to narrow to
{prop: unknown}rather than{prop: any}wouldn't you?Reacted by Felipe, James Bromwell, Hen Greville, zakcodez, Dennis Schridde and Mitch RyanMatt McCutchen (@mattmccutchen) The problem is that trying to access a property on null/undefined will throw an error at runtime. I would support adding those type guards, but only on the
objecttype. (If there were a way to get anunknownWithoutNullOrUndefinedtype, that would be even better)Reacted by Joe Calzaretta, Joshua Baker, Luke Page, Matthew Robb and Aaron CowieRyanCavanaugh commented
on Aug 13, 2018 MemberMore actionsJoe Calzaretta (@jcalz) can you highlight the differences between #10485 and what you'd want to happen?
TL;DR: #10485 narrows by filtering union constituents. This suggestion narrows by adding properties to the type.
In cases where
yis not a union type or none of the constituents have an explicit property namedx, the testif (x in y)should not try to filter union constituents, but should instead narrowyby intersecting its type withRecord<typeof x, unknown>.
In #10485, the type of the object you're checking with
inis meant to be a union, and the type guard filters that union based on whether the constituents do or do not explicitly have a property of the relevant name:type A = { w: string, x: number }; type B = { y: string, z: number }; function foo(p: A | B): number { if ('w' in p) { return p.x; // p is narrowed to A } else { return p.z; // p is narrowed to B } }
Note that this isn't exactly sound, since you can call
foo({ y: "oops", z: 100, w: true });
but the point of property checking on unions is usually to use that property as a discriminant, and the sound behavior would probably annoy the heck out of developers.
Compare to the following:
function bar(p: {}): string { if ('w' in p) { return String(p.w); // error?! ☹ } else { return "nope"; } }
It is surprising that after what feels like an explicit test for
p.w, the compiler still doesn't know thatp.wexists. Worse,pis narrowed tonever, probably because of #10485.What I want to see happen is:
- Only do the narrowing in Treat
inoperator as type guard #10485 if the type is a union where at least one constituent explicitly features the relevant property (edit: optional properties count as "explicit" also). - Otherwise (or afterwards), have the
x in ycheck be equivalent to the type guardy is typeof y & Record<typeof x, unknown>(modulo the bug in For object type C, type parameter T and mapped type { [K in keyof T & C]: any }, keyof C isn't assignable to K #18538).
Reacted by Ryan Cavanaugh, SlurpTheo, Matt Kantor, Sean Vieira, Jeremy Scheff, Brad Zacher, pierre, Luke Page, Peter Flynn, Gabriele Tomberli and 5 more- Only do the narrowing in Treat
- addedIn DiscussionNot yet reached consensusNot yet reached consensus
on Aug 14, 2018 Joe Calzaretta (@jcalz) This guard doesn't work with partial interfaces
function foo(x: {foo: "bar"} | {toFixed?: () => any}) { if (inOperator("toFixed", x)) { x.toFixed(); // Error, Object is of type unknown } }
Reacted by Michael Kriesesirian Yeah, strictly speaking, all you know is that
x.toFixedexists, since a value of type{foo: "bar"}may contain atoFixedproperty (e.g.,foo(Object.assign({ foo: "bar" as "bar" }, { toFixed: "whoops" })); // no error
), but assuming people still want the current behavior in #10485 where we eliminate
{foo: "bar"}from consideration as soon as we find atoFixedproperty, then this suggestion is to apply theinOperator()behavior after that elimination.So in your case,
xwould first be narrowed to{toFixed?: () => any}as per #10485 and then to{toFixed: (() => any) | undefined}via something likeinOperator()... meaning thattoFixedis definitely there but it might beundefined(since?is ambiguous about whether the property is actually missing or present but undefined.)If you want a better idea what the behavior would end up being like, try the following complex user-defined type guard using conditional types:
type Discriminate<U, K> = ( U extends any ? K extends keyof Required<U> ? U : never : never ) extends infer D ? [D] extends [never] ? U : D : never function hybridInOperator<K extends keyof any, T>( k: K, o: T ): o is Discriminate<T, K> & Record<K, unknown> { return k in o; }
producing:
function foo(x: { foo: "bar" } | { toFixed?: () => any }) { if (hybridInOperator("toFixed", x)) { x.toFixed(); // error, possibly undefined if (x.toFixed) x.toFixed(); // okay } }
Does that seem better?
Joe Calzaretta (@jcalz)
Better, but there is another problem with type infer. Look at examplefunction foo(x: { foo: "bar" } | Number | { toFixed?: () => any }) { if (hybridInOperator("toFixed", x)) { x.toFixed(); // no error. since x resolved as Number if (x.toFixed) x.toFixed(); // okay } }
I also tried various type guards. But always found a new counterexample(
Upd. As a quick fix - if you change
o is Discriminate<T, K> & Record<K, unknown>too is Extract<T, Discriminate<T, K>>. then x will be resolved asNumber | { toFixed?: () => any }Upd2.
So in your case, x would first be narrowed to {toFixed?: () => any} as per #10485 and then to {toFixed: (() => any) | undefined}
Not right...
toFixedwould be narrowed tounknown... Look at example. So and error in #21732 (comment) screenshot was not "object is possibly undefined"94 remaining items
It's happening!!!
Reacted by Will Slattum, Antoine Moreaux, lazytype, NicoVIII, Trond Bergquist, Jacek Nowacki, Albert Mañosa, Yuriy Burychka, Katie Byers, Artem Baranov and 8 more#50666 did not fully fix this issue:
I'm marking this PR as fixing #21732 even though it doesn't address the case of key in obj where key is of some generic type.
function f<K extends string, T extends object>(key: K, genericObj: T, concreteObj: {foo: string}) { if ('a' in concreteObj) { concreteObj.a // no longer an error } if ('a' in genericObj) { genericObj.a // no longer an error } if (key in concreteObj) { concreteObj[key]; // still an error } if (key in genericObj) { genericObj[key] // still an error } }
can this issue be re-opened? or is there a new issue for this?
Reacted by Sebastian Malton, Marcus Riemer, Leandro Aguiar, Albert Mañosa, Bernhard Häussner, Katie Byers, Eugene, btoo, Alexander, Nathan McWilliams and 2 moreDetachHead How would you expect this to work?
// `concreteObj` is type `{foo: string}` if (key in concreteObj) { // `concreteObj` is narrowed to ??? concreteObj[key]; }
concreteObjbeing type{foo: string}doesn't mean that'foo'is the only key inconcreteObj. So let's say in the conditional we narrowed the type ofconcreteObjdown to{foo: string} & Record<K, unknown>. Now there'd be no type error, but it would allow code like this:function f<K extends string, T extends object>(key1: K, key2: K, genericObj: T, concreteObj: {foo: string}) { if (key1 in concreteObj) { concreteObj[key2]; }
This is obviously unsafe, and there's no way in the type system to express that
concreteObjhaskey1but notkey2.Reacted by DetachHead, Bernhard Häussner, Jérémy Rialland and Leon AdlerReacted by Artur KlesunReacted by SlurpTheoehoogeveen-medweb commented
on Feb 9, 2023 More actionsYeah, you'd need a construct like
valueof key1(so that in the 4th case,valueof key1 extends keyof genericObj). Not sure where excess properties would fit into that, though. Either way,noUncheckedIndexedAccessis very difficult to use without something like that (and without something to represent the inferred minimum length of arrays).Reacted by DetachHead and Bernhard HäussnerI think it does not make sense that TypeScript assumes there are additional properties somehow on an object but also does not allow adding new properties to an object at the same time. Just like an
ifnarrows down a type, adding properties to an object could narrow down the type to objects that also include this property. Consider this example:const objectWithKnownKeys = { a: 1, b: 2, } as const; // This is an error but maybe it should work and just change // the type of objectWithKnownKeys to include {c: 3} maybe? objectWithKnownKeys.c = 3; const key: string = 'a'; if (key in objectWithKnownKeys) { // TypeScript should know here there are no additional keys or allow setting c const value = objectWithKnownKeys[key]; }So maybe the actual bug is that
objectWithKnownKeys.c =generates an error? Basically TS2339 should not go off when setting. I can find an old issue #766 about this but related to classes. I would also allow to construct object like this instead of relying onanyorPartial<>:type SomeObject = { a: number, b?: number } // We have to rely on "any", even Partial<SomeObject> would cause an error later const initiallyEmptyObject: any = {}; initiallyEmptyObject.a = 1; if (Math.random() > 0.5) { // or some logic initiallyEmptyObject.b = 2; } const someObject: SomeObject = initiallyEmptyObject;But I assume this would be a pretty big change and there would need to be a solution for letting people know when they accidentally create a new property with a typo, so that's your reason why this will probably never be fixed.
RyanCavanaugh commented
on Feb 15, 2023 MemberMore actionsthere would need to be a solution for letting people know when they accidentally create a new property with a typo
This is the entire crux of it; a computer can't tell whether this code is intentional or not:
const dimensions = { width: 0, height: 0 }; dimensions.widht = 3;
Our experience is that detecting retrospectively-obvious-to-human typos is seen as worth the false positives in other cases. It's why we have type annotations.
Reacted by James Bromwell, DetachHead, Albert Mañosa, Leon Adler and SlurpTheothere would need to be a solution for letting people know when they accidentally create a new property with a typo
This is the entire crux of it; a computer can't tell whether this code is intentional or not:
const dimensions = { width: 0, height: 0 }; dimensions.widht = 3;
Our experience is that detecting retrospectively-obvious-to-human typos is seen as worth the false positives in other cases. It's why we have type annotations.
Could "as const" be used to detect that case?
const dimensions = { width: 0, height: 0 } as const; dimensions.widht = 3;
if you want to allow adding unknown keys to an object, you can define it like so in the type:
interface Dimensions { width: number height: number [key: string]: number } const dimensions: Dimensions = { width: 0, height: 0 }; dimensions.widht = 3; // no error
that's an edge case though, and as Ryan Cavanaugh (@RyanCavanaugh) said it's far more likely that assigning an unknown key is an error, which is why it wouldn't make sense to not show an error by default, expecting the user to use
as constto opt into the functionality they'd want 99% of the timeReacted by Marcus Riemer and Artur KlesunAaron Cowie (@aaroncowie) A subtype could already contain that key with an unrelated type, therefor losing type soundness.
function foo(a: { width: number, height: number }) { a.length = "idk" } const a = { width: 0, height: 0, length: 0 } foo(a) a.length // STRING????? HUUUHHH?????
function f<K extends string, T extends object>(key1: K, key2: K, genericObj: T, concreteObj: {foo: string}) { if (key1 in concreteObj) { concreteObj[key2]; // ts(2536): Type 'K' cannot be used to index type '{ foo: string }'. }
This is obviously unsafe, and there's no way in the type system to express that
concreteObjhaskey1but notkey2.I'm not following what's obvious about this issue/thread/example. It makes sense to me that you can't index by
key2asf<"foo" | "BAR", { foo: string }>("foo", "BAR", { foo: "a3" }, { foo: "a4" })is a type-valid caller of this API.It makes sense to me that you can't index by
key2The goal of the comment I was replying to is to be able to index
concreteObjbykey1after checking forkey1's existence with theinoperator. What is "obvious" is that it would be unsafe to indexconcreteObjbykey2if only checking for the existence ofkey1. My point is that from the perspective of the type systemkey1andkey2are of the same type. So there's no way to model the behavior thatconcreteObjis indexable bykey1but notkey2in the current type system.- added a commit that references this issue
on Jan 22, 2024 lazytype, I believe you are looking at it from the wrong angle. The
incheck should not change the type of theconcreteObj, it should change the type of thekey- I believe that's what DetachHead meant when he asked if this issue can be re-opened since it was not fully fixed which is my big request also.type InputKey = "goodKey1" | "goodKey2" | "badKey"; const obj = { "goodKey1": 123, "goodKey2": 234, }; const inputKeyOptions: InputKey[] = ["goodKey1", "goodKey2", "badKey"]; const inputKey: InputKey = inputKeyOptions[Math.floor(Math.random() * 3)]; if (inputKey in obj) { // gives an error, but compiler should have narrowed the type of inputKey to "goodKey1" | "goodKey2" const value = obj[inputKey]; }
Artur Klesun (@klesun) you did not read my comment carefully enough. You cannot narrow
inputKeyto"goodKey1" | "goodKey2"because from the typechecker's perspectiveobjis of type{ goodKey1: number; goodKey2: number }which means any object which has "goodKey1" and "goodKey2" keys with numerical values. That includes objects that can have additional keys. The typechecker does not know thatobjhas exactly those keys. This concept doesn't exist in TypeScript.Reacted by snarbles2

TypeScript Version: 2.8.0-dev.20180204
Search Terms:
inoperator type guard generic assertCode
Actual behavior:
The compiler does not recognize that the objects have relevant keys even after checking for the existence of the key with the
inoperator. According to a comment by Nathan Shively-Sanders (@sandersn), theintype guard (as implemented in #15256) narrows by eliminating members from a union; it does not assert that a key exists.Desired behavior:
The compiler would assert that each object had a property with the relevant key, after having checked for the existence of the key with the
inoperator. Note that one possible implementation of an asserting type guard would look likebut this does not behave exactly as desired, possibly due to the bug in #18538:(not sure if #18538 was officially fixed, but it's not erroring anymore)If a fix for #18538 appears and makes theg()function compile without error, great. Otherwise, maybe the property assertion forincould happen some other way. Not sure.Playground Link: Here
Related Issues:
#10485, Treat
inoperator as type guard#15256, Add type guard for
inkeyword#18538, Error when mixing
keyofand intersection type and type variable (fixed?)(EDIT: #18538 seems to be fixed)
(EDIT: change
anytounknown)