Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type for-in initializer as string #54856

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

Andarist
Copy link
Contributor

@Andarist Andarist commented Jul 1, 2023

I think that for-in should have the same soundness guarantees as Object.keys. Since Object.keys always return string[], the same should be true for the for-in initializer - it has to be a string and can't be of a more narrow type.

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Jul 1, 2023
@typescript-bot
Copy link
Collaborator

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

let k1: K;
for (k1 in obj) {
~~
!!! error TS2405: The left-hand side of a 'for...in' statement must be of type 'string' or 'any'.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be an error because this is a valid call:

declare const obj: { a: 1, b: 2, c: 3 }
declare const key: 'a' | 'b'
f1(obj, key)

'c' is not assignable to K

@fatcerberus
Copy link

Require for-in initializer to be assignable to string

the same should be true for the for-in initializer - it has to be a string and can't be of a more narrow type.

These two statements contradict.

@Andarist Andarist changed the title Require for-in initializer to be assignable to string Type for-in initializer as string Jul 1, 2023
@Andarist
Copy link
Contributor Author

Andarist commented Jul 1, 2023

Good catch :p

@Mateusnasciment
Copy link

The statement that the for-in initializer must be a string and cannot be of a stricter type is contradictory. If the initializer is restricted to a string it would limit its usefulness as objects can have keys of different types. Therefore, it is appropriate that the for-in initializer accepts broader types such as string and any to cover all possible object keys.

@fatcerberus
Copy link

@Mateusnasciment "Initializer" is a misleading term here IMO - this is specifically about the type of the range variable of for...in, which is always string. The only other type that an object key can possibly be at runtime is symbol, and for...in ignores symbols.

According to MDN:

The for...in statement iterates over all enumerable string properties of an object (ignoring properties keyed by symbols), including inherited enumerable properties.

for (x in foo) { ... } - x can never be anything other than string. In particular, for arrays, it's (maybe counterintuitively) not number.

@fatcerberus
Copy link

@Andarist IIRC @RyanCavanaugh has said in the past that this was an intentional soundness tradeoff so people could do this...

const obj = { foo: 777, bar: 1206 };
let key: keyof typeof obj;
for (key in obj) {
    /* key :: "foo" | "bar" */
    console.log(obj[key]);  // normally an error, now ok
}

...otherwise you have to type-assert the key every time.

@Andarist
Copy link
Contributor Author

Andarist commented Jul 2, 2023

Ye, I get what is the consequence of that. However, I really can’t find a reason why this would not be consistent with Object.keys. If this is an intended soundness hole - that’s fine but the Object.keys then should have the same hole and it doesn't today

@ssalbdivad
Copy link

ssalbdivad commented Jul 2, 2023

@RyanCavanaugh If this is true, thank you- it is a massive help! From a discussion in TS Discord:

ssalbdivad: I almost always have to use let key: keyof MyObj...for(key in myObj) ... I assume it was added as a workaround. I think of it as a way to cast the key type as opposed to the assignment being safe... It seemed plausible there would be an escape hatch of some sort in that situation because what is the alternative? Creating another const variable in the loop just to cast the key type? Feels awful

If I understand correctly that this change would break that workaround, I'm strongly in favor of preserving the existing behavior. As far as I'm aware, without it, you're essentially forced to declare an extra variable in the loop body just to narrow the key type. If you're adding runtime logic just to make your types happy, something has gone wrong- a cast should always be sufficient in these situations.

@fatcerberus
Copy link

Aside: TIL for..in includes prototype properties 🙀

@Andarist
Copy link
Contributor Author

Andarist commented Jul 2, 2023

Enumerable ones but ye - it has been a popular combo in the past to have for-in+hasOwnProperty

@RyanCavanaugh
Copy link
Member

The reason for-in is allowed is that it is (or at least was) idiomatic to do this with arrays:

const arr = [1, 2, 3, 4];
for (const i in arr) { 
  console.log(arr[i]);
}

Rather than give i the completely-wrong type number, it's string with a carve-out to allow arr[i].

Should we just give up and type Object.keys as keyof T[] ? I am so tired of arguing about this.

@Andarist
Copy link
Contributor Author

The reason for-in is allowed is that it is (or at least was) idiomatic to do this with arrays:

I probably wouldn't cal this idiomatic in today's landscape so perhaps if that was the only-ish reason for this behavior then it could be revisited.

Rather than give i the completely-wrong type number, it's string with a carve-out to allow arr[i].

It could be given a type of ${number} today. That would allow us to remove the carve-out to allow arr[i] since it would already be legal. It wouldn't exactly cover perfectly cases like number[] & { prop: 1 } but those kind of things are not always handled by the compiler perfectly today so I think it should be fine here to type i as just string in cases like this.

Should we just give up and type Object.keys as keyof T[] ? I am so tired of arguing about this.

I totally get how you might be tired of this. I'm glad that Object.keys work like it does today - it gives me more confidence in my code and when I'm sure that I can only get keyof T then I cast it and it's fine. My only argument behind this PR is that the behavior of Object.keys and for-in should be identical because they are essentially the same(ish) operation. The for-in already works like Object.keys in the majority of cases and the weird carveout that I'm removing here just feels weird. It's like a hidden undiscoverable feature that feels out of place to me.

@rbuckton
Copy link
Member

Should we just give up and type Object.keys as keyof T[] ? I am so tired of arguing about this.

The problem with that is you would get the wrong type as a result of subclassing:

interface Point { x: number, y: number }
interface Point3D extends Point { z: number };

const p2d: Point = { x: 0, y: 1 };
const p3d: Point3D = { ...p2d, z: 2 };

let p: Point;

p = p2d;
Object.keys(p); // ("x" | "y")[]

p = p3d;
Object.keys(p); // ("x" | "y")[], but it also contains "z"

The wider type is safer.

@ssalbdivad
Copy link

@RyanCavanaugh I agree with @Andarist and @rbuckton (and you) that the widened type makes sense.

It feels like the only good solution to this is an exact constraint on object types that allows narrowed key iteration and disallows assignment from non-exact objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Status: Waiting on reviewers
Development

Successfully merging this pull request may close these issues.

7 participants