skip to Main Content

Given the example below:

const brands = { mercedes: "mercedes", audi: "audi" } as const;
type Brands = keyof typeof brands;

type MercedesFactory = {
    propA: string;
}

type AudiFactory = {
    propB: string;
}

type CarProps<TFactory> = {
    color: string;
    hp: number;
    factory: TFactory
}

type Mercedes = {
    c180: CarProps<MercedesFactory>,
    c220: CarProps<MercedesFactory>,
}

type Audi = {
    a3: CarProps<AudiFactory>,
    tt: CarProps<AudiFactory>,
}

const mercedes: Mercedes = {
    c180:
    {
        color: "blue",
        hp: 120,
        factory: { propA: "xx" }
    },
    c220: {
        color: "black",
        hp: 150,
        factory: { propA: "yy" }
    }
}

const audi: Audi = {
    a3:
    {
        color: "blue",
        hp: 120,
        factory: { propB: "zz" }
    },
    tt: {
        color: "red",
        hp: 150,
        factory: { propB: "aa" }
    }
}

const allCars: Record<Brands, Mercedes | Audi> = {
    mercedes,
    audi,
}

I want the generic function bellow to return Fabric information for all the blue cars, depends on the brand passed as parameter:

const getAllBlueCars = (brand: Brands) => {
    const carBrand = allCars[brand]; // Mercedes | Audi
    // object car is not typed correctly (any), and function returns any[]
    return Object.values(carBrand).reduce((acc, car) => {
        if (car.color === "blue") {
            return [...acc, car.factory];
        }
        return acc;
    }, [])
    // I'd like car is typed correctly and function returns an MercedesFactory[] or AudiFactory[], depends on which brand was passed like the example bellow
}

But as you can see below, it’s returning any;

const allAudiBlueCarsFabric = getAllBlueCars("audi"); // any

This works fine, car is typed correctly and it returns correct type:

const mercedesFactories = Object.values(mercedes).reduce<MercedesFactory[]>((acc, car) => (car.color === "blue" ? [...acc, car.factory] : acc), []);

// another simple example, using filter
const carMercedesFilterExample = Object.values(mercedes).filter((car) => car.color === "blue");

Typescript Playground Link


This is the Chat GPT Solution. The returned value is correct, but car is still typed wrong:

const getAllBlueCars2 = <TBrand extends Brands>(brand: TBrand) => {
    const carBrand = allCars[brand];

    // it works, but car is still typed as any
    return Object.values(carBrand).reduce<((TBrand extends "mercedes" ? MercedesFactory : AudiFactory)[])>((acc, car) => {
        if (car.color === "blue") {
            return [...acc, car.factory];
        }
        return acc;
    }, []);
};

const allAudiBlueCarsFabric2 = getAllBlueCars2("audi"); // AudiFactory[]

3

Answers


  1. Chosen as BEST ANSWER

    After some hours digging into, I also found myself an acceptable solution.

    type Factory<T extends Brands> = T extends "mercedes" ? MercedesFactory : T extends "audi" ? AudiFactory : never;
    
    const getAllBlueCars2 = <T extends Brands>(brand: T) => {
      const carBrand = allCars[brand];
      const cars: CarProps<Factory<T>>[] = Object.values(carBrand)
    
      return Object.values(cars).reduce<Factory<T>[]>((acc, car) => {
        if (car.color === "blue") {
          return [...acc, car.factory];
        }
        return acc;
      }, [])
    }
    
    const allAudiBlueCarsFabric2 = getAllBlueCars2("mercedes");
    

  2. This is the closest solution I could find.

    // Modified the ChatGPT version
    const getAllBlueCars2 = <T extends Brands>(brand: T) => {
      type ArrType = CarProps<MercedesFactory | AudiFactory>
      const carBrand = allCars[brand];
      const cars: ArrType[] = Object.values(carBrand)
    
      return cars.reduce<ArrType['factory'][]>((acc, car) => {
        if (car.color === "blue") {
          return [...acc, car.factory];
        }
        return acc;
      }, []) as (T extends "mercedes" ? MercedesFactory : AudiFactory)[];
    };
    
    Login or Signup to reply.
  3. Your allCars value doesn’t have a strongly typed relationship between the keys and values:

    const allCars: Record<Brands, Mercedes | Audi> = {
        mercedes,
        audi,
    }
    

    Since you annotated as Record<Brands, Mercedes | Audi>, then it could possibly be a value like {mercedes: audi, audi: mercedes}. If you want better typings, you’ll need to give it a stronger type like {mercedes: Mercedes, audi: Audi}, which is what the compiler infers if you don’t annotate at all:

    const allCars = {
        mercedes,
        audi,
    };
    // const allCars: {mercedes: Mercedes; audi: Audi};
    type AllCars = typeof allCars;
    

    Still, even if you do that, the compiler can’t follow the logic:

    const getAllBlueCars = <K extends Brands>(brand: K) => {
        const carBrand = allCars[brand]; //  AllCars[K]
        const carPropsArray = Object.values(carBrand); // any[]
        return carPropsArray.reduce((acc, car) => {
            if (car.color === "blue") {
                return [...acc, car.factory];
            }
            return acc;
        }, []);
    }
    
    const allAudiBlueCarsFabric = getAllBlueCars("audi"); // any
    

    The carPropsArray value is inferred as having type any[]. The compiler doesn’t understand that Object.values(AllCars[K]) should also be generic in K. The correlation between brand and carPropsArray has been lost.

    Of course you can just annotate carPropsArray as any array type you want, since the any type is loose enough to permit even crazy things (e.g., const carPropsArray: number[] = Object.values(carBrand); would be accepted), but you’re not getting the compiler to compute the correct types for you.


    The only way I know of to get it the correlation between brand and carPropsArray to be computed by the compiler is to refactor your types using the technique described in microsoft/TypeScript#47109. Essentially you need to express carBrands as a mapped type over some underlying base type, and then index into it with the generic type K to get another generic type out.

    The way I usually do this is to rename the original object out of the way

    const _allCars = {
        mercedes,
        audi,
    }    
    type _AllCars = typeof _allCars;
    

    and then use it to compute the related base type:

    type CarFactories = { [K in Brands]: 
      _AllCars[K][keyof _AllCars[K]] extends CarProps<infer F> ? F : never 
    }
    
    /* type CarFactories = {
      mercedes: MercedesFactory;
      audi: AudiFactory;
    } */
    

    and then rebuild the original type as a mapped type over the base type:

    type AllCars = { [K in Brands]: Record<string, CarProps<CarFactories[K]>> };
    

    and assign the original object to the new value annotated with the new type:

    const allCars: AllCars = _allCars;
    

    On the face of it this doesn’t look like anything much has changed, but now the compiler sees an explicit relationship between AllCars[K] and `Record<string, CarProps<CarFactories[K]>>.

    And the implementation of getAllBlueCars suddenly becomes a lot more strongly typed:

    const getAllBlueCars = <K extends Brands>(brand: K) => {
    
        const carBrand = allCars[brand]; //  AllCars[K]
        const carPropsArray = Object.values(carBrand); // CarProps<CarFactories[K]>[]
        return carPropsArray.reduce<CarFactories[K][]>((acc, car) => {
            if (car.color === "blue") {
                return [...acc, car.factory];
            }
            return acc;
        }, [])
    }
    

    Here, carPropsArray is seen as type CarProps<CarFactories[K]>[], and thus when we reduce() that array with an initial value of type CarFactories[K][], the compiler understands that the implementation is safe, and that the return value is of that generic type. So the type of getAllBlueCars is <K extends Brands>(brand: K) => CarFactories[K][]

    Which means when we call it, we get the strongly-typed output you expect:

    const allAudiBlueCarsFabric = getAllBlueCars("audi"); // AudiFactory[]
    

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search