Search code examples
typescript

Add another value to derived type in TypeScript


I have these interface

interface CommonAnimalProps {
    common: {
        size: number, 
        weight: number,
        origin: string,
    }
}

interface DogProps extends CommonAnimalProps {
    name: string
}

interface CatProps extends CommonAnimalProps {
    furious: boolean
}

interface BirdProps extends CommonAnimalProps {
    canFly: boolean
}

export enum AnimalType {
    dog = "dog",
    cat = "cat",
    bird = "bird",
}

export interface AnimalTypeToAnimalPropsMap {
    [AnimalType.dog]: (DogProps)[],
    [AnimalType.cat]: (CatProps)[],
    [AnimalType.bird]: (BirdProps)[]
}

export type AllAnimalValues<T> = T[keyof T]
export type AllAnimalTypesArray = AllAnimalValues<AnimalTypeToAnimalPropsMap>
export type AllAnimalTypes = AllAnimalValues<AnimalTypeToAnimalPropsMap>[0]
export type AllAnimalTypesArrayDiscriminating = AllAnimalTypes[]

This AllAnimalTypesArrayDiscriminating results in an array looking like this

const foo: AllAnimalTypesArrayDiscriminating = [
    {
        name: "Bello",
        common: {
            size: 10,
            weight: 25,
            origin: "Knowhwere"

        },
    },
    {
        furious: true,
        common: {
            size: 3,
            weight: 5,
            origin: "Anywhere"

        },
    },
    {
        canFly: false,
        common: {
            size: 39,
            weight: 50,
            origin: "Somewhere"

        },
    },    
]

Is it possible to use AllAnimalTypesArrayDiscriminating, and create a new type/interface from it (AllAnimalTypesArrayDiscriminatingModified), where each element in the resulting array bar has another prop (color: string) added to CommonAnimalProps so that the result looks like this

const bar: AllAnimalTypesArrayDiscriminatingModified = [
  {
    name: "Bello",
    common: {
      size: 10,
      weight: 25,
      origin: "Knowhwere",
      color: 'red',

    },
  },
  {
    furious: true,
    common: {
      size: 3,
      weight: 5,
      origin: "Anywhere",
      color: 'brown',

    }
  }, {
    canFly: false,
    common: {
      size: 39,
      weight: 50,
      origin: "Somewhere",
      color: 'white',

    },
  }
];

Note, I cannot modify the original CommonAnimalProps to add color as an optional parameter. I also cannot edit DogProps, CatProps, BirdProps. I am looking for a way that uses AllAnimalTypesArrayDiscriminating (or any of the other types which I did not exclude) to add this property. Is there a way to do this?


Solution

  • By far the easiest approach is to just use an intersection to add the appropriately nested member to AllAnimalTypes:

    type AllAnimalTypesModified = AllAnimalTypes & { common: { color: string } };
    type AllAnimalTypesArrayDiscriminatingModified = AllAnimalTypesModified[];
    

    That gives you the desired behavior in your example:

    const bar: AllAnimalTypesArrayDiscriminatingModified = [
      {
        name: "Bello",
        common: { size: 10, weight: 25, origin: "Knowhwere", color: 'red' }
      },
      {
        furious: true,
        common: { size: 3, weight: 5, origin: "Anywhere", color: 'brown' }
      },
      {
        canFly: false,
        common: { size: 39, weight: 50, origin: "Somewhere", color: 'white' }
      }
    ];
    

    But note that this doesn't actually put that member down inside each member of the AllAnimalTypes union. All it's doing is saying that, in addition to being an AllAnimalTypes value, it should also have a string-valued property at its common property.

    So while, for example, DogProps & { common: { color: string } } is more or less equivalent to {name: string, common: { size: number, weight: number, origin: string, color: string } }, it's not represented that way, and depending on what type manipulations you perform, that difference could be noticeable.

    If you really wanted to compute that type so that each member of the resulting union has a nested property that lives directly in the same object type as the rest of the common properties, you need to do more type juggling. Here's one way to do it:

    type AllAnimalTypesModified = AddCommonColor<AllAnimalTypes>;
    

    where AddCommonColor is a utility type like

    type AddCommonColor<T extends CommonAnimalProps> =
      T extends unknown ? {
        [K in keyof T]: K extends "common" ? T[K] & { color: string } : T[K]
      } : never;
    

    This is a distributive conditional type. The type T extends unknown ? ⋯T⋯ : never looks like a no-op, since all types T extend unknown, but the point of this type isn't to perform a check, but to distribute the ⋯T⋯ piece over unions in T. That is, we want AddCommonColor<X | Y | Z> to evaluate to AddCommonColor<X> | AddCommonColor<Y> | AddCommonColor<Z>.

    The actual type we're computing for a given union member is { [K in keyof T]: K extends "common" ? T[K] & { color: string } : T[K] }, a mapped type that leaves all properties alone except for common, to which {color: string} is intersected.

    If we now inspect AllAnimalTypesModified we get

    type AllAnimalTypesModified = {
      name: string;
      common: {
          size: number;
          weight: number;
          origin: string;
      } & {
          color: string;
      };
    } | {
      furious: boolean;
      common: {
          size: number;
          weight: number;
          origin: string;
      } & {
          color: string;
      };
    } | {
      canFly: boolean;
      common: {
          size: number;
          weight: number;
          origin: string;
      } & {
          color: string;
      };
    } 
    

    where the color property lives down inside the common property of each union member. Even this might not be the way you'd like to see it; it's possible to get rid of that intersection like this:

    type AddCommonColor<T extends CommonAnimalProps> =
      T extends unknown ? {
        [K in keyof T]: K extends "common" ? {
          [P in "color" | keyof T[K]]: P extends keyof T[K] ? T[K][P] : string
        } : T[K]
      } : never;
    

    which produces

    type AllAnimalTypesModified = {
        name: string;
        common: {
            color: string;
            size: number;
            weight: number;
            origin: string;
        };
    } | {
        furious: boolean;
        common: {
            color: string;
            size: number;
            weight: number;
            origin: string;
        };
    } | {
        canFly: boolean;
        common: {
            color: string;
            size: number;
            weight: number;
            origin: string;
        };
    }
    

    but that might be overkill for your use case.

    Playground link to code