Search code examples
typescripttypes

Why does Typescript loses inference on nested objects when accessing them with dynamic property names?


Playground: https://tsplay.dev/m3Gqqm

I'm trying to access deeply nested properties of my theme object based on input parameters, so I can provide a nice dev experience by having autocomplete.

But Typescript is losing inference when trying to access the nested properties inside the function body, when I try to access them like this. Why is that? And how can I solve it?

const theme = {
  typography: {
    text: {
      small: {
        base: "",
        mobile: "",
        desktop: "",
      },
      medium: {
        base: "",
        mobile: "",
        desktop: "",
      },
    },
    heading: {
      h1: {
        base: "",
        mobile: "",
        desktop: "",
      },
      h2: {
        base: "",
        mobile: "",
        desktop: "",
      },
    },
  },
} as const;

type Theme = typeof theme;

function createVariant<T extends keyof Theme["typography"], S extends keyof Theme["typography"][T]>(
  variantName: T,
  variantValue: S,
) {
  const breakpoints = theme.typography[variantName][variantValue];
  const base = breakpoints.base; // error, why?
}

const result = createVariant("heading", "h2");

When I explicitly annotate the theme object like this, it does know the available nested properties:

type ThemeAnnotation = {
  typography: {
    text: {
      [index: string]: Record<"base" | "mobile" | "desktop", string>;
    },
    heading: {
      [index:string]: Record<"base" | "mobile" | "desktop", string>;
    }
  }
}

const theme:ThemeAnnotation = { ... } 

But that would prevent the autocompletion on the call site, because it can't infer the available variantValue.

Parameters, casting, generics. Didn't work. And I don't understand WHY this happens.


Solution

  • TypeScript is unable to take a type like Theme and automatically deduce higher order relationships, such as the fact that every sub-subproperty of typography has the same type. So when you use generic keys to index into such a sub-subproperty, TypeScript can't really see much about its type.

    In order for TypeScript to notice such relationships, you need to rewrite the type so that they are explicitly represented in specific ways, as described in microsoft/TypeScrip#47109. TypeScript can follow the logic of using generic indexed access types when you index into mapped types.

    We can rewrite the type of theme.typography to be such a mapped type (indeed, a nested mapped type):

    type Typography = {
        [K1 in keyof Theme["typography"]]: {
            [K2 in keyof (Theme["typography"][K1])]: {
                base: string,
                mobile: string,
                desktop: string
            }
        }
    };
    const typography: Typography = theme.typography;
    

    The assignment works because it needs to match the full structure of both types, which succeeds. (It's not trying to abstract over generic keys when matching these types).

    But now, even though the type of typography and the type of theme.typography are essentially the same, the former was written explicitly in terms of the relevant mapping. And so of we use typography inside createVariant(), it succeeds:

    function createVariant<
      K1 extends keyof Theme["typography"], 
      K2 extends keyof Theme["typography"][K1]
    >(
        variantName: K1,
        variantValue: K2,
    ) {
        const breakpoints = typography[variantName][variantValue];
        const base = breakpoints.base; // string, okay
    }
    

    The type of breakpoints is Typography[K1][K2], whose representation in the type system is just {base: string, mobile: string, desktop: string}, and so you can index into it with base.

    Playground link to code