Home > Software engineering >  Typescript create a type from generic nested keys
Typescript create a type from generic nested keys

Time:01-06

I'm teaching myself how to use typescript generics by recreating a CSS class builder function similar to the CVA library. However, I'm stuck with how to extract keys out of the generic. I have the below code (simplified for brevity):

type TConfig = {
    base?: string;
    variants?: Record<string, Record<string, string>>;
};

type TInput<T extends TConfig> = Record<keyof T["variants"], string>;

export const classBuilder = <T extends TConfig>(config: T) => {
    ///...
    return (input: TInput<T>) => {
        // return ...
    };
};

const builder = classBuilder({
    base: "text-white rounded-sm",
    variants: {
        color: {
            red: "bg-red",
            blue: "bg-blue",
        },
        size: {
            sm: "text-sm",
            lg: "text-lg",
        },
    },
});

// this should then take an object using the variant keys provided in the config
const classes = builder({ color: "blue", size: "sm" })

I'm expecting the type of the return function to be:

const builder: (input: {
    color: string;
    size: string;
}) => string

However when hovering over the builder function in VS code I get the following:

const builder: (input: TInput<{
    base: string;
    variants: {
        size: {
            sm: string;
            md: string;
            lg: string;
        };
    };
}>) => string

This seems to be outputting the generic without actually extracting the variants and I can't for the life of me work out why.

If anyone has any guidance on this it would be much appreciated.

CodePudding user response:

To be clear, the type TInput<{ base: string; variants: { color: { red: string; blue: string; }; size: { sm: string; lg: string; }; }; }> is the same type as {color: string; size: string}, in much the same way that (5×3) 2 is the same number as 17. Your problem isn't that the compiler isn't computing the types properly; it's just that it isn't displaying the types the way you expect via IntelliSense's Quick info.

TypeScript uses a bunch of heuristic rules to determine how to display a type. There's a tradeoff between preserving type aliases for display and eliminating them for display, and there's not a "correct" way to do it that would make everyone happy, especially because sometimes two people will use structurally analogous code and want different things. As mentioned in microsoft/TypeScript#50941:

Quick info does not have a contractual obligation to show one particular form or the other, nor to always show one form or the other (and indeed it's possible to write types that can't be shown in certain forms).

So if you don't like TInput<...> being displayed, you can tweak the usage and definition to try to get different behavior, but keep in mind that this behavior is just heuristic.


By far the easiest thing to do is not to introduce any type aliases you don't want to see in your display. Replace TInput<T> with its definition. Replace the use of the Record<K, V> utility type with its definition also, if you don't want that in your display. It's not unreasonable for the compiler to assume that you defined TInput<T> because you want to see it, and in any case, the compiler is certainly not going to use something that it doesn't even know about.

That gives you this:

export const classBuilder = <T extends TConfig>(config: T) => {
  ///...
  return (input: { [P in keyof T["variants"]]: string }) => {
    // return ...
  };
};

which gives you the desired output:

const builder = classBuilder({
  base: "text-white rounded-sm",
  variants: {
    color: {
      red: "bg-red",
      blue: "bg-blue",
    },
    size: {
      sm: "text-sm",
      lg: "text-lg",
    },
  },
});

/* const builder: (input: {
  color: string;
  size: string;
}) => void */

If you want to keep the TInput<T> definition then you can start tweaking the definition. Some of this is covered in How can I see the full expanded contract of a Typescript type? and in microsoft/TypeScript#28508, but one easy way to do it is to add a "no-op" union or intersection to the type which doesn't change the actual output type, but encourages the compiler to evaluate the type more completely. For example:

type TInput<T extends TConfig> =
  { [P in keyof T["variants"]]: string } & {};    
// or { [P in keyof T["variants"]]: string } & unknown;
// or { [P in keyof T["variants"]]: string } | never;

Here we still aren't using Record (because we don't want to see that), but now we are intersecting with the empty object type {}. Any object type intersected with {} will reduced to the object type. The same thing would happen if you intersect with the unknown type or union with the never type. The compiler often prefers to eagerly collapse an intersections/unions like this rather than leave it as as TInput<{...}> & {}. So you get the behavior you're looking for here also:

/* const builder: (input: {
  color: string;
  size: string;
}) => void */

Playground link to code

  • Related