Home > Mobile >  Typing a generic function which retrieves data from objects and (deep) nested objects in Typescript
Typing a generic function which retrieves data from objects and (deep) nested objects in Typescript

Time:12-05

I have created a generic function using ES6 in the past and want to reuse it in a new React Typescript project.

The function is called getNestedValuesFromObject and retrieves data from objects and (deep) nested objects.

const getValueFromNestedObject = (objectPath, baseObject) => {
    if (typeof objectPath !== 'string') return undefined;
    return objectPath
        .split('.')
        .reduce((object, key) => (object && object[key] !== 'undefined' ? object[key] : undefined),
            baseObject);
};

export default getValueFromNestedObject;

And can be used as follows:

const testObject = {
  exampleString: 'string',
  nestedObject: {
   exampleNumber: 1,
   nestedNestedObject: {
     exampleArray: [1, 2, 3],
   }
 }
}

const arrayFromNestedNestedObject = getValueFromNestedObject('nestedObject.nestedNestedObject', testObject);

Can you help type this function? Obviously objectPath is string and baseObject can maybe be a generic also the return type.

CodePudding user response:

The problem with this kind of function is that it's inherently not type-safe. You're providing the object path as a string, assuming that string is valid for the given object (and there's no way to avoid that being an assumption), and returning whatever value you end up on. FWIW, I'd suggest not using it. Instead, use optional chaining (and when needed, nullish coalescing):

const arrayFromNestedNestedObject = testObject.nestedObject?.nestedNestedObject;
// or
const arrayFromNestedNestedObject = testObject.nestedObject?.nestedNestedObject ?? defaultValue;

That way, you get type checking.

But if you want to use the function:

  • As you say, path will be string.

  • baseObject will probably have to be really wide open, like {[key: string]: any}.

  • The return value is problematic. It could be anything.

For the return value, you basically have two options:

  1. The function return value could be the dreaded any.

  2. The function could accept a required type parameter saying what end type you're expecting.

They both have upsides and downsides. The upside of any is that, well, you can assign it to anything. So if you have:

let num: number = getValueFromNestedObject("the.number", obj);

it'll work. The downside is that it'll work even if the value that the function finds isn't a number, so you won't get a compilation error, but the runtime behavior will (probably) be wrong.

The required generic parameter has much the same problem: If you think the result will be number and do:

let num = getValueFromNestedObject<number>("the.number", obj);

...and it isn't, again you get no compilation error but the runtime behavior isn't good.

Just for completeness, here's the function with any as the return type:

const getValueFromNestedObject = (objectPath: string, baseObject: {[key: string]: any}): any => {
    if (typeof objectPath !== 'string') return undefined;
    return objectPath
        .split('.')
        .reduce((object, key) => (object && object[key] !== 'undefined' ? object[key] : undefined),
            baseObject);
};

Playground link

To mitigate the issue with the return type, you might consider having multiple functions:

  • getStringFromNestedObject
  • getNumberFromNestedObject
  • getBooleanFromNestedObject
  • getObjectFromNestedObject

...where they're all wrappers for the above, but with specific return types:

const getStringFromNestedObject = (objectPath: string, baseObject: {[key: string]: any}): string => {
    const result = getValueFromNestedObject(objectPath, baseObject);
    if (typeof result !== "string") {
        throw new Error(`Invalid assertion, ${objectPath} didn't result in a string`);
    }
    return result;
};

It could be overloaded to allow undefined:

function getStringFromNestedObject(objectPath: string, baseObject: {[key: string]: any}, allowUndefined: true): string | undefined;
function getStringFromNestedObject(objectPath: string, baseObject: {[key: string]: any}, allowUndefined: false): string;
function getStringFromNestedObject(objectPath: string, baseObject: {[key: string]: any}, allowUndefined = false): string | undefined {
    const result = getValueFromNestedObject(objectPath, baseObject);
    if (typeof result === "undefined" && allowUndefined) {
            return result;
    }
    if (typeof result !== "string") {
        throw new Error(`Invalid assertion, ${objectPath} didn't result in a string`);
    }
    return result;
}

Etc.

But again, I suggest using optional chaining and/or nullish coalescing so you get compile-time errors for type mismatches.

  • Related