Search code examples
typescripttypescript-genericsdiscriminated-unionnested-objecttype-narrowing

How to narrow a variable from a string to a key of a nested object in a large object with Discriminated unions


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


Solution

  • 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