I am trying to write a Generic type that accepts a parameter and its possible values. It will allow the parameter to be set with one of its values OR one of the values can be set to true and everything else must be never | undefined
.
Example of intended usage:
type AnimalExample = NewType<"animal","mammal"|"reptile"|"bird">
const example1:AnimalExample = {animal: "bird"} // VALID
const example2:AnimalExample = {animal: "bird", bird: true} // INVALID
const example3:AnimalExample = {bird: true} // VALID
const example4:AnimalExample = {bird: true, reptile: true} // INVALID
My code so far:
type NewType<Field extends string, Params extends string> =
| (Record<Field, Params> & Partial<Record<Params, never>>)
| (Partial<Record<Field, undefined>> & Partial<Record<Params, boolean>>); // DISTRIBUTION NEEDS TO HAPPEN HERE
The intersection with Partial<Record<Params, boolean>>
is where I'm stuck. I'm not sure how to distribute over the Params
type. The following yielded the intended object union but I don't know how to combine it with the above NewType
type.
type AnimalTypes = "mammal"|"reptile"|"bird"
type Test<T> = T extends AnimalTypes
? { [Self in T]: true } & Partial<
Record<Exclude<AnimalTypes, T>, never>
>
: never;
This yields:
(
| { mammal: true; reptile?: never; bird?: never }
| { mammal?: never; reptile: true; bird?: never }
| { mammal?: never; reptile?: never; bird: true }
)
I would like the final result to be something like (if not exactly):
(
| { animal: "mammal"|"reptile"|"bird"; mammal?: never; reptile?: never; bird?: never }
| { mammal: true; reptile?: never; bird?: never; animal?: never }
| { mammal?: never; reptile: true; bird?: never; animal?: never }
| { mammal?: never; reptile?: never; bird: true; animal?: never }
)
My inclination here would be to implement NewType<F, P>
in terms of a utility type OneProp<T>
, where OneProp<T>
produces a union of types for each property key K
of T
, where each union member is an object that contains only the K
property, while the other ones are prohibited (and since you can't explicitly prohibit a property, you have to make it an optional property of the impossible never
type, so {a?: never}
is approximately the same as "a
is forbidden").
So OneProp<{a: string, b: number, c: boolean}>
would be equivalent to {a: string, b?: never, c?: never} | {a?: never, b: number, c?: never} | {a?: never, b?: never, c: boolean}
. I'll implement OneProp
later.
Once we implement OneProp
, then NewType<F, P>
will be the result of OneProp
acting on an object with an F
-keyed property of type P
, and also a property for each key in P
of type true
. That is:
type NewType<F extends string, P extends string> =
OneProp<Record<F, P> & Record<P, true>>
where the Record<K, V>
utility type just means "property keys of type K
and property values of type V
", and the intersection joins two object types together into something with all the properties from both. Let's make sure that this does something reasonable for your example.
If F
is "a"
and P
is "m" | "r" | "b"
, then Record<F, P> & Record<P, true>
is Record<"a", "m" | "r" | "b"> & Record<"m" | "r" | "b", true>
, which is equivalent to the single object type {a: "m" | "r" | "b", m: true, r: true, b: true}
. Hopefully it makes sense that NewType<F, P>
should be OneProp<{a: "m" | "r" | "b", m: true, r: true, b: true}>
, so that it either has the F
-keyed property, or one of the P
-keyed properties.
Okay, so then we only have to implement OneProp<T>
. There are multiple ways to do it, but one approach is to make a mapped type over keyof T
and then index into that mapped type with keyof T
, thus producing the union of the properties for each K
in keyof T
:
type OneProp<T> = {
[K in keyof T]: Pick<T, K> & Partial<Record<Exclude<keyof T, K>, never>>
}[keyof T]
In this case, the property we're putting for each union member is Pick<T, K> & Partial<Record<Exclude<keyof T, K>, never>>
. That means it has the K
property (using the Pick<T, K>
utility type) and then for all keys other than K
, (that is, using the Exclude
utility type to say Exclude<keyof T, K>
) the property is an optional one (due to Partial
) of type never
.
So if T
is {a: string, b: number, c: boolean}
and K
is "a"
, then it becomes Pick<{a: string, b: number, c: boolean}, "a"> & Partial<Record<Exclude<"a" | "b" | "c", "a">, never>>
, which looks like {a: string} & Partial<Record<"b" | "c", never>>
or {a: string} & {b?: never, c?: never}
or {a: string, b?: never, c?: never}
. And since K
iterates over each property, you get a mapped type like {a: {a: string, b?: never, c?: never}, b: {a?: never, b: number, c?: never}, c: {a?: never, b?: never, c: boolean}
and then index into it with "a" | "b" | "c"
to get the desired union {a: string, b?: never, c?: never} | {a?: never, b: number, c?: never} | {a?: never, b?: never, c: boolean}
... except it's going to be written in terms of Partial
, Exclude
, Record
, and Pick
.
And that's the whole thing. We can check your example:
type AnimalExample = NewType<"animal", "mammal" | "reptile" | "bird">;
const example1: AnimalExample = { animal: "bird" } // okay
const example2: AnimalExample = { animal: "bird", bird: true } // error
const example3: AnimalExample = { bird: true } // okay
const example4: AnimalExample = { bird: true, reptile: true } // error
And just for fun we can write a Pretty
utility type to split the union into pieces and combine each one into a single object, just to see what AnimalExample
looks like without Record
, Pick
, etc:
type Pretty<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type AE = Pretty<AnimalExample>;
/* type AE = {
mammal: true;
reptile?: undefined;
bird?: undefined;
animal?: undefined;
} | {
reptile: true;
mammal?: undefined;
bird?: undefined;
animal?: undefined;
} | {
bird: true;
mammal?: undefined;
reptile?: undefined;
animal?: undefined;
} | {
animal: "mammal" | "reptile" | "bird";
mammal?: undefined;
reptile?: undefined;
bird?: undefined;
} */
Looks good (note that for --strict
, optional properties always get undefined
in their domain, so mammal?: undefined
and mammal?: never
are the same type).