Search code examples
javascripttypescriptfunctionoption-type

Building a custom function for creating a new object as a subset of object properties of any depth: how to deal with missing entries?


Based on this answer, I've built my own custom function that subsets an object, to keep only some of its properties. Problem is, the code breaks if I point to a property that doesn't exist.

Essentially, I'm looking to mimic the functionality of the optional chaining operator (?.) , but in the context of my own custom function.

My current function implementation

The function accepts two inputs:

  1. The object to subset
  2. An object that specifies the path of each property we want to keep, as well as a name for the key of this value in the new object.

The function's output is a new object, which is a subset of the original. By design, this is an immutable implementation.

// typescript
const is = (t: any, T: any) => t?.constructor === T;
const getProp = <T>(obj: any, key: string): T => obj[key] ?? null;
const getValue = <T>(pathToVal: string[], origData: T): T => pathToVal.reduce(getProp, origData);

const select = (data: Object, paths: Record<string, string[]>) =>
    !is(paths, Object) // if `paths` is not object, fail now
        ? new Error('path supplied not an object')
        : // else do what we want
          Object.entries(paths).reduce(
              (res: object, [key, path]: [string, string[]]) => Object.assign(res, { [key]: getValue(path, data) }),
              {}
          );

Here is an example how to call select().

const earthData = {
  distanceFromSun: 149280000,
  continents: {
    asia: {
      area: 44579000,
      population: 4560667108,
      countries: { japan: { temperature: 62.5 } },
    },
    africa: { area: 30370000, population: 1275920972 },
    europe: { area: 10180000, population: 746419440 },
    america: { area: 42549000, population: 964920000 },
    australia: { area: 7690000, population: 25925600 },
    antarctica: { area: 14200000, population: 5000 },
  },
};


const earthDataSubset = select(earthData, {
  distanceFromSun: ['distanceFromSun'],
  asiaPop: ['continents', 'asia', 'population'],
  americaArea: ['continents', 'america', 'area'],
  japanTemp: ['continents', 'asia', 'countries', 'japan', 'temperature'],
});

console.log(earthDataSubset)
// { distanceFromSun: 149280000,
//   asiaPop: 4560667108,
//   americaArea: 42549000,
//   japanTemp: 62.5 }

The problem

Requesting to subset a property that doesn't exist results in an error, instead of simply returning null for that non-existing path.

const earthDataSubset2 = select(earthData, {
  distanceFromSun: ["distanceFromSun"],
  asiaPop: ["continents", "asia", "population"],
  americaArea: ["continents", "america", "area"],
  japanTemp: ["continents", "asia", "countries", "japan", "temperature"],
  foo: ["bar", "baz"] // <~~~~ this is the addition that breaks the code
});

console.log(earthDataSubset2)
// Cannot read properties of null (reading 'baz') 

But what I expect to get is:

// expected output

console.log(earthDataSubset2)
// { distanceFromSun: 149280000,
//   asiaPop: 4560667108,
//   americaArea: 42549000,
//   japanTemp: 62.5,
//   foo: null
}

Also please note that if the non-existing property is at the end of the path, we do get null as expected.

const earthDataSubset3 = select(earthData, {
  distanceFromSun: ["distanceFromSun"],
  asiaPop: ["continents", "asia", "population"],
  americaArea: ["continents", "america", "area"],
  japanTemp: ["continents", "asia", "countries", "japan", "temperature"],
  foo: ["continents", "asia", "countries", "japan", "temperature", "baz"] // <~~~~ this returns null as expected
});

console.log(earthDataSubset3)
// { distanceFromSun: 149280000,
//   asiaPop: 4560667108,
//   americaArea: 42549000,
//   japanTemp: 62.5,
//   foo: null }

Bottom line, my question is how I could alter the current definition of select() to account for situations where a non-existing property could be at any position in that specified path, not only at the path's end.


Solution

  • It is your getProp() that's the issue – it errors when obj is null in obj[key], since you cannot access properties on null. This happens when a top level object property does not exist on the object being selected. We can use optional chaining to work around this:

    const getProp = <T>(obj: any, key: string): T => obj?.[key] || null;
    

    The ?. operator is like the . chaining operator, except that instead of causing an error if a reference is nullish (null or undefined), the expression short-circuits with a return value of undefined.

    const is = (t, T) => t.constructor === T;
    const getProp = (obj, key) => obj?.[key] || null;
    const getValue = (pathToVal, origData) => pathToVal.reduce(getProp, origData);
    
    const select = (data, paths) =>
        !is(paths, Object)
            ? new Error('path supplied not an object')
            : // else do what we want
              Object.entries(paths).reduce(
                  (res, [key, path]) => Object.assign(res, { [key]: getValue(path, data) }),
                  {}
              );
              
    const earthData = {
      distanceFromSun: 149280000,
      continents: {
        asia: {
          area: 44579000,
          population: 4560667108,
          countries: { japan: { temperature: 62.5 } },
        },
        africa: { area: 30370000, population: 1275920972 },
        europe: { area: 10180000, population: 746419440 },
        america: { area: 42549000, population: 964920000 },
        australia: { area: 7690000, population: 25925600 },
        antarctica: { area: 14200000, population: 5000 },
      },
    };
    
    const earthDataSubset2 = select(earthData, {
      distanceFromSun: ["distanceFromSun"],
      asiaPop: ["continents", "asia", "population"],
      americaArea: ["continents", "america", "area"],
      japanTemp: ["continents", "asia", "countries", "japan", "temperature"],
      foo: ["bar", "baz"] // <~~~~ this is the addition that breaks the code
    });
    
    console.log(earthDataSubset2)