Maxime4000
Maxime4000

Reputation: 87

Loose Type definition with Omit and [key:string]: unknown

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

Answers (2)

gadicc
gadicc

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

jcalz
jcalz

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 strings 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.

Playground link to code

Upvotes: 8

Related Questions