1

I'm working with a double-nested JSON object in typescript, and have been banging my head against a wall when I try to narrow a string down to a key for the first nested string. Narrowing a string to a key for the main object is trivial, and if there were only a few nested objects, this could be handled with an if-else or switch reliably. However, there are about 40 1st layer nested objects, each with discriminating unions. I could right out a long switch test case for each specific object, but this would be tedious and probably end up repeating a lot of code, so I wanted to find a more elegant way.

This is an example of the object:

const jsonObject = {
  cow: {
    milk: {
      chemicalState: "liquid",
      value: 10,
      description: "for putting on cereal"
    },
    cheese: {
      chemicalState: "solid",
      value: 25,
      description: "for putting on sandwiches"
    },
  },
  pig: {
    bacon: {
      chemicalState: "solid",
      value: 100,
      description: "for putting on everything"
    },
    pork: {
      chemicalState: "solid",
      value: 50,
      description: "for dinner"
    },
  },
  //... and thirty more double nested objects from here
}

In this example, each animal will have a completely different set of products with no overlap with each other. The goal is to write a function that can pull the description entry out of jsonObject given the animal and product:

const getDescription = (animal: string, product: string):string => {
  return jsonObject[animal][product].description
}

Getting the first key (animal) narrowed is trivial with a user-defined type guard. However, jsonObject[animal] does not narrow to one specific product object, but rather a union of all possible product objects (the aforementioned discriminated union). At the point after jsonObject[animal] is evaluated, there is only one animalProduct object type being used, but the typescript compiler can't know which, so instead of returning the single animalProduct object it reurns a union of the 32 possible animalProducts, a lot to handle in a switch. I'm also trying to avoid using the bail out of "as" for now as well.

Ideally, there would be a way to write a general/generic user-defined type guard to narrow for the second key just like the first; one that only allows the specific keys for the selected animal through (so for jsonObject[cow], the key type should be milk|cheese). That may not be possible or practical however, so any and all suggestions and tips are welcome (still very new to Typescript and learning the ins and outs)!


I've made a bunch of attempts to narrow both the keys and then the discriminated union first, then the keys. My closest attempt has probably been with this user-defined generic (inspired by this post, which unfortunately didn't address my issue as it doesn't eliminate the need for a massive switch statement):

const isAnimalProduct = <T extends string>(s:string, o: Record<T,any>): s is T => {
  return s in o;
}

This gets close, but gives you type Record<"milk" | "cheese" | "bacon" | "pork", any> rather than Record<"milk" | "cheese", any> | Record<"bacon" | "pork", any>, and doesn't appear to narrow as necessary, but I'm still admittedly wrapping my head around it.

Here's the Playground link to code with the full model of the code and some more attempts.


I've also reviewed this response and this response on related threads, but I haven't been able to get a similar result as both work off a hard-coded value, and in my case both values are variables, and this you still get a discriminated union in the end (but please let me know if I am mistaken).

Also, as far as I can tell, this issue has nothing to do with JSON, as I've set "resolveJsonModule": true, and typescript seems to have no issues importing it. As you can see from the playground link, the issue persists even with a pure object.


Edit: The inputs animal and product in getDescription come from user inputs (in particular, two comboboxes). I have configured the second combobox for product to accept only proper inputs based on the animal combobox state (if the user selects cow in the first combobox, the second will only display milk and cheese). However, the behavior I was trying to guard against is when the user goes back and changes the first animal combobox (i.e. to pig), causing a key mismatch (e.g. pig, milk).

JCalz proposed that I

widen jsonObject to a doubly nested index signature and test for undefined.

My understanding is this should guard for this condition, since if Pig and Milk were supplied to the index signature, this would be properly detected as undefined. I'll implement it fully now to confirm!

New playground link with updated context

  • ① Why `item` and `chemicalState`? Is one of those a typo? ② Given your data I'd say you really really don't want to be torturing yourself like this unless you actually care about particular key names. I'd just widen `jsonObject` to a doubly nested index signature and test for `undefined`, as shown [here](https://tsplay.dev/NllqrN). If that *doesn't* meet your needs, please [edit] the code example to motivate why. If it does, I could write up an answer explaining. – jcalz Apr 25 '23 at 20:19
  • Thanks for the help Jcalz! ① Yes, I was trying to come up with a simple example, and this got left in the transition. fixed it! ② "I'd say you really really don't want to be torturing yourself like this" - I am a masochist after all. Honestly been forcing myself to be as precise as possible to force myself to learn typescript rather than use and everywhere. "just widen jsonObject" - I hadn't considered this, and believe that it is a solution in this case. I'll add a bit more to my question to confirm I'm understanding the answer correctly. And apologies, hit enter instead of shift-enter. – QuantumNoisemaker Apr 26 '23 at 03:05
  • So... does that mean I *should* write up my suggestion as an answer? – jcalz Apr 26 '23 at 03:06
  • Okay, uh, I now see that your previous comment has been edited and, you're editing the question to... talk about my suggestion working maybe? When you get a chance, please just comment to let me know if you would like me to write up an answer explaining my suggestion , or if I should run away screaming ‍♂️. Either way is fine with me. – jcalz Apr 26 '23 at 03:25
  • 1
    Sorry hahaha! Yeah, I hit enter instead of shift-enter when typing up the earlier comment, and posted it much earlier than I meant to lol (though comments don't care about /n at all anyways, so woe is me). I edited my question to provide a bit more of the motivation behind the type guards, and also my reasoning as for why I believe your answer addresses my issue (seems to work in my full code base as well). Please let me know if my understanding is correct, or feel free to run away screaming, I'd completely understand at this point! – QuantumNoisemaker Apr 26 '23 at 03:39
  • No problem, I'll write up an answer when I get a chance. (It's my bedtime now so probably in ~8 or ~9 hours) – jcalz Apr 26 '23 at 03:42
  • Absolutely no worries! Thanks so much for your help with this JCalz, Your suggestion has already opened up my approach. I am also updated the playground right now to supply the full user input and Combobox context. – QuantumNoisemaker Apr 26 '23 at 03:54

1 Answers1

1

The compiler isn't very good about tracking correlated union types (see microsoft/TypeScript#30581) so your original approach will bring you nothing but headaches. Often it's better to write things with generics instead of unions, but in your case the structure of jsonObject can be represented much more simply with index signatures:

const j: Dictionary<Dictionary<Product>> = jsonObject;

interface Dictionary<T> {
  [k: string]: T | undefined
};

interface Product {
  chemicalState: string;
  value: number;
  description: string;
}

I've reassigned jsonObject (which I assume came from an external imported file so you can't change its type) to j, whose type is Dictionary<Dictionary<Product>>. That assignment succeeds, so the compiler sees them as compatible. A Dictionary<T> is just an object type with unknown keys, whose values are either T or undefined. So j's type is an object with unknown keys, whose values are either undefined or another object with unknown keys, whose values are either undefined or a Product as defined above. All you have to do now is to check for undefined when you index into j, as shown here:

const getDescription = (animal: string, product: string): string => {
  return j[animal]?.[product]?.description ?? "not valid";
}

This is using the optional chaining (?.) operator and the nullish coalescing (??) operator to handle undefineds. If j[animal][product].description exists then that's what getDescription returns. Otherwise, j[animal]?.[product]?.description will be undefined (instead of an indexing error), and undefined ?? "not valid", a.k.a, "not valid" will be returned.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Super clear answer and write up, thanks so much JCalz, it works like a charm now! One follow up on `jsonObject (which I assume came from an external imported file so you can't change its type)`. In my case, the JSON object is imported into the file, but it is locally stored and immutable; The JSON is actually pulled from a google sheets spreadsheet, which the program then uses as a local database for converting user input into numerical values. Since I have full control over the form of the JSON object, is there a better way I could format it, or a better method for using it with typescript? – QuantumNoisemaker Apr 27 '23 at 03:18
  • I'd need to see the code importing it before I could be sure but I don't think there's anything bad about this method. – jcalz Apr 27 '23 at 03:28
  • Awesome, the code is just: `import lvls from './json/CharLvl.json'` for example, with `"resolveJsonModule": true,` in the tsconfig. Seemed to work well, so I decided to go with it. – QuantumNoisemaker Apr 27 '23 at 04:04
  • I'd say you should just keep it as-is. There are ways to tell the compiler to treat the imported json as a particular type, but it's actually less type-safe to do that. You could write `import _lvls from './json/CharLvl1.json'` and then `const lvls: Dictionary> = _lvls` (or whatever the right type is) and then you can keep using `lvls` but now it has a more useful type. – jcalz Apr 27 '23 at 13:14
  • Great, that matches my thoughts as well. I actually had to do just that with one JSON file I imported, as it contained a lot of optional values (i.e. some objects had values other objects didn't), and the compiler doesn't seem to handle those properly without explicitly casting the JSON as an object type with optionals (though I could also rework that code to use your above suggestion, rather than re-casting it globally). I'll keep both options in mind. Once again, thanks for you help JCalz, really helped clarified best practices for me. – QuantumNoisemaker Apr 28 '23 at 21:38