Home > Enterprise >  Pick only two properties from a type in Typescript
Pick only two properties from a type in Typescript

Time:05-16

I need to pick only two properties whose names are not yet defined from a type, and create a new type from there with one of these properties required and another one optional.

I am aware that it is possible to pick a single property with

<T extends Record<string,any>> {
    [K in keyof T]: (Record<K, T[K]> & 
    Partial<Record<Exclude<keyof T, K>, never>>) extends infer U ? { [P in keyof U]: U[P] } : never
}[keyof T]

But didn't figure out how (and if) it is possible to pick two properties using this method.

Follows an example of how i would like to use it

class Article {
    name: string
    id: number
    content?: string
}

const article: TwoKeys<Article> = { id: 23 } // no error
const article: TwoKeys<Article> = { name: "my article", id: 122 } // no error
const article: TwoKeys<Article> = { name: "my article" , id: 23, content: "my content" } // error! we passed more than two props.

CodePudding user response:

First, let's make a helper type called PickOnly<T, K> where you take an object-like type T and a key type K (or union of such keys) and produce a new object-like type where the properties of T with keys in K are known to be present (just like the Pick<T, K> utility type), and where the keys not in T are known to be absent (which is not required in Pick<T, K>):

type PickOnly<T, K extends keyof T> =
    Pick<T, K> & { [P in Exclude<keyof T, K>]?: never };

The implementation intersects Pick<T, K> with a type that prohibits keys in T other than those in K. The type {[P in Exclude<keyof T, K>]?: never} uses the Exclude<T, U> utility type to get the non-K keys of T, and says that they must all be optional properties whose value type is the impossible never type. An optional property may be missing (or undefined depending on compiler options), but a never property cannot be present... that means these properties must always be missing (or undefined).

An example:

let x: PickOnly<{a: string, b: number, c: boolean}, "a" | "c">;
x = {a: "", c: true} // okay
x = {a: "", b: 123, c: true} // error!
// -------> ~
//Type 'number' is not assignable to type 'never'.
x = {a: ""}; // error! Property 'c' is missing

A value of type X must be an {a: number, c: boolean}, and furthermore cannot contain a b property at all.


So, your desired AtMostTwoKeys<T> is presumably a union of PickOnly<T, K> for every K consisting of every possible set of keys in T which at most two elements. For Article that looks like

| PickOnly<Article, never> // no keys
| PickOnly<Article, "name"> // only name
| PickOnly<Article, "id"> // only id
| PickOnly<Article, "content"> // only content
| PickOnly<Article, "name" | "id"> // name and id
| PickOnly<Article, "name" | "content"> // name and content
| PickOnly<Article, "id" | "content"> // id and content

So let's build AtMostTwoKeys<T>. The part with no keys is easy:

type AtMostTwoKeys<T> = (
    PickOnly<T, never> |    
)'

Now for one key... the easiest way to do this is via a distributive object type of the form coined in microsoft/TypeScript#47109. A type of the form {[K in KK]: F<K>}[KK], where you immediately index into a mapped type produces a union of F<K> for all K in the KK union.

So for one key, that looks like:

type AtMostTwoKeys<T> = (
    PickOnly<T, never> |
    { [K in keyof T]: PickOnly<T, K> }[keyof T]
);

Oh, but in keyof T makes the mapped type homomorphic, which will possibly introduce unwanted undefined values in the output for optional input properties, I will preemptively use the -? mapped type modifier to remove the optionality modifier from the mapping:

type AtMostTwoKeys<T> = (
    PickOnly<T, never> |
    { [K in keyof T]-?: PickOnly<T, K> }[keyof T]
);

For two keys, things are a bit trickier. We want to do two layers of distributive objects here. The first one iterates over every key K in keyof T, and the second one should introduce a new type parameter (say, L) to do the same. Then K | L will be every possible pair of keys from keyof T, as well as every single key (when K and L are the same). This double-counts distinct pairs, but that doesn't hurt anything:

type AtMostTwoKeys<T> = (
    PickOnly<T, never> |
    { [K in keyof T]-?: PickOnly<T, K> |
        { [L in keyof T]-?:
            PickOnly<T, K | L> }[keyof T]
    }[keyof T]
) 

That's basically it, but the resulting type will be expressed in terms of PickOnly:

type AMTKA = AtMostTwoKeys<Article>;
/* type AMTKA = PickOnly<Article, never> | PickOnly<Article, "name"> | 
  PickOnly<Article, "name" | "id"> | PickOnly<Article, "name" | "content"> | 
  PickOnly<Article, "id"> | PickOnly<Article, "id" | "content"> | \
  PickOnly<Article, "content"> */

Maybe that's fine. But usually I like to introduce a little helper to expand out such types into their actual properties:

type AtMostTwoKeys<T> = (
    PickOnly<T, never> |
    { [K in keyof T]-?: PickOnly<T, K> |
        { [L in keyof T]-?:
            PickOnly<T, K | L> }[keyof T]
    }[keyof T]
) extends infer O ? { [P in keyof O]: O[P] } : never

Let's try it again:

type AMTKA = AtMostTwoKeys<Article>;
/* type AMTKA = 
| {  name?: never;  id?: never;  content?: never; } // no keys
| {  name: string;  id?: never;  content?: never; } // only name
| {  name: string;  id: number;  content?: never; } // name and id
| {  name: string;  content?: string;  id?: never; } // name and content
| {  id: number;  name?: never;  content?: never; }  // only id
| {  id: number;  content?: string;  name?: never; } // id and content
| {  content?: string;  name?: never;  id?: never; } // only content
*/

Looks good!


And just to be sure, let's check your example use cases:

let article: AtMostTwoKeys<Article>;
article = { id: 23 } // okay
article = { name: "my article", id: 122 } // okay
article = { name: "my article", id: 23, content: "my content" } // error!

Success!

Playground link to code

  • Related