Why Object.keys doesn't return (keyof T)[](vladimirzdrazil.com)
vladimirzdrazil.com
Why Object.keys doesn't return (keyof T)[]
https://vladimirzdrazil.com/posts/object-keys-keyof-t/
56 comments
That'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).
> 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.
I think most engineers who use (and swear by) TypeScript don't fully understand how TypeScript works (or JavaScript for that matter).
I've worked across multiple teams at multiple companies and always run into types I can't trust because they are just plain wrong and inconsistent with the runtime, or data that isn't typed correctly. I also found that everyone writes TS differently, and sometimes the verbose typings and weird non-idiomatic patterns introduced just to please tsc actually make the code harder to read (30-line JS modules become 120-line TS behemoths). I still prefer working with TS on teams (though I prefer JS on solo projects now), but after a while I ask "is this the best we can do?"
/end minirant
I think most engineers who use (and swear by) TypeScript don't fully understand how TypeScript works (or JavaScript for that matter).
I've worked across multiple teams at multiple companies and always run into types I can't trust because they are just plain wrong and inconsistent with the runtime, or data that isn't typed correctly. I also found that everyone writes TS differently, and sometimes the verbose typings and weird non-idiomatic patterns introduced just to please tsc actually make the code harder to read (30-line JS modules become 120-line TS behemoths). I still prefer working with TS on teams (though I prefer JS on solo projects now), but after a while I ask "is this the best we can do?"
/end minirant
> 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
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
> There was no need for Typescript to be like this
typescript is strictly a superset of javascript, and javascript is like this.
typescript is strictly a superset of javascript, and javascript is like this.
To 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.
I think it is also a large part of the reason why TypeScript is amazing and very useful in the real world.
Because you need a ridiculously complex type system to model JS's behavior and then in the end don't even get soundness for all your trouble?
I know this comes across as really hostile but I just feel very burned by TS's promises and hype. You can work in it more confidently than JS I think but a lot of that is due to the tooling. In exchange you massively increase the required knowledge and mental modeling to keep track of what's going on and why. I used to be all about it but now after using it in a few big projects I've lost the enthusiasm.
Someone on here once described it in an excellent way, saying it's great for working out "type puzzles," which smart engineers tend to experience as important and productive. But the actual practical benefits in "the real world" as you say I'm becoming less and less convinced of.
I know this comes across as really hostile but I just feel very burned by TS's promises and hype. You can work in it more confidently than JS I think but a lot of that is due to the tooling. In exchange you massively increase the required knowledge and mental modeling to keep track of what's going on and why. I used to be all about it but now after using it in a few big projects I've lost the enthusiasm.
Someone on here once described it in an excellent way, saying it's great for working out "type puzzles," which smart engineers tend to experience as important and productive. But the actual practical benefits in "the real world" as you say I'm becoming less and less convinced of.
A sorry, it's really not that complex. That's just a matter of opinion and depends on what you compare with.
B You say you are less convinced of the "real world" benefit and actually specify the main one: "a lot of that is due to the tooling"... great tooling requires specific typing, this is a only a compile-time pass.
B You say you are less convinced of the "real world" benefit and actually specify the main one: "a lot of that is due to the tooling"... great tooling requires specific typing, this is a only a compile-time pass.
Yes obviously it's opinion and I was offering mine.
It's more complex than other languages with similar benefits. Languages with type systems as sophisticated as TS give you MUCH stronger assurances for it. Languages with less sophisticated type systems give you much stronger assurances for it. People are bringing up ocaml in here for very good reason. It has a fairly limited and simple type system compared to TS yet is able to make strong assertions based on it. Rescript is an excellent example of how this could have been applied to JS.
This is the core tradeoff of being a superset of JS, but as more time passes it's harder to see the other benefits of that. You need a compiler anyway.
It's more complex than other languages with similar benefits. Languages with type systems as sophisticated as TS give you MUCH stronger assurances for it. Languages with less sophisticated type systems give you much stronger assurances for it. People are bringing up ocaml in here for very good reason. It has a fairly limited and simple type system compared to TS yet is able to make strong assertions based on it. Rescript is an excellent example of how this could have been applied to JS.
This is the core tradeoff of being a superset of JS, but as more time passes it's harder to see the other benefits of that. You need a compiler anyway.
> you massively increase the required knowledge and mental modeling to keep track of what's going on and why.
Can you explain more about what additional knowledge and mental modeling you think is required for Typescript that you're exempted from knowing in Javascript? In my experience, Typescript only exposes the ugly realities of Javascript. Ignoring it doesn't eliminate it.
Can you explain more about what additional knowledge and mental modeling you think is required for Typescript that you're exempted from knowing in Javascript? In my experience, Typescript only exposes the ugly realities of Javascript. Ignoring it doesn't eliminate it.
> Because you need a ridiculously complex type system to model JS's behavior and then in the end don't even get soundness for all your trouble?
most type systems are not sound. Co-, Contra- or In-Variance is a extremly hard problem there are not many languages out there who offer complete soundness over all types.
most problems of typescript happen because people use interfaces a lot, since they are type hints for javascript objects, however once you start with classes way more and use strict mode, stuff is way better than just using js.
most type systems are not sound. Co-, Contra- or In-Variance is a extremly hard problem there are not many languages out there who offer complete soundness over all types.
most problems of typescript happen because people use interfaces a lot, since they are type hints for javascript objects, however once you start with classes way more and use strict mode, stuff is way better than just using js.
The 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 :-)
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 :-)
> 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.
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.
Yep. It pretty genuinely makes me feel like I’m doing that Wrong Thing.
"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.
> 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.
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.
The only way I can think of off the top of my head where this would be an issue is when calling JSON.stringify on the object, or a similar function that operates on all available keys. But for the most part, why would you write a function like that if you only wanted to operate on certain keys?
Of course, you'd run into the same issue in regular Javascript by expecting a base class and receiving a child class of that base.
Of course, you'd run into the same issue in regular Javascript by expecting a base class and receiving a child class of that base.
It'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.
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.
That's fine. You can not like/believe in static types - I'm not here to debate that (even if i did start accidentally advocating for it).
What I don't understand is why you would chose to use Typescript, and then opt out of the only/main benefit it actually has. At that point you're just writing dynamically typed Javascript with more keyboard presses.
What I don't understand is why you would chose to use Typescript, and then opt out of the only/main benefit it actually has. At that point you're just writing dynamically typed Javascript with more keyboard presses.
Why do type system discussions bring out this type of academic-tier absolutism?
You can get immense value out of TS even if you hit eject every so often, just like you get immense value out of Rust’s memory safety even if you write unsafe code, just like you get immense value out of 2FA even if you tick “remember me for 30 days”.
Arguing that you shouldn’t use TS if you override it sometimes is throwing 100 babies out with the bathwater. Extending this to claiming that someone “doesn’t believe in static types” is needlessly argumentative and presumptuous. Anyone that makes this sort of absolute argument, I’d argue either hasn’t used TS enough, is using it in an environment where resources can be burned working around and abstracting the cases where it isn’t expressive enough, or has an overly permissive configuration.
This is purely anecdotal.
I could just as easily argue with equivalent experience the exact opposite.
I could just as easily argue with equivalent experience the exact opposite.
So 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.
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.
> The grandparent comment is very conveniently particularly worded: “until it doesn’t”
Those words do not appear in the comment that I replied to.
The argument that I'm challenging is this opening line:
> It's amazing how in practice it's not really a big deal.
I'm not discarding their experience, but from my own experience I would argue the opposite — in practice it really is a big deal.
Those words do not appear in the comment that I replied to.
The argument that I'm challenging is this opening line:
> It's amazing how in practice it's not really a big deal.
I'm not discarding their experience, but from my own experience I would argue the opposite — in practice it really is a big deal.
> may lead to potential issues
That's a terrifyingly vague comment!
That's a terrifyingly vague comment!
I 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.
https://tsplay.dev/WKvkzm
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
It seems to be stem from `Record` being contravariant in its Key type, right?
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.
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.
Flowtype works like this out of the box. In my opinion, it's a much better default.
Huh, 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/
I 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.
I 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/MYewdgzgLgBFCGEDWB...I think you were originally correct and that `Record<string, number>` was not a good example. I would guess that union literals and primitives are handled differently by the compiler in this case, perhaps because a record with keys of every possible string is impossible it may have a special condition to skip some typing rules. In fact, `Record<number, number>` is assignable to `Record<string, number>`.
This 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.
This really needs to be stated more clearly I guess.
Exact types are probably the only thing I still miss after moving away from Flow a few years ago.
100%. 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.
I 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]
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]
If 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.
"objectKeys" wrapper from https://github.com/sindresorhus/ts-extras also does this.
It would be nice if the behaviour with "as const" was different:
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'This was my first thought. I thought `as const` might have narrowed the type.
The 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.
In 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:
> 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())Devs 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.
That's like saying the following is incorrect
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.
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.
The 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.
The 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?
This 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.
The 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.
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.
No, 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.> 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).
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).
Can you share a playground link? When I try I do get the suggestion, but that suggestion is coming from the list of words in the file, not from typescript's language service. In the playground you can see the difference because the the autocomplete entry for the suggestion has an abc icon rather than a cube icon.
Here is a screenshot from my Webstorm IDE. The Playgrond is less useful, even when I had a demo it showed a thousand completely useless completions in addition to the two string constants and I'm not editing in there but in the IDE anyway so that is what matters.
https://imgur.com/a/fntl6GX
It also works when the type is defined in another file/module.
Playground: https://www.typescriptlang.org/play?noUnusedLocals=true&noUn...
I don't know where exactly the suggestions come from, if it's the language service or something else. Why would that matter? The only point I care about is that those suggestions are made, making those useless-for-type-checks union components still useful. I found WebStorm to show them even after I unchecked the TS language service in the settings. So what?
https://imgur.com/a/fntl6GX
It also works when the type is defined in another file/module.
Playground: https://www.typescriptlang.org/play?noUnusedLocals=true&noUn...
I don't know where exactly the suggestions come from, if it's the language service or something else. Why would that matter? The only point I care about is that those suggestions are made, making those useless-for-type-checks union components still useful. I found WebStorm to show them even after I unchecked the TS language service in the settings. So what?
"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.
Or just don't do it, it's not type safe.
i 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
Curious why run examples on StackBlitz when you have TypeScript playground: 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.
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.
Overpaid behavior. ES5 was fine.
(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.