Search code examples
typescriptmapped-types

Dynamically create objects in Typescript using dynamic keys, without widening type to { [key: string]: T }


Dynamic object key without widening to { [key: string]: V } ?

I'm trying to create a Typescript function to generate an object with a dynamic key whose name is provided in the function signature, without the return type getting widened to { [key: string]: V }.

So I want to call:

createObject('template', { items: [1, 2, 3] })

and get back an object { template: { items: [1, 2, 3] } } that isn't just an object with string keys.

Quick detour - Lets make a rainbow!

Before you tell me this is impossible, let's make a rainbow:

type Color = 'red' | 'orange' | 'yellow';
 
const color1: Color = 'red';
const color2: Color = 'orange';    

Create two dynamic properties on a rainbow object:

const rainbow = {
    [color1]: { description: 'First color in rainbow' },
    [color2]: { description: 'Second color in rainbow' }
};

Now let's look at the type of what we created:

 type rainbowType = typeof rainbow;

The compiler is smart enough to know that the first property must be named 'red' and the second is called 'orange', giving the following type. This enables us to use autocomplete for any object typed as typeof rainbow:

type rainbowType = {
    red: {
        description: string;
    };
    orange: {
        description: string;
    };
}

Thanks Typescript!

So now we've confirmed we can create an object with dynamic properties and it won't always end up typed as { [key: string]: V }. Of course putting this into a method is going to complicate things...

First attempt

So now for the first attempt to create our method and see if we can trick the compiler into giving the right result.

function createObject<K extends 'item' | 'type' | 'template', T>(keyName: K, details: T)
{
    return {
        [keyName]: details
    };
}

This is a function to which I'll provide a dynamic key name (constrained to one of three choices) and assign a value to it.

const templateObject = createObject('template', { something: true });

This will return an object with a runtime value of { template: { something: true } }.

Unfortunately the type of this has been widened as you may have feared:

typeof templateObject = {
    [x: string]: {
        something: boolean;
    };
}

Solution using conditional types

Now there is an easy way to fix this if you only have 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 uses a conditional type to force the return type to have an explicitly named key the same as the value passed in.

If I call createObject('item', { color: 'red' }) I'll get back an object with a type of:

{
    item: {
        color: string;
    };
}

This strategy requires that you update your method if you need a new key name, which isn't always going to work.

That's often where this discussion would end!

What about fancy newer Typescript features?

I'm not satisfied with that and wanted to do better. So I wondered if any of the newer Typescript language features like Template literals might help.

You can do some really clever things like the following (this is just a random fun unrelated example):

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 here is the template literal ${C}-${A}. So let's create an animal...

const brownDog = createAnimal('dog', 'brown');;

// the type brownDogType is actually 'brown-dog' !!
type brownDogType = typeof brownDog;

Unfortunately, here you're effectively just creating a 'smarter string type'.

I tried really hard, but couldn't get any further using template literals.

What about Key Remapping via as...

Could I maybe remap a key and get the compiler to retain that new key name in the returned type.....

It turns out it works, but it's sort of awful:

Key remapping only works with a mapped type and I don't have a mapped type. But maybe I could make one. How about taking a dummy type with only one key, and mapping that?

type DummyTypeWithOneKey = { _dummyProperty: any };

// this is what we want the key name to be (note: it's a type, not a string)
type KEY_TYPE = 'template';

// Now we use the key remapping feature
type MappedType = { [K in DummyTypeWithOneKey as `${ KEY_TYPE }`]: any };

The magic here is as ${ KEY_TYPE }` which changes the name of the key.

The compiler now tells us that:

type MappedType = {
    template: any;
}

This type can be returned from a function and not get converted into a stupid string indexed object.

Meet StrongKey<K, V>

So now what? I can create a helper type to abstract away the horrible dummy type.

Note that autocomplete suggests _dummyProperty so I've renamed 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>;
}

If I now do the following I'll finally get autocomplete for obj!!!

const obj = createObject('type', { something: true });

What if I want to use string?

Turns out we can take it one step further and change the key type/name to be a pure string and everything still works!

export type StrongKey<K extends string, V> = { [P in keyof { dynamicKey: any } as `${ K }`]: V };

// creates a 'wrapped' object using a well known key name
function createObject<K extends string, T extends {}>(keyName: K, details: T)
{
    return { [keyName]: details } as StrongKey<K, T>;
}

Combine generated objects

If we can't combine the generated objects then everything is in vain. Fortunately the following works:

const dynamicObject = {...createObject('colors', { colorTable: ['red', 'orange', 'yellow' ] }),
                       ...createObject('animals', { animalTable: ['dog', 'cat', 'mouse' ] }) };

And typeof dynamicObject becomes

{
    animals: {
        animalTable: string[];
    };
    colors: {
        colorTable: string[];
    };
}

So sometimes Typescript is cleverer than it thinks it is.

What's my actual question? Is this the best we will ever be able to do? Should I file an issue with a suggestion? Did I miss anything? Did you learn anything? Am I the first person to ever do this?


Solution

  • You can make mapped types iterate over any key or union of keys you want without having to resort to remapping-with-as. Mapped types don't need to look like [P in keyof T]... they can be [P in K] for any keylike K. The canonical example of this is the Record<K, T> utility type whose definition looks like this:

    type Record<K extends keyof any, T> = {
        [P in K]: T;
    };
    

    and represents an object type whose keys are of type K and whose values are of type T. This is essentially what your StrongKey<K, T> is doing without passing through the dummy middleman of keyof { dynamicKey: any }.


    So your createObject() function could be written as:

    function createObject<K extends string, T extends {}>(keyName: K, details: T) {
      return { [keyName]: details } as Record<K, T>;
    }
    

    Which produces the same output:

    const dynamicObject = {
      ...createObject('colors', { colorTable: ['red', 'orange', 'yellow'] }),
      ...createObject('animals', { animalTable: ['dog', 'cat', 'mouse'] })
    };
    
    /* const dynamicObject: {
        animals: {
            animalTable: string[];
        };
        colors: {
            colorTable: string[];
        };
    } */
    

    Playground link to code