Home > Enterprise >  Writing a function to calculate object properties whereas the object includes optional properties
Writing a function to calculate object properties whereas the object includes optional properties

Time:08-05

I have a record of data (object-literal), and I need to calculate the average of several of its properties. For example, consider that each record reflects a sample of dog breeds and their weights. We have one representative for each breed, but two specific breeds may not appear in the data:

// typescript

type dogSample = {
    // 5 core breeds that always appear
    bulldog: number,
    poodle: number,
    pug: number,
    chihuahua: number,
    boxer: number,
    // 2 additional that sometimes appear
    dalmatian?: number,
    rottweiler?: number, // rottweiler could appear only if dalmatian appears, but not vice versa
    // other irrelevant properties
    foo: string,
    bar: boolean,
    baz: number
    // and potentially hundreds of unrelated properties
}

My goal is to calculate the average of dogs weights. So I target those properties directly with three functions:

const calcMeanCoreFive = (obj: dogSample) =>
  (obj.bulldog   obj.poodle   obj.pug   obj.chihuahua   obj.boxer) / 5;

const calcMeanCoreFivePlusDalmatian = (obj: Required<dogSample>) => // using `Required` solves type problem
  (obj.bulldog  
    obj.poodle  
    obj.pug  
    obj.chihuahua  
    obj.boxer  
    obj.dalmatian) /
  6;

const calcMeanCoreFivePlusDalmatianPlusRottw = (obj: Required<dogSample>) =>
  (obj.bulldog  
    obj.poodle  
    obj.pug  
    obj.chihuahua  
    obj.boxer  
    obj.dalmatian  
    obj.rottweiler) /
  7;

Finally I want to add a wrapper around all three versions such that:

const calcMeanDogSample = (obj: dogSample, nBreeds: 5 | 6 | 7) =>
  nBreeds === 5
    ? calcMeanCoreFive(obj)
    : nBreeds === 6
    ? calcMeanCoreFivePlusDalmatian(obj)
//                                   ^
    : calcMeanCoreFivePlusDalmatianPlusRottw(obj);
//                                            ^
// argument of type 'dogSample' is not assignable to parameter of type 'Required<dogSample>'

My attempt to work around this

I tried to type calcMeanDogSample() with Required<dogSample>:

const calcMeanDogSample2 = (obj: Required<dogSample>, nBreeds: 5 | 6 | 7) =>
  nBreeds === 5
    ? calcMeanCoreFive(obj)
    : nBreeds === 6
    ? calcMeanCoreFivePlusDalmatian(obj)
    : calcMeanCoreFivePlusDalmatianPlusRottw(obj);

This solves the error as far as the function's definition. However, calling calcMeanDogSample2() and passing an object of type dogSample wouldn't work:

const someDogSample = {
  bulldog: 24,
  poodle: 33,
  pug: 21.3,
  chihuahua: 7,
  boxer: 24,
  dalmatian: 20,
  foo: "abcd",
  bar: false,
  baz: 123,
} as dogSample;

calcMeanDogSample2(someDogSample, 6);
//                     ^
// Argument of type 'dogSample' is not assignable to parameter of type // 'Required<dogSample>'.
//   Types of property 'dalmatian' are incompatible.
//     Type 'number | undefined' is not assignable to type 'number'.
//       Type 'undefined' is not assignable to type 'number'.ts(2345)

My question

Is there a way to type calcMeanDogSample() differently and solve this problem?


Reproducible code at TS playground

CodePudding user response:

The problem is that there's no correlation between nBreeds and the input obj in the type system.

However, TypeScript offers you a feature to inform the compiler that these properties do actually exist within your data if they can pass a conditional check: it's called a user-defined type guard and its return type is called a type predicate. Here's an example of how to use it with your data, and — when you do — you no longer need to provide the nBreeds argument!

See also the utility types Required<Type> and Pick<Type, Keys>

TS Playground

type DogSample = {
  bulldog: number;
  poodle: number;
  pug: number;
  chihuahua: number;
  boxer: number;
  dalmatian?: number;
  rottweiler?: number;
  // other irrelevant properties
  foo: string;
  bar: boolean;
  baz: number;
};

// This is called a user-defined type guard (and the return type is called a type predicate)
function sampleIncludesBreed <T extends DogSample, K extends keyof DogSample>(
  obj: T,
  breed: K,
): obj is T & Required<Pick<DogSample, K>> {
  return typeof (obj as Required<DogSample>)[breed] === 'number';
}

const calcMeanCoreFive = (obj: DogSample) =>
  (obj.bulldog   obj.poodle   obj.pug   obj.chihuahua   obj.boxer) / 5;

const calcMeanCoreFivePlusDalmatian = (obj: (
  DogSample
  & Required<Pick<DogSample, 'dalmatian'>>
)) =>
  (obj.bulldog  
    obj.poodle  
    obj.pug  
    obj.chihuahua  
    obj.boxer  
    obj.dalmatian) /
  6;

const calcMeanCoreFivePlusDalmatianPlusRottw = (obj: (
  DogSample
  & Required<Pick<DogSample, 'dalmatian'>>
  & Required<Pick<DogSample, 'rottweiler'>>
)) =>
  (obj.bulldog  
    obj.poodle  
    obj.pug  
    obj.chihuahua  
    obj.boxer  
    obj.dalmatian  
    obj.rottweiler) /
  7;

const calcMeanDogSample = (obj: DogSample) => {
  if (sampleIncludesBreed(obj, 'dalmatian')) {
    return sampleIncludesBreed(obj, 'rottweiler')
      ? calcMeanCoreFivePlusDalmatianPlusRottw(obj) /*
                                               ^^^
      This type is: (parameter) obj: DogSample & Required<Pick<DogSample, "dalmatian">> & Required<Pick<DogSample, "rottweiler">> */
      : calcMeanCoreFivePlusDalmatian(obj); /*
                                      ^^^
      This type is: (parameter) obj: DogSample & Required<Pick<DogSample, "dalmatian">> */
  }
  return calcMeanCoreFive(obj); /*
                          ^^^
  This type is: (parameter) obj: DogSample */
};

const someDogSample = {
  bulldog: 24,
  poodle: 33,
  pug: 21.3,
  chihuahua: 7,
  boxer: 24,
  dalmatian: 20,
  foo: "abcd",
  bar: false,
  baz: 123,
} as DogSample;

calcMeanDogSample(someDogSample);

Completely unrelated to your question, but related to dogs and data: You might like the API at dog.ceo.

CodePudding user response:

While one could implement your existing algorithm in a way the TypeScript compiler would recognize as type safe, my approach here would be to refactor the algorithm to something a bit more general:

const calcMeanDogSample = (obj: DogSample) => {
  let sum = obj.bulldog   obj.poodle   obj.pug   obj.chihuahua   obj.boxer;
  let nBreeds = 5;
  for (let v of [obj.dalmatian, obj.rottweiler]) {
    if (typeof v === "number") {
      sum  = v; nBreeds  ;
    }
  }
  return sum / nBreeds;
}

To get the mean, we need to divide the sum of the relevant properties by how many there are (nBreeds). We can do the "core five" without worry because they are required properties. For the optionanl dalmatian and rottweiler properties, we only want to contribute to sum and nBreeds if they are actually present.

You can verify that this behaves as expected:

const someDogSample = {
  bulldog: 24,
  poodle: 33,
  pug: 21.3,
  chihuahua: 7,
  boxer: 24,
  dalmatian: 20,
  foo: "abcd",
  bar: false,
  baz: 123,
};

console.log(calcMeanDogSample(someDogSample)); // 21.55

You could even refactor this more by transforming the list of keys to a list of possibly-defined numbers, filtering out the undefined values, and computing the mean of that:

const mean = (x: number[]) => x.reduce((a, v) => a   v, 0) / x.length;
const calcMeanDogSample2 = (obj: DogSample) => mean(
  (["bulldog", "poodle", "pug", "chihuahua", "boxer", "dalmatian", "rottweiler"] as const)
    .map(k => obj[k])
    .filter((v): v is number => typeof v === "number")
);
console.log(calcMeanDogSample2(someDogSample)); // 21.55

Playground link to code

  • Related