typescripttypescript-generics

TypeScript Generic Distribution Utility Type


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 }
)

Solution

  • 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).

    Playground link to code