2

I'm trying to generate several types from an object, but have been completely stuck. Even the non-TS way (first generating more simple objects/arrays), and creating types from those, doesn't seem to work. I'm trying to avoid repeating the information stored in the object.

I really appreciate any help!

// Object that I want to generate types from
const PRODUCT_SECTIONS = {
  fruit: {
    name: "Delicious fruit",
    products: {
      banana: "Banana",
      apple: "Apple",
    },
  },
  vegetables: {
    name: "Fresh vegetables",
    products: {
      mixedGreens: "Mixed greens",
      lettuce: "Lettuce",
      cucumbers: "Cucumbers",
    },
  },
  soda: {
    name: "Quality soda",
    products: {
      coke: "Coke",
      sprite: "Sprite",
    },
  },
} as const;

// Desired type 1: all section names
type SECTION_NAME = "Delicious fruit" | "Fresh vegetables" | "Quality soda";

// Desired type 2: all products
type PRODUCT = "Banana" | "Apple" | "Mixed greens" | "Lettuce" | "Cucumbers" | "Coke" | "Sprite";

// Desired type 3: product selection allowing 1 product per section
type SELECTED_PRODUCTS_STATE = {
  fruit: "Banana" | "Apple";
  vegetables: "Mixed greens" | "Lettuce" | "Cucumbers";
  soda: "Coke" | "Sprite";
};
  • 1
    Does [this approach](https://tsplay.dev/NlxOBN) work for you? If so I can write up an answer; if not, please let me know what I'm missing. – jcalz Dec 19 '21 at 22:39
  • damn @jcalz you are a wizard. I didn't even know about AllValuesOf, I was trying to use `typeof PRODUCT_SECTIONS[keyof typeof PRODUCT_SECTIONS]["products"]` lmao – Daniel Dec 19 '21 at 22:50
  • Thanks @jcalz! You're amazing! That works great. Silly question, but how does that answer change the moment product becomes an array? I'm having a hard time modifying your answer to fit this [different object](https://tsplay.dev/WoJ7lm). – Vincent van der Meulen Dec 19 '21 at 22:58
  • Arrays have keys other than numeric indices, so you need something like [this](https://tsplay.dev/NVgEnm) instead. That seems out of scope for the question, right? Can I answer the original one as asked and if you have followup questions you could consider posting a new one? Would that work for you? – jcalz Dec 19 '21 at 23:02
  • @jcalz why the `T extends any` part? Shouldn't this always be true? why not just `T[keyof T]`. – Thomas Dec 19 '21 at 23:04
  • 2
    @Thomas I'm writing up an answer that will explain, but see [the documentation for *distributive conditional types*](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types). – jcalz Dec 19 '21 at 23:04
  • Thanks @jcalz! Both solutions work perfectly! Thank you! Excited to dig into this part of the TypeScript spec. And yes, my bad for asking about arrays! I'm still playing around with the object. If you post your comment as an answer, I can mark it as resolved! – Vincent van der Meulen Dec 19 '21 at 23:09

1 Answers1

1

I think your main stumbling block is that the operations you're performing are not distributing over union types. You want to get all the property value types of an object, which normally one could write as

type ValueOf<T> = T[keyof T];

But this often fails when T is a union of object types. The problem is that if you have an object type like {a: string} and index into it with its key type "a", you'll get its value type string. But if you have a union of object types like {a: string} | {b: number}, there's no key you can do to index into it safely. You can't index into it with "a" | "b". Since {a: string} has no known b property and {b: number} has no known a property, you can't index into the union with either key. And no, you don't get undefined if you index into {a: string} with a key that isn't a. Object types in TypeScript are extendible and may have extra properties. If you index into {a: string} with "b" you would get the any type out, not undefined. And the compiler complains anyway. When you ask the compiler what keyof ({a: string} | {b: number}) is, it says it's the never type; there are no keys. And so ValueOf<{a: string} | {b: number}> is also never:

type Sad = ValueOf<{ a: string } | { b: number }>
// type Sad = never

If you want to get string | number out of {a: string} | {b: number}, you conceptually want to break the union into pieces {a: string} and {b: number}, index into each with its known keys, and put them back together in a new union. That is, you want to distribute the ValueOf<T> operation over unions in T.

Luckily, TypeScript provides distributive conditional types with such behavior. If you have a generic type parameter T and check it against anything with a conditional type, like T extends unknown ? F<T> : X , the compiler will automatically distribute the F<T> operation over unions in T.

So we can turn ValueOf into AllValuesOf like this:

type AllValuesOf<T> = T extends any ? T[keyof T] : never;

Yes, this looks like a no-op. But T extends any ? T[keyof T] : never should be read "for each union member U in T, calculate U[keyof U], and unite them back into a new union".

Let's try it on our toy union type from above:

type Happy = AllValuesOf<{ a: string } | { b: number }>
// type Happy = string | number

Works like a charm.


Armed with AllValuesOf<T>, you can do indexed accesses and mapped types more easily on your object type now:

type ProductSections = typeof PRODUCT_SECTIONS;

type SECTION_NAME = AllValuesOf<ProductSections>["name"]
// type SECTION_NAME = "Delicious fruit" | "Fresh vegetables" | "Quality soda"

type PRODUCT = AllValuesOf<AllValuesOf<ProductSections>["products"]>
//"Banana" | "Apple" | "Mixed greens" | "Lettuce" | "Cucumbers" | "Coke" | "Sprite";

type SELECTED_PRODUCTS_STATE = { [K in keyof ProductSections]:
    AllValuesOf<ProductSections[K]["products"]> }
/* type SELECTED_PRODUCTS_STATE = {
    readonly fruit: "Banana" | "Apple";
    readonly vegetables: "Mixed greens" | "Lettuce" | "Cucumbers";
    readonly soda: "Coke" | "Sprite";
} */

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360