Search code examples
typescriptfunctionoption-typerequired-field

Writing a function to calculate object properties whereas the object includes optional properties


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


Solution

  • 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