Home > Mobile >  Dynamic typings based on value in object
Dynamic typings based on value in object

Time:09-14

We have an API response we are looking to improve our typings for, I'll simplify for this example. Say the API returns something like this where there is a value type and an associated value.

Note: Unfortunately this is an existing API and we are just trying to work with what it provides in the current shape.

{
  meta: {
    id: string;
    name: string;
    valueType: 'cat' | 'bird'
  }
  value: { paws: number } | { wings: number }
}

With this response there is actually a direct relationship between the typings valueType and value. So to try and improve the typing support for the API response I've tried something like this.

type MetaType = {
  id: string;
  name: string;
};

type APIResponseType =
  | {
      meta: MetaType & {
        valueType: 'cat';
      };
      value: { paws: number };
    }
  | {
      meta: MetaType & {
        valueType: 'bird';
      };
      value: { wings: number };
    };

const sampleResponse: APIResponseType[] = [
  {
    meta: {
      id: '123',
      name: 'Garfield',
      valueType: 'cat',
    },
    value: { paws: 4 },
  },
  {
    meta: {
      id: '123',
      name: 'Woody',
      valueType: 'bird',
    },
    value: { wings: 2 },
  },
];

This works great so far, where the typings ensure the proper shape of the data when statically typed like they are above. If you tried changing the valueType from cat to bird in the first object, it would result in a type error.

Now the problem comes up when I try to interact with this data in a dynamic way and try to use a type guard.

sampleResponse.forEach((animal) => {
  if (animal.meta.valueType === 'cat') {
    console.log(animal.value.paws);
  }
});

// TypeScript see's the type for value as this:
(property) value: { paws: number; } | { wings: number; }

So in this case although the typing initially seems to work, it fails to function as expected with type guards which still means we need to do casting.

So is there a way to achieve this functionality through another method of typing?

CodePudding user response:

Typescript seems to have a hard time narrowing the parent object, from the inner object. I wish there was an elegant way to handle that, but as @kelly points out in their answer, you will probably need type guards here. (This fact is a surprise to me, but it appears to be true.)

But to prevent you having a different function for every possible type, you can use a generic function to do the check for you:

function checkResponseType<
  T extends APIResponseType['meta']['valueType']
>(
  res: APIResponseType,
  type: T
): res is Extract<APIResponseType, { meta: { valueType: T }}> {
  return res.meta.valueType === type
}

Here we take the type to check as T. And we use Extract to filter down the union to only the matches.

Then usage feels, and looks, pretty nice.

sampleResponse.forEach((animal) => {
  if (checkResponseType(animal, 'cat')) {
    console.log(animal.value.paws); // fine
  }
});

See playground

CodePudding user response:

Using type predicates, it's possible to narrow down the type yourself:

function isCat(data: APIResponseType): data is Extract<APIResponseType, { meta: { valueType: "cat" } }> {
    return data.meta.valueType === "cat";
}

sampleResponse.forEach((animal) => {
  if (isCat(animal)) {
    console.log(animal.value.paws);
  }
});

Playground

TS doesn't let you use paws because it's not guaranteed that animal doesn't change between when you check it and when you use it (TS doesn't do that kind of static analysis).

  • Related