Search code examples
typescripttypescript-genericstypescript-types

Correct typing when creating a collection where the elements are made up of different instantiations of the same generic function


I want to to create an object where the values are all different instantiations of the same generic. I then want to dynamically key into the object to fetch and use the value, all in a type-safe way.

The object is meant to contain several 100 elements, so it would be unwieldy to manually type and would instead like the types to be inferred.

Here is an example of what I'm trying to accomplish. It's a contrived example where I've attempted to strip out anything not related to this particular problem.

type Items = {
  a: string;
  b: number;
  c: number;
  d: string;
};

const createTemplate = <T extends Record<string, string | number>>(
  opts: {
    pick: (items: Items) => T;
    use: (props: T) => string;
  },
) => opts;

const FirstTemplate = createTemplate({
  pick: (items) => ({ foo: items.a, bar: items.b, }),
  use: (props) => `${props.foo} and ${props.bar}`,
});

const SecondTemplate = createTemplate({
  pick: (items) => ({ foo: items.c, bar: items.d, }),
  use: (props) => `${props.foo} or ${props.bar}`,
});

const TEMPLATE_MAP = {
  One: FirstTemplate,
  Two: SecondTemplate,
}

const evaluateTemplate = (items: Items, key: string) => {
  const template = TEMPLATE_MAP[key];
  //     ^? const template: any
  if (template === undefined) {
    throw new Error("Template not found")
  }
  const picked = template.pick(items)
  const evaluated = template.use(picked)
  return evaluated
}

TS Playground

Unfortunately, the typescript lsp surfaces an error on the line where I try to key into TEMPLATE_MAP.

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ One: { pick: (items: Items) => { foo: string; bar: number; }; use: (props: { foo: string; bar: number; }) => string; }; Two: { pick: (items: Items) => { foo: number; bar: string; }; use: (props: { foo: number; bar: string; }) => string; }; }'.
  No index signature with a parameter of type 'string' was found on type '{ One: { pick: (items: Items) => { foo: string; bar: number; }; use: (props: { foo: string; bar: number; }) => string; }; Two: { pick: (items: Items) => { foo: number; bar: string; }; use: (props: { foo: number; bar: string; }) => string; }; }'.(7053)

I attempted to fix it by explicitly declaring the type the type of TEMPLATE_MAP, with the purpose of declaring the type of the keys of the objects as strings instead of literals.

// Added the type to `TEMPLATE_MAP`
const TEMPLATE_MAP: Record<string, ReturnType<typeof createTemplate>> = {
  One: FirstTemplate,
  Two: SecondTemplate,
}

TS Playground

But now I get a typing error on the lines where I try to declare the elements of the array. The error looks like

Type '{ pick: (items: Items) => { foo: string; bar: number; }; use: (props: { foo: string; bar: number; }) => string; }' is not assignable to type '{ pick: (items: Items) => Record<string, string | number>; use: (props: Record<string, string | number>) => string; }'.
  Types of property 'use' are incompatible.
    Type '(props: { foo: string; bar: number; }) => string' is not assignable to type '(props: Record<string, string | number>) => string'.
      Types of parameters 'props' and 'props' are incompatible.
        Type 'Record<string, string | number>' is missing the following properties from type '{ foo: string; bar: number; }': foo, bar(2322)

My expectation would be that it would widen the types of values in the object (which are objects where the keys are const and values are a specific shape) to Record<string, string | number>—a wider type.


Solution

  • First, there's no principled way for key to be string and have it work. If you give TemplateMap a string index signature then there's no very useful type for you to specify as the property. You end up with a union of pick/use pairs, and unions of functions end up only being callable with intersections of arguments, in order to be safe.

    The right thing to do is let TemplateMap be inferred:

    const TemplateMap = {
      One: FirstTemplate,
      Two: SecondTemplate,
    }
    

    and then you want key to be one of those keys, either "One" or "Two" (i.e., keyof typeof TemplateMap):

    const evaluateTemplate = (items: Items, key: "One" | "Two") => {
      const template = TemplateMap[key];
      const picked = template.pick(items)
      const evaluated = template.use(picked); // error!
      /* Argument of type 
        '{ foo: string; bar: number; } | { foo: number; bar: string; }' 
        is not assignable to parameter of type 
        '{ foo: string; bar: number; } & { foo: number; bar: string; }'. */
      return evaluated
    }
    

    But it still doesn't work, for the same reason. The type of template is a union of pick/use pairs, and as soon as you try to call it, the compiler gets angry. TypeScript doesn't realize that template.pick and template.use are correlated with each other. It treats them as independent unions of functions, and thus you end up where template.pick(items) produces a union of arguments and template.use is a union of functions, and you can't safely pass the former to the latter. This lack of tracking of correlation across multiple utterances of the same union-typed value (in this case template) is described in microsoft/TypeScript#30581.


    The recommended approach is to refactor away from unions and towards generics that are constrained to the relevant union. All operations need to be written in terms of:

    • a "base" key-value mapping representing the most fundamental and simple type relationship your code abstracts over;
    • mapped types over that base key-value mapping; and
    • generic indexes into those types.

    For your example it looks like:

    interface MyIO {
        One: {
            foo: string;
            bar: number;
        };
        Two: {
            foo: number;
            bar: string;
        };
    }
    
    type TemplateMap = { [K in keyof MyIO]: 
      { 
        pick: (items: Items) => MyIO[K], 
        use: (props: MyIO[K]) => string 
      }};
    
    const TemplateMap: TemplateMap = {
      One: FirstTemplate,
      Two: SecondTemplate,
    }
    
    const evaluateTemplate = <K extends keyof TemplateMap>(items: Items, key: K) => {
      const template = TemplateMap[key];
      const picked = template.pick(items)
      const evaluated = template.use(picked) // okay
      return evaluated
    }
    

    Here the "base" key-value mapping is MyIO, which shows the relationship between One/Two and the type returned by pick and consumed by use. Then TemplateMap is a mapped type over MyIO, and the TemplateMap value is annotated as having the TemplateMap type. Finally evaluateTemplate is a generic function over the type K of key. Now template is of type TemplateMap[K]. TypeScript therefore sees picked as being of type MyIO[K] and template.use as being of type (props: MyIO[K]) => string. So instead of a union of arguments and a union of functions, we have a single generic argument and a single function that takes the same generic argument. The call succeeds.


    That's mostly the answer to your question, except that this formulation makes you specify the type of MyIO and the contents of TemplateMap separately, which is redundant. It would be better for MyIO to be computed from the TemplateMap variable. This can be done as long as we rename the TemplateMap variable out of the way and then assign it later:

    const _TemplateMap = {
      One: FirstTemplate,
      Two: SecondTemplate,
    }
    
    type _TM = typeof _TemplateMap;
    type MyIO = { [K in keyof _TM]: ReturnType<_TM[K]["pick"]> }
    
    const TemplateMap: TemplateMap = _TemplateMap;
    

    Here I've named the original value _TemplateMap. I then compute MyIO from typeof _TemplateMap; you can verify that it's the same as before. Then we declare TemplateMap, annotate it as TemplateMap, and assign _TemplateMap to it. Everything after that stays the same.

    So now you can just add new members to _TemplateMap and, as long as they satisfy the same general contract as the others, your code will continue to work.

    Playground link to code