Reputation: 2536
I ran into an issue, I don't know if it's an actual problem or a misunderstanding on my side.
Considering this piece of code
type Props = {
foo: string
bar: string
} & Record<string, unknown>
// Using Record<string, unknown> seems to be the recommended way of saying "any object"
// Looks fine, no errors: the known properties seems to be kept in the intersection
const obj: Props = { foo: '', bar: '' }
type KeysOfProps = keyof Props // Here this type is "string"
For me:
keyof Props is string
then Props
shouldn't allow obj
to have toto
and tata
being strings{ foo: string; bar: string }
is assignable to Props
, then keyof Props
should still have the foo
and bar
known properties. (else foo
and bar
should be unknown in my understanding)It seem incompatible, but I figured out that keyof Props
is probably 'foo' | 'bar' | string
which is simplified to string
.
But, this causes some problems in my case. For example:
// This is Record<string, unknown>, `bar` is lost in the process
type PropsWithoutFoo = Omit<Props, 'foo'>
// This I can explain by the fact that Omit is described this way:
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// `keyof T` being `string and K being 'foo': Exclude<string, 'foo'> is string
// Pick<Props, string> is 'foo' | 'bar' | unknown which is unknown
// => Every keys of the intersection is lost
This happened in a code with generics where Record<string, unknown>
is actually a default type that could be replaced by any type that extends it. The lost of my properties seems to boil down to this, that's why I need to get this working.
Upvotes: 0
Views: 728
Reputation: 328302
Everything is working as intended in TypeScript here. It looks to me like your bullet points are both misunderstandings: the keyof
type operator is lossy when applied to types containing string indexes. As you noted, 'foo' | 'bar' | string
is indeed just string
; this is true, since a value of type Props
can have any string
-valued key. If you write Pick<XXX, keyof XXX>
, where XXX
has a string index signature, you will therefore lose the known keys:
type PropsMissingKnownKeys = Pick<Props, keyof Props>;
/* type PropsMissingKnownKeys = {
[x: string]: unknown;
} */
Note that this is different from a direct mapped type of the form K in keyof XXX
; in that case, the compiler will treat in keyof
specially and iterate over each known key as well as the indexer:
type HomomorphicProps = { [K in keyof Props]: Props[K] };
/* type HomomorphicProps = {
[x: string]: unknown;
foo: string;
bar: string;
} */
This leads to a possible different version of Omit
implemented using the support for key remapping in mapped types as introduced in TypeScript 4.1:
type AlternativeOmit<T, K extends PropertyKey> = {
[P in keyof T as Exclude<P, K>]: T[P]
}
AlternativeOmit<T, K>
is a direct mapped type that iterates over each property key P
in T
, and remaps each key to never
or P
depending on whether or not P
is assignable to K
. If you use that instead of Omit
, the desired type comes out:
type PropsWithoutFoo = AlternativeOmit<Props, "foo">;
/* type PropsWithoutFoo = {
[x: string]: unknown;
bar: string;
} */
Since AlternativeOmit
and Omit
have different behavior, there may well be edge cases where Omit
behaves the way you want and AlternativeOmit
does not. So I wouldn't say that you should just replace all instances of Omit
with AlternativeOmit
, and I certainly wouldn't suggest that TypeScript's library change the definition of Omit
, which would affect everyone else. As mentioned in a relevant GitHub issue comment (microsoft/TypeScript#31501):
All possible definitions of
Omit
have certain trade-offs; we've chosen one we think is the best general fit and can't really change it at this point without inducing a large set of hard-to-pinpoint breaks.
Upvotes: 2