Antoine Laffargue
Antoine Laffargue

Reputation: 2536

Keyof loses known properties when applied on a type intersected with Record<string, unknown> and leads to incorrect type used with Omit<T, K>

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:

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 2

Related Questions