1

Assuming I declare a Typescript interface to a very large, deep and complex object in a method like this:

interface DeepObject {
    prop1: string;
    prop2: {
        prop3: string;
        prop4: boolean;
        prop5: {
            prop6: Date;
        };
    };
}

I would like to write a function that sets a deep property on the object, while restricting the type of said property based on the accessor string. Here is an example of said function:

function setDeep(object: any, path: string, value: any): void {
    const tags = path.split('.');
    const length = tags.length - 1;
    for (let i = 0; i < length; i++) {
        if (object[tags[i]]) {
            object = object[tags[i]];
        } else {
            return;
        }
    }
    object[tags[length]] = value;
}

I could use the setDeep function like this:

const myDeepObject: DeepObject = {
    prop1: '',
    prop2: {
        prop3: '',
        prop4: false,
        prop5: {
            prop6: new Date()
        }
    }
};

setDeep(myDeepObject, 'prop2.prop5.prop6', new Date(2002));

The problem in this example is the any notations. I would like the setDeep function to only accept an interface of DeepObject for the first argument, and extrapolate from that interface and the string provided in argument 2, what the type should be for argument 3. Instead of just allowing anything to be set for argument 3. Is this possible?

user2867288
  • 1,979
  • 16
  • 20
  • Does this answer your question? [Typescript string dot notation of nested object](https://stackoverflow.com/questions/47057649/typescript-string-dot-notation-of-nested-object) – pilchard Feb 06 '23 at 23:34
  • I would highly recommend reading this answer: https://stackoverflow.com/a/6394168/13762301 and its cautions/caveats about using a dot delimited string at all. That combined with the wrestling required to make types fit should point you towards looking for a cleaner approach altogether. ie. In your example you've already typed the delimited string so `myDeepObject.prop2.prop5.prop6 = new Date(2002);` is both shorter and implicitly protected by TS inference. – pilchard Feb 07 '23 at 00:11
  • I'm aware, thing is, I'm working with data stores (Redux/Vuex/etc) where it is considered bad practice to set things in the store directly. At the same time, with 100s of fields in a large form spread out across multiple components, it is also impractical to create 100s of setters. – user2867288 Feb 07 '23 at 00:19
  • @pilchard that question is very similar but not exactly a duplicate, there appear to be nuances between setting vs getting. – user2867288 Feb 07 '23 at 00:24
  • Agreed, that answer also isn't able to parse generics due to the depth of evaluation. – pilchard Feb 07 '23 at 00:25

2 Answers2

2

You can get the value of any dot notation with GetValuePath and if a element don't exists in the object you get a never type.

So, in setDeep in a case who is invalid path will throw a TS error. (NOTE: not a JavaScript error, if you call the function with any will be throw a uncaught error)

type GetValuePath<O extends Record<string | number, any>, P extends string> = P extends "" // ends path?
    ? O
    : P extends `${infer Head}.${infer Tail}` // has another deep property?
        ? Tail extends "" // Invalid tail?
            ? never
            : GetValuePath<O[Head], Tail>
        : P extends keyof O  // exists in the object?
            ? O[P]
            : never;

function setDeep<T extends Object, P extends string>(object: T, path: P, value: GetValuePath<T, P>): void {
    // A empty case
    if (path === "") {
        object = value as GetValuePath<T, "">;
        return;
    }

    // Get tags and lastTag
    const tags = path.split('.');
    const lastTag = tags.pop() as string; // should always an string by the split

    // Iterate over all tags
    let objPointer: Record<string, any> = object;
    for (const tag of tags) {
        objPointer = objPointer[tag];
    }

    // Set the last tag
    objPointer[lastTag] = value;
}

interface DeepObject {
    prop1: string;
    prop2: {
        prop3: string;
        prop4: boolean;
        prop5: {
            prop6: Date;
        };
    };
    prop7: [boolean, string],
    prop8?: boolean,
}

const deepObject: DeepObject = {
    prop1: "",
    prop2: {
        prop3: "",
        prop4: false,
        prop5: {
            prop6: new Date(0),
        },
    },
    prop7: [false, ""],
}

setDeep(deepObject, "prop1", "test1");
setDeep(deepObject, "prop2.prop4", true);
setDeep(deepObject, "prop2.prop5.prop6", new Date("2023-02-06T01:00:00.000Z"));
setDeep(deepObject, "prop7.1", "an string");
setDeep(deepObject, "prop8", true);

console.log(JSON.stringify(deepObject, null, 2));

type TEST = GetValuePath<DeepObject, "prop2.prop5.prop6000">; // never
setDeep(deepObject, "prop2.prop5.", true); // Argument of type 'boolean' is not assignable to parameter of type 'never'.ts(2345)
setDeep(deepObject, "prop10", true); // Argument of type 'boolean' is not assignable to parameter of type 'never'.

output:

{
  "prop1": "test1",
  "prop2": {
    "prop3": "",
    "prop4": true,
    "prop5": {
      "prop6": "2023-02-06T01:00:00.000Z"
    }
  },
  "prop7": [
    false,
    "an string"
  ],
  "prop8": true
}

TSPlayground

pilchard
  • 12,414
  • 5
  • 11
  • 23
jtwalters
  • 1,024
  • 7
  • 25
  • I add `Tail extends ""` to handle cases like `prop2.prop5.` (should fail) – jtwalters Feb 07 '23 at 00:11
  • Adding `Tail extends ''` appears to break it for other simple cases like `prop2.prop5` – user2867288 Feb 07 '23 at 00:29
  • 1
    You can remove it but only if you expect to set properties as objects, ie `setDeep(deepObject, "prop2.prop5", {prop6: new Date()});` rather than `setDeep(deepObject, "prop2.prop5.prop6", new Date());` [playground](https://tsplay.dev/wXOnVW) – pilchard Feb 07 '23 at 00:32
  • 1
    Ah I see the edit. Misunderstood where to add that. – user2867288 Feb 07 '23 at 00:35
1

Yes, it is!

type LookupFieldByPath<
  Object extends Record<string, any>,
  Path extends string
> = Path extends ""
  ? Object
  : Path extends `${infer Head}.${infer Tail}`
  ? LookupFieldByPath<Object[Head], Tail>
  : Object[Path];

function setDeep<Object, Path extends string>(
  object: Object,
  path: Path,
  value: LookupFieldByPath<Object, Path>
): void {
  //...
}
Toastrackenigma
  • 7,604
  • 4
  • 45
  • 55
  • Looks like it could work, but it throws some errors on sandbox https://tsplay.dev/mbEZ4N – Konrad Feb 06 '23 at 23:19
  • Yeah, TypeScript never likes things that refer to plain objects, especially when generics are involved. You can work around it with a little cast: https://tsplay.dev/mAJnRW – Toastrackenigma Feb 06 '23 at 23:30