Generate objects dynamically in Typescript by utilizing dynamic keys, all while avoiding the need to expand the type to { [key: string]: T }

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?

Answer №1

If you want to customize mapped types to iterate over specific keys or key unions without using remapping-with-as, it's possible. Mapped types don't necessarily have to follow the format of [P in keyof T]... they can also be structured like [P in K] targeting any key-like structure represented by K. An excellent illustration of this concept is demonstrated with the utility type Record<K, T>, which has a definition resembling this:

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

This defines an object type where the keys are of type K and the values are of type T. Essentially, similar to what your StrongKey<K, T> does but skipping the intermediary step of keyof { dynamicKey: any }.


Hence, your createObject() function can be implemented as follows:

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

This function 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[];
    };
} */

Link to playground for code demonstration

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What causes the difference in behavior between packed and non-packed generics?

When attempting to exclude properties outside of generics, it functions properly but results in a breakdown within the generic context. The issue lies in the fact that Omit<Thing, 'key1' | 'key2'> transforms into Omit<Thing, &a ...

"Hmm, the React context's state doesn't seem to be changing

I have been working on a next.js app and I encountered an issue related to using react context to export a state. Despite my efforts, the state doesn't seem to update and it remains stuck at the initial value defined by the createContext hook, which i ...

"Encountering issues when trying to retrieve a global variable in TypeScript

Currently facing an issue with my code. I declared the markers variable inside a class to make it global and accessible throughout the class. However, I am able to access markers inside initMap but encountering difficulties accessing it within the function ...

Upgrading embedded data in MongoDB using Mongoose

I have been attempting to modify the nested value data1 within a MongoDB document: { "content": { "data": { "data1": "Some data", "data2": "Better data" }, "location ...

Can you explain why it prints to the console twice every time I try to add an item?

This is a note-taking application created using Angular and Firebase. The primary functionalities include adding items and displaying them. I noticed a strange behavior where my ngOnInit() method is being called twice every time I add an item. As shown in ...

Adding files to an Angular ViewModel using DropzoneJS

I am facing a challenge in extracting file content and inserting it into a specific FileViewModel. This is necessary because I need to bundle all files with MainViewModel which contains a list of FileViewModel before sending it from the client (angular) to ...

Is it possible to utilize Angular's structural directives within the innerHtml?

Can I insert HTML code with *ngIf and *ngFor directives using the innerHtml property of a DOM element? I'm confused about how Angular handles rendering, so I'm not sure how to accomplish this. I attempted to use Renderer2, but it didn't wor ...

Resolving the issue of missing properties from type in a generic object - A step-by-step guide

Imagine a scenario where there is a library that exposes a `run` function as shown below: runner.ts export type Parameters = { [key: string]: string }; type runner = (args: Parameters) => void; export default function run(fn: runner, params: Parameter ...

NestJS testing issue encountered: Compiled JS file not found in E2E test using Mocha

I'm currently facing an issue with executing an E2E test. The file structure for the E2E test is auto-generated by nestcli. import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; i ...

Fill up the table using JSON information and dynamic columns

Below is a snippet of JSON data: { "languageKeys": [{ "id": 1, "project": null, "key": "GENERIC.WELCOME", "languageStrings": [{ "id": 1, "content": "Welcome", "language": { ...

What is the best way to retrieve the chosen item within a Tabmenu component in Primeng?

I have created a simple array of MenuItem objects to populate the Tabmenu component from Primeng. Here is an example: .ts file: items = MenuItem[]; activeItem = MenuItem; //constructor, etc... ngOnInit() { this.items = [ {label: &a ...

Unable to access attributes of an undefined value (current state is undefined)

After completing a small project, I attempted to deploy it on Vercel. The project runs smoothly without any errors on my local machine. However, when I tried to run it on the server, I encountered the following error: "Cannot read properties of undefined ( ...

Passing a retrieved parameter from the URL into a nested component in Angular

I'm currently facing an issue where I am trying to extract a value from the URL and inject it into a child component. While I can successfully retrieve the parameter from the URL and update my DOM with a property, the behavior changes when I attempt t ...

What is the best way to search for an Enum based on its value?

One of my challenges involves an enum containing various API messages that I have already translated for display in the front-end: export enum API_MESSAGES { FAILED_TO_LOAD = 'Failed to load data', TOKEN_INVALID = 'Token seems to be inva ...

How can I access DOM elements in Angular using a function similar to the `link` function?

One way to utilize the link attribute on Angular 2 directives is by setting callbacks that can transform the DOM. A practical example of this is crafting directives for D3.js graphs, showcased in this pen: https://i.sstatic.net/8Zdta.png The link attrib ...

Remove the main project from the list of projects to be linted in

Currently in the process of transitioning my Angular application to NX and have successfully created some libraries. I am now looking to execute the nx affected command, such as nx affected:lint, but it is throwing an error: nx run Keira3:lint Oops! Somet ...

Issue with data not refreshing when using router push in NextJS version 13

I have implemented a delete user button on my user page (route: /users/[username]) which triggers the delete user API's route. After making this call, I use router.push('/users') to navigate back to the users list page. However, I notice tha ...

Detecting when users stop scrolling in Angular 5

Is there a way to toggle visibility of a div based on user scrolling behavior? I'd like the div to hide when the user scrolls and reappear once they stop. I've attempted to achieve this effect using @HostListener, but it only seems to trigger wh ...

Encountering Next.js Hydration Issue when Using Shadcn Dialog Component

While working on a Next.js project, I came across a hydration error when utilizing the Shadcn Dialog component. The specific error message reads: "Hydration failed because the initial UI does not match what was rendered on the server." Highligh ...

Developing JSON objects with Typescript

What is the best approach to generate and store an array of JSON objects when a new item is added? The issue I am currently facing is: Is it necessary to create a class object before saving JSON objects, or can they be saved directly? What method shoul ...