How to Create Dynamic Object Key in Typescript Without Widening to { [key: string]: V }
?
I am exploring ways to create a Typescript function that can generate an object with a dynamic key, where the name of the key is provided in the function signature, without the return type being automatically widened to { [key: string]: V }
.
For example, I want to be able to call:
createObject('template', { items: [1, 2, 3] })
and receive an object like
{ template: { items: [1, 2, 3] } }
, instead of just getting an object with generic string keys.
A Fun Way to Make a Rainbow!
Before dismissing this as impossible, let's create a rainbow:
type Color = 'red' | 'orange' | 'yellow';
const color1: Color = 'red';
const color2: Color = 'orange';
Let's assign two dynamic properties on a rainbow object:
const rainbow = {
[color1]: { description: 'First color in rainbow' },
[color2]: { description: 'Second color in rainbow' }
};
Now check the type of our creation:
type rainbowType = typeof rainbow;
The compiler recognizes the correct property names and provides the following explicit type. This allows autocomplete for any object typed as typeof rainbow
:
type rainbowType = {
red: {
description: string;
};
orange: {
description: string;
};
}
Well done, Typescript!
We've confirmed that we can create objects with dynamic properties without always having them typed as { [key: string]: V }
. However, implementing this logic into a method might complicate things...
Initial Approach
Let's start by creating a method and trying to trick the compiler into giving us the desired result.
function createObject<K extends 'item' | 'type' | 'template', T>(keyName: K, details: T)
{
return {
[keyName]: details
};
}
This function takes a dynamic key name (constrained to one of three options) and assigns a value to it.
const templateObject = createObject('template', { something: true });
This will generate an object at runtime with a value of
{ template: { something: true } }
.
However, the type has been widened as follows:
typeof templateObject = {
[x: string]: {
something: boolean;
};
}
Solution using Conditional Types
An easy way to resolve this issue is by using conditional types if you have only a few possible key names:
function createObject<K extends 'item' | 'type' | 'template', T extends {}>(keyName: K, details: T)
{
const newObject: K extends 'item' ? { item: T } :
K extends 'type' ? { type: T } :
K extends 'template' ? { template: T } : never = <any>{ [keyName]: details };
return newObject;
}
This approach utilizes a conditional type to ensure that the return type includes an explicitly named key corresponding to the provided value.
If we invoke
createObject('item', { color: 'red' })
, the resulting object's type will be:
{
item: {
color: string;
};
}
This strategy requires updating the method whenever a new key name is needed, which may not always be feasible.
Typically, this would mark the end of the discussion!
Exploring Advanced Typescript Features
Dissatisfied with the existing solutions, I pondered over whether newer features like Template literals could offer a better solution.
You can achieve some sophisticated outcomes, such as the following (though this is unrelated to our current challenge):
type Animal = 'dog' | 'cat' | 'mouse';
type AnimalColor = 'brown' | 'white' | 'black';
function createAnimal<A extends Animal,
C extends AnimalColor>(animal: A, color: C): `${C}-${A}`
{
return <any> (color + '-' + animal);
}
The magic lies in the template literal ${C}-${A}
. Let's create an animal...
const brownDog = createAnimal('dog', 'brown');;
// the type brownDogType actually represents 'brown-dog' !!
type brownDogType = typeof brownDog;
Unfortunately, this merely results in creating a more refined "string type". After extensive trials, I couldn't achieve further progress using template literals.
Key Remapping via `as` Feature...
Is it possible to remap a key and have the compiler preserve the new key name in the returned type?
Surprisingly, it works, albeit with some complexities:
Key remapping is restricted to use within a mapped type, which isn't directly applicable here. But what if we created a mapped type using a dummy type with only one key?
type DummyTypeWithOneKey = { _dummyProperty: any };
// defining the key name as a type, not a string
type KEY_TYPE = 'template';
// Utilizing key remapping feature
type MappedType = { [K in DummyTypeWithOneKey as `${ KEY_TYPE }`]: any };
The key transformation via as ${ KEY_TYPE }`
is the secret sauce here.
The compiler indicates:
type MappedType = {
template: any;
}
This type can be returned from a function without being converted to a standard string-indexed object.
Introducing StrongKey<K, V>
What next? Let's develop a helper type to abstract away the cumbersome dummy type.
Note that auto-complete suggests _dummyProperty
so let's rename it to dynamicKey
.
export type StrongKey<K extends string, V> = { [P in keyof { dynamicKey: any } as `${ K }`]: V };
function createObject<K extends 'item' | 'type' | 'template', T extends {}>(keyName: K, details: T)
{
return { [keyName]: details } as StrongKey<K, T>;
}
We should now benefit from autocomplete when working with the resulting object like in the following example:
const obj = createObject('type', { something: true });
Support for String Key Names
Remarkably, we can extend this concept to fully accommodate pure string key names while maintaining compatibility.
export type StrongKey<K extends string, V> = { [P in keyof { dynamicKey: any } as `${ K }`]: V };
// creates an 'enhanced' object using a known key name
function createObject<K extends string, T extends {}>(keyName: K, details: T)
{
return { [keyName]: details } as StrongKey<K, T>;
}
Merging Generated Objects
If we cannot merge the generated objects effectively, all efforts go to waste. Luckily, the following approach proves successful:
const dynamicObject = {...createObject('colors', { colorTable: ['red', 'orange', 'yellow' ] }),
...createObject('animals', { animalTable: ['dog', 'cat', 'mouse' ] }) };
The resulting typeof dynamicObject
is:
{
animals: {
animalTable: string[];
};
colors: {
colorTable: string[];
};
}
At times, Typescript surprises us by being smarter than we thought. Is this the best approach available? Should I propose a suggestion or are there other possibilities that I missed? If you learned something or have insights to share, please do so! Could I possibly be the first person to attempt this?