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
}
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,
}
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.
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:
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.