Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Omit type on classes with a index type signature results in minimum properties not being required

Tags:

typescript

I have a class which has some mandatory properties and can then have other properties defined by classes extending this one (or anyway, some extra properties I don't know about).

Now I'm trying to use Omit type on it, defined as

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

If we define the class as

class Thing {
  constructor(
     public id: number,
     public name: string,
     public description: string
  ) {}
}

We can do Omit<Thing, 'id'> and get the right type inference as Pick<Thing, "name" | "description">

If we add a [index: string]: any to the class to support unknown properties and retry Omit<Thing, 'id'>, now the inferred type will be Pick<Thing, string | number>.

class Thing {
  constructor(
     public id: number,
     public name: string,
     public description: string
  ) {}

  [index: string]: any;
}

This doesn't really make sense: when I type using the class, all properties are required even when a [index: string]: any; is defined, why would it become a looser type when omitting one of its properties? I would expect the return type to still be Pick<Thing, "name" | "description">.

Does anyone knows what's going on?

like image 446
Paolo 'Callo' Caleffi Avatar asked Feb 22 '19 11:02

Paolo 'Callo' Caleffi


2 Answers

Omit uses keyof to get all the keys of a type and then excludes the omit keys from that. So a definition of Omit might be:

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

The problem with this approach (not that there is another one) is that for types with an index signature keyof T always just return string | number. This is logical and is the only way it could be. What are the possible keys for Thing for example? Well they are id, name and description but also any string so keyof might return a union of all of these id | name | description | string | number. But string is the base type of all string literal types. This means that string will eat up all the other string literal types resulting in string.

Given that keyof Thing is string excluding anything from string (with Exclude<keyof T, K>) will still result in string, given the signature you found surprising.

like image 42
Titian Cernicova-Dragomir Avatar answered Sep 23 '22 20:09

Titian Cernicova-Dragomir


UPDATE FOR TYPESCRIPT 4.1+

Since typeScript 4.1 introduced key remapping in mapped types, you can now write a non-ugly version of Omit which handles types with index signatures more gracefully:

type NewOmit<T, K extends PropertyKey> = 
  { [P in keyof T as Exclude<P, K>]: T[P] };

You can verify that it works as desired:

type RemoveId = NewOmit<Thing, "id">;
/* type RemoveId = {
    [x: string]: any;
    name: string;
    description: string;
} */

Nice!

Playground link to code


PRE-TYPESCRIPT 4.1 ANSWER

You can make an Omit<> that handles index signatures, but it's really ugly.

As noted, keyof T if T has a string index signature is string | number, and any literal keys (like 'id') get eaten up. It is possible to get the literal keys of a type with an index signature, by doing some crazy type-system hoop jumping. Once you have the literal keys and the index signature keys, you can build up an Omit:

type _LiteralKeys<T> = {
  [K in keyof T]: string extends K ? never : number extends K ? never : K
} extends { [_ in keyof T]: infer U } ? U : never;
type LiteralKeys<T> = _LiteralKeys<T> extends infer K ?
  [K] extends [keyof T] ? K : never : never;

type Lookup<T, K> = K extends keyof T ? T[K] : never;
type _IndexKey<T> = string extends keyof T ?
  Lookup<T, string> extends Lookup<T, number> ?
  string : (string | number) : number extends keyof T ? number : never;
type IndexKey<T> = _IndexKey<T> extends infer K ?
  [K] extends [keyof T] ? K : never : never;

type WidenStringIndex<T extends keyof any> =
  string extends T ? (string | number) : T;

type Omit<T, K extends keyof T,
  O = Pick<T, Exclude<
    LiteralKeys<T>, K>> & Pick<T, Exclude<IndexKey<T>, WidenStringIndex<K>>>
  > = { [P in keyof O]: O[P] }

I said it was ugly.

You can verify that it behaves as you expected:

type RemoveId = Omit<Thing, 'id'>;
// type RemoveId = {
//  [x: string]: any;
//  name: string;
//  description: string;
// }

If you want to take the above Omit, shove it in some library where nobody has to look at it, and use it in your code; that's up to you. I don't know that it handles all possible edge cases, or even what the "right" behavior is in some instances (e.g., you can have both a string and a number index where the number values are narrower than the string values... what do you want to see if you Omit<T, string> on such a type? 🤷‍♂️) So proceed at your own risk. But I just wanted to note that a solution of sorts is possible.

Okay, hope that helps. Good luck!

like image 150
jcalz Avatar answered Sep 23 '22 20:09

jcalz