Reputation: 87
I post code first explain after :
type ExampleType = {
a: string;
b: boolean;
c: () => any;
d?: boolean;
e?: () => any;
[inheritsProps: string]: unknown;
// If this ^ line over is remove, TypeNoC would work as I expect, but without a loose definition
};
type TypeNoC = Omit<ExampleType, "c">
// TypeNoC == {}
Some months ago, I was searching a way to define React Props allowing any other properties while still keeping only the essential define. Reason was that our props type were extends with React.HTMLProps<HTMLDivElement| HTMLButtonElement | ...> but when trying to use the component, you get 200+ optional properties mixed with the Required properties. That's annoying and not helpful, so I found this solution : [inheritsProps: string]: unknown;
which said that any other props define would be valid even if they aren't define in typing.
Now I'm working on a component with a controlled and uncontrolled version and I would like to remove some definition of the controlled version for the uncontrolled definition. Uncontrolled is using the controlled component and try to simplify how you use it. So I would like to remove some of the required properties. I would expect that Omit<ExampleType, "c">
should make a type that c is no more {a,b,d,e,...} while still c could exist because of [key:string]:unknown
, but that is not the current case. The current case is that TypeNoC has no properties... I think it's a bug, but I'm not sure if I'm using the feature properly in the first place.
Upvotes: 3
Views: 1199
Reputation: 1461
Adding to @jcalz's great answer above, I'll just note that I found the following in MongoDB's source here:
/** TypeScript Omit (Exclude to be specific) does not work for objects with an "any" indexed type, and breaks discriminated unions @public */
export type EnhancedOmit<TRecordOrUnion, KeyUnion> = string extends keyof TRecordOrUnion
? TRecordOrUnion // TRecordOrUnion has indexed type e.g. { _id: string; [k: string]: any; } or it is "any"
: TRecordOrUnion extends any
? Pick<TRecordOrUnion, Exclude<keyof TRecordOrUnion, KeyUnion>> // discriminated unions
: never;
which is used in the same file for things like:
export type WithId<TSchema> = EnhancedOmit<TSchema, '_id'> & { _id: InferIdType<TSchema> };
and works just as well for unknown
type.
Upvotes: 2
Reputation: 328292
You're right that TypeScript's Omit<T, K>
utility type doesn't really work well when T
has an index signature. I'd say this is just a limitation of Omit
and not really a bug.
The definition of Omit<T, K>
in TypeScript's library is
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
where Pick<T, K>
is defined as
type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
and Exclude<T, U>
is defined as
type Exclude<T, U> = T extends U ? never : T
When T
is a type with only known keys like {a: string, b: number}
, then Omit
behaves as expected. But when T
also has a string
index signature like ExampleType
then keyof T
will be the union of string | number
(why number
?) with all the known literal keys like "a"
and "b"
. But the union of string | number
with any string or numeric literal like "a"
or 1
is just string | number
, because string or numeric literals don't add any new possibilities to string | number
:
type KeyofExampleType = keyof ExampleType; // string | number
And so Exclude<keyof ExampleType, "c">
becomes Exclude<string | number, "c">
which, if you follow the distributive conditional type definition of Exclude
, is also just string | number
: you can't express "all string
s except for "c"
in TypeScript; it's just string
.
And that means that Omit<ExampleType, "c">
becomes Pick<ExampleType, string | number>
, which just produces the string
/number
indexes of ExampleType
and completely omits all known keys:
type BadTypeNoC = Omit<ExampleType, "c">
/* type BadTypeNoC = {
[x: string]: unknown;
[x: number]: unknown;
} */
(this is not {}
as you claim in your question, though... you should double check that because I can't reproduce it)
Could Omit
be implemented differently so as to act "properly" for types with index signatures? (and are we really sure what "properly" means? there might be edge cases). Well, maybe, but with considerably more effort and producing a much more complicated definition.
First you need a way to express something like keyof ExampleType
that gives you the known literal keys and not just the wider string
and number
index types. This can be done using conditional type inference, as mentioned in this GitHub issue comment, but it's crazy/ugly/surprising:
type KnownKeys<T> = {
[K in keyof T]: string extends K ? never : number extends K ? never : K
} extends { [_ in keyof T]: infer U } ? U : never;
type KnownPart<T> = Pick<T, KnownKeys<T>>
So KnownKeys<ExampleType>
is "a" | "b" | "c" | "d" | "e"
, and KnownPart<ExampleType>
is just the ExampleType
type without its index signatures. Analogously, you can come up with the index-signature keys and index-signature part:
type IndexableKeys<T> = {
[K in keyof T]: string extends K ? K : number extends K ? K : never
} extends { [_ in keyof T]: infer U } ? U : never;
type IndexPart<T> = Pick<T, IndexableKeys<T>>
Finally, you can make a version of Omit<T, K>
that splits T
into its KnownPart
and its IndexPart
and performs the key exclusion on each part, and then joins them back together:
type Omitʹ<T, K extends PropertyKey> =
Omit<KnownPart<T>, K> & Omit<IndexPart<T>, K> extends
infer O ? { [P in keyof O]: O[P] } : never;
That produces the expected TypeNoC
, I think:
type TypeNoC = Omitʹ<ExampleType, "c">
/* type TypeNoC = {
[x: string]: unknown;
a: string;
b: boolean;
d?: boolean | undefined;
e?: (() => any) | undefined;
} */
If you want to use something like Omitʹ
instead of Omit
in your code, you should feel free. But the reason why such a definition is not provided in the standard library is probably that it is way too complex and has too many potential points of failure. The benefits of being able to handle indexable types are probably not worth it, especially because indexable types are less common.
Upvotes: 8