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!