6 ms·
Why Object.keys doesn't return (keyof T)[]
- davnicwil 3y agoThe last section "I understand all this, but I still want to do it" is quite funny. TS is very pragmatic in the way it allows you to convince it you know what you're doing and, really, this will be fine in reality even if the types don't really agree. It's a DX tradeoff I believe is usually worth making. But it is comical on a certain level how you'll choose to use a tool to point out the potential of problems with your code but then sometimes ignore it / tell it it is wrong when it does :-)
- tantalor 3y ago> may lead to potential issues That's a terrifyingly vague comment!
- Waterluvian 3y ago> but then sometimes ignore it Usually when the tool fails to be expressive enough for me to comfortably communicate that no, it’s definitely going to be T, not `string` Using Object.entries/keys/fromEntries and Array.reduce are my least-favourite moments with TS because of how darn wordy and assertive I have to get.
- KyeRussell 3y agoYep. It pretty genuinely makes me feel like I’m doing that Wrong Thing.
- giraffe_lady 3y ago"Arguing with the type checker" is definitely a step in the feedback loop of all typed languages, but in my experience with TS you spend a lot more time on this step compared to others.
- madeofpalk 3y ago> it allows you to convince it you know what you're doing and, really, this will be fine in reality even if the types don't really agree I disagree here. Typescript allows you to lie about your code, and that's only a ticking time bomb that will work until it doesn't. The workaround might work in the current state of your application, but over time if things/people change, it makes it much more difficult to reason about things. Because typescript allows interfaces to have additional properties, five levels up the call stack someone will pass in an object that has extra properties for whatever reason, and then the assumptions we once made about the code fall out and you ship an unknown bug.
- Guthur 3y agoIt's amazing how in practice it's not really a big deal. I've worked on many dynamically types systems and have no problems thinking in terms of compile time, run time, and macro expansion if you're so lucky, but some people just want the computer to say yes. Oh and miss specification of the system will happen in all. Even when computer says yes.
- yakshaving_jgt 3y agoThis is purely anecdotal. I could just as easily argue with equivalent experience the exact opposite.
- KyeRussell 3y agoSo what? Someone choosing to act based on their experience is what being experienced IS. We all do it. I do it. You do it. The grandparent comment is very conveniently particularly worded: “until it doesn’t”, with at times orders of magnitude of differences in the likelihood of “until” happening. If you’re acting like a type system suddenly means that you aren’t making trade-offs, that’s a huge sign of incompetence in my eyes. And if you AREN’T acting like this, what precisely is the issue here? Not everything needs to be an argument. The parent commenter is quite rightfully pointing out some nuance, not necessarily arguing that Your Preferred Way To Do Things is wrong.
- mpoc 3y ago"objectKeys" wrapper from https://github.com/sindresorhus/ts-extras https://github.com/sindresorhus/ts-extras also does this. It would be nice if the behaviour with "as const" was different: const person = { name: 'John Doe', age: 35 } as const const result = Object.keys(person) // return type is not 'name' | 'age'
- have_faith 3y agoThis was my first thought. I thought `as const` might have narrowed the type.
- jtwerasdf 3y agoThis should be obvious as TypeScript has inexact types (shame they didn't port exact types from Flow). This really needs to be stated more clearly I guess.
- streptomycin 3y agoExact types are probably the only thing I still miss after moving away from Flow a few years ago.
- STRML 3y ago100%. Flow always had some great safety features and some complex type combinators. But the churn in the project and lack of non-FB participation made it hard to recommend.
- ralusek 3y agoThe logic here seems inconsistent. Of course the keys could be different at runtime, but that can happen in all sorts of situations. I could type an API response and the API could change, or I could've been wrong about the response type, but it's not like Typescript says "well we refuse to accept your API typing because it could be wrong at runtime." I feel like it just makes more sense to trust that the object type is what we've said it is, and if it's not, THAT is the problem, and not that TypeScript let us believe that the keys were type keyof T.
- matt_kantor 3y agoIn all of your examples you're either starting from `any` or doing a type assertion somewhere along the line. That's explicitly opting-out of type checking, the same way that asserting an `Object.keys` return value is `(keyof T)[]` would be. > I feel like it just makes more sense to trust that the object type is what we've said it is The object type is what we've said it is. The type is correct, it just doesn't necessarily specify every last detail of your value, because subtyping is a thing. For example: class Pet { name: string } class Dog extends Pet { isAGoodBoy: boolean } const f = (pet: Pet): keyof Pet => Object.keys(pet) // this call should be allowed, but that means `f`'s return type is wrong f(new Dog())
- madeofpalk 3y agoDevs on the Typescript team have commented that ideally they would rather JSON.parse() returns unknown, but it wasn't around when lib.d.ts was first made, and it would be too big of a breaking change to do so now.
- codeflo 3y agoAlthough it's technically a more complex case, I think many developers would intuitively understand something like this: const base: BaseClass = new SubClass(); const keys = Object.keys(base); // should give you the subclass keys as well (This is pseudo-code that might not quite work due to how prototypes and classes interact, but I'm trying to illustrate a principle.) What's perhaps a bit surprising is that all types in TypeScript behave like BaseClass in this example. Object types (e.g. {a: number, b: string}) specify the minimally required properties, not the maximally allowed properties -- they are effectively the same thing as interfaces. Any concrete object that implements the interface can have more properties than the interface requires. That's so fundamental to the type system that I think if you want to work around it, you might not fully understand how TypeScript works yet.
- ygra 3y agoThat's precisely the example that Anders gave and the article quoted. And yes, that makes the issue very apparent (as well as Object.keys({}) having to return never[] by TypeScript's rules).
- nextaccountic 3y ago> Object types (e.g. {a: number, b: string}) specify the minimally required properties, not the maximally allowed properties -- they are effectively the same thing as interfaces In contrast, OCaml's objects differs <a: int; b: int> (exactly those fields) from <a: int; b: int; ..> (may have more fields). Likewise for Purescript records etc This stuff was figured out decades ago. There was no need for Typescript to be like this
- chii 3y ago> There was no need for Typescript to be like this typescript is strictly a superset of javascript, and javascript is like this.
- skinkestek 3y agoTo build on this: I think it is also a large part of the reason why TypeScript is amazing and very useful in the real world.
- ryanoptimus 3y agoThe argument is the fact it can have additional properties at runtime. In which case does it not make more sense to have Object.keys typed as `(keyof T & string)[]` Where it's typed as the keys but allows for additional string values too?
- phpnode 3y agoThis won't work - the code you've written here will forbid non string keys (i.e. symbols) but it won't allow arbitrary strings. If you changed it to a union rather than an intersection then that won't work either because the union of `"a" | "b" | string` is `string`. TS team made the correct call here, as annoying as it is.
- nosianu 3y agoThe difference is IDE help: Even though the type itself ends up being "string", the IDE is able to offer you the individual values that you specify additionally if you ask for possible completions at places where that type is valid. That's why I occasionally list specific options in addition to the larger parent type, just for the IDE, not for the type checks. TypeScript mixes a lot of things and tools that are actually separate things and presents it in one big package, for better or worse, not just in this case.
- phpnode 3y agoNo, in this case the IDE won't help you. To make that happen you'd have to write this as: (keyof typeof someObject | (string & Record<never, never>))[] which will give you autocomplete but still allow arbitrary strings. TS will still complain as soon as you use it as an object indexer though, defeating the point.
- nosianu 3y ago> No, in this case the IDE won't help you. Yes it does! I even tried it just to be sure with just that example "a" | "b" | string. The Playground and Webstorm both provided correct completions, Playground added a lot of wrong useless stuff, Webstorm - using tsc service I guess - only "a" and "b" as possible values (there is nothing concrete to other string after all).
- davedx 3y ago"However, it’s important to be aware of the potential drawbacks of using this type of wrapper, so consider adding a warning message to it." Or just don't do it, it's not type safe.
- asherah 3y agoi think it's reasonable for cases where you know the type, e.g. an internal object which a user of a library can't access
- webstrand 3y agoI solve this issue by leveraging the type-system to force me to enumerate all of the keys I expect to iterate over at compile time, i.e. knownKeys(o, ["x", "y"]); // returns ["x", "y"] Excess keys are not an issue, because they are ignored. And if I fail to list a key that the compiler knows about, the compiler will complain. https://tsplay.dev/WKvkzm https://tsplay.dev/WKvkzm
- _fat_santa 3y agoI run into this issue pretty often when using `Array.reduce`, I will do something like: Object.keys(someObject).reduce((acc, key) => ...., { const val = someObject[key] }) When I extract the value in Typescript, I always have to coerce the type like: someObject[key as keyof typeof someObject]
- andrewaylett 3y agoIf you're going to be indexing into the object with the key, you might be interested in `Object.entries()` instead -- it gives you all the keys paired with their values, so you don't have to look them up inside your reducer.
- krossitalk 3y agoOverpaid behavior. ES5 was fine.
- planede 3y agoIt seems to be stem from `Record` being contravariant in its Key type, right? const taskCounts = { work: 10, home: 5, personal: 3 }; This probably has the deduced type of `Record<"work" | "home" | "personal", number>`. I wonder if there is a hole in the type system that could be filled with an `ExactKeyTypeRecord<K, V>`, which is invariant in K and is a subtype of `Record<K, V>`. Maybe this could have Object.keys() defined as expected. I have no experience with TS, but I believe Python's TypedDict has a bunch of similar holes in the type system that could be mitigated with a bit more elaborate type hierarchies.
- STRML 3y agoFlowtype works like this out of the box. In my opinion, it's a much better default.
- planede 3y agoHuh, nice! Looking at the documentation[1], I assume this is the difference between "exact object types" and "inexact object types". I though about bringing this up for Python TypedDict, so I'm glad there is an actual precedent. https://flow.org/en/docs/types/objects/ https://flow.org/en/docs/types/objects/
- headbee 3y agoI believe that would be covariant: `'home' | 'work'` is a subtype of `string`, and `Record<'home' | 'work', number>` is assignable to `Record<string, number>` so the subtype relationship is preserved.
- planede 3y agoI don't follow how this parameter works then in Record: const taskCounts = { work: 10, home: 5, personal: 3 }; const what : Record<string, number> = taskCounts; // OK, covariance? const the : Record<"work" | "home", number> = taskCounts; // Also OK, contravariance? const hell : Record<"work" | "home" | "personal" | "hello", number> = taskCounts; // This is an error https://www.typescriptlang.org/play?#code/MYewdgzgLgBFCGEDWBhEBXMUIwLwwG8YB3EAJyQC4YBGABgBoYALEAWwFNqBWJgBw5kI4eABtqAZhgBfANwAoeaEixizeLGoAlDqDIATADzQyASzABzJmHRsARoIB8eOIlQYsEWTAD0PmKAAbvBm8GDAHAD8SuDQcMwcMNq65EYARKQUaTAAPjBprJxp1rYOZM74CMhomNjefgHgUGTwwaHhUTEqLByiokkwOnrpmUjZeQXsHOP5AkIiojMFvaIgxTA29k4uVe61XkA https://www.typescriptlang.org/play?#code/MYewdgzgLgBFCGEDWB...
- petilon 3y agoThat's like saying the following is incorrect let foo: number; because at runtime you can assign a string to foo. Typescript's type system is for compile-time checking, not runtime checking. The above definition is correct if at compile-time you want to check that all assignments to foo are of type number.
- tshaddox 3y agoThe difference is that TypeScript won’t let you assign a string to foo at runtime, but TypeScript will absolutely let you pass an object with additional properties at runtime.
- rickstanley 3y agoCurious why run examples on StackBlitz when you have TypeScript playground: https://www.typescriptlang.org/play/ https://www.typescriptlang.org/play/ , just a matter of preference I guess. Very interesting though, I have not thought that it could be a problem/necessity, maybe I'm programming on "automatic mode", in a way that I already know the workarounds of TS type system.