Home > OS >  Access nested property value via dynamic string literals of object's nested keys
Access nested property value via dynamic string literals of object's nested keys

Time:08-20

I would like to access a value (object) via the keys of a nested object.

Here is an example. I have an object that holds a dynamic number of car brands. Each brand has a dynamic number of models. Each model has an object with information about the car.

Please see this TS playground for a full example.

type CarInfo = {
  size: number;
  color: string;
};

type Cars = {
  // brand
  [brandKey: string]: {
    // model
    [modelKey: string]: {
      size: number;
      color: string;
    };
  };
};

const exampleCars: Cars = {
  mercedes: {
    c: { size: 123, color: "green" },
  },
};

In reality, I can't give this object a type, because I want to use the keys as string literals, which would be overwritten by the string union. So my cars look like this:

const CARS = {
  mercedes: {
    c: {
      size: 333,
      color: "red",
    },
    ...
  },
  ...
} as const;

type Cars = typeof CARS;

With that, I can "extract" the string literals and use them in a function to get one specific car info via the brand key and the model key:

function getCarInfo<
  TBrandKey extends keyof Cars,
  TModelKey extends keyof Cars[TBrandKey]
>(brandKey: TBrandKey, modelKey: TModelKey) {
  const brandEntry = CARS[brandKey];
  const carInfo = brandEntry[modelKey];

  return carInfo;
}

Please note that the there is no explicit return, but only an implicit return - more on that further down below.

This now works when calling the function with explicit string literals:

// works
const car1 = getCarInfo("mercedes", "c");
// works (fails as expected)
const car2 = getCarInfo("audi", "wrong-on-purpose");

// works
const color = car1.color

But instead of using fixed string literals, I want to give them through as props of a React component:

function Car<
  TBrandKey extends keyof Cars,
  TModelKey extends keyof Cars[TBrandKey]
>(props: { brand: TBrandKey; model: TModelKey }): JSX.Element {
  // works
  const carInfo = getCarInfo(props.brand, props.model);
  // does not work
  const carInfo2 = CARS[brand][model];

  return (
    <>
      {/* does not work */}
      <p>{carInfo.color}</p>
    </>
  );
}

// works
<Car brand="mercedes" model="c" />;

Here, the props of the component work as expected. They e.g. give autocomplete for the "model" when providing the "brand".

But I cannot get the car info.

  • When having an implicit return of getCarInfo, TS doesn't know that color exists on carInfo.
  • When giving getCarInfo an explicit return (CarInfo), the function itself does not work, as I whatever is found via brandEntry[modelKey] is not of type CarInfo in TypeScript's world.

What am I missing?

CodePudding user response:

Implementing generic functions in a way that TypeScript can check can be difficult. I generally recommend creating wider types for use in the implementation.

Here are a few options:

function Car<
  TBrandKey extends keyof Cars,
  TModelKey extends keyof Cars[TBrandKey] & string
>(props: { brand: TBrandKey; model: TModelKey }): JSX.Element {
  let CARS2: Record<string, Record<string, CarInfo>> = CARS;
  const car = CARS2[props.brand][props.model];
  return <p>{car.color}</p>;
}

This requires TModelKey to extend string in addition to the keyof because TS doesn't track that there's no symbol key on the object through the indirection of the first generic.

function Car<
  TBrandKey extends keyof Cars,
  TModelKey extends keyof Cars[TBrandKey]
>(props: { brand: TBrandKey; model: TModelKey }): JSX.Element {
  let CARS2: Record<string, Record<PropertyKey, CarInfo>> = CARS;
  const car = CARS2[props.brand][props.model];
  return <p>{car.color}</p>;
}

Instead of & string, you could also widen the CARS2 type even more.


TypeScript also supports overloaded functions, and a really useful trick occasionally is to define an overloaded function which only has one visible signature, with an easier to work with implementation signature.

This doesn't work great here, but is a useful trick to know:

function Car<
  TBrandKey extends keyof Cars,
  TModelKey extends keyof Cars[TBrandKey] & string
>(props: { brand: TBrandKey; model: TModelKey }): JSX.Element
function Car(props: { brand: keyof Cars, model: keyof Cars[keyof Cars] }): JSX.Element {
  const car = CARS[props.brand][props.model];
  //    ^? const car: never - because keyof on an intersection requires common keys
}
``
  • Related