使用动态键在 Typescript 中动态创建对象,无需将类型扩展为 { [key: string]: T }

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

没有扩展到 { [key: string]: V } 的动态对象键?

我正在尝试创建一个 Typescript 函数来 生成一个带有动态键的对象,其名称在函数签名中提供,而 return 类型不会扩大到 { [key: string]: V }.

所以我想打电话给:

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

并取回一个对象 { template: { items: [1, 2, 3] } },它不仅仅是一个带有字符串键的对象。

速速绕道——一起造彩虹吧!

在你告诉我这是不可能的之前,让我们做一个彩虹:

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

在彩虹对象上创建两个 dynamic properties

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

现在让我们看看我们创建的类型

 type rainbowType = typeof rainbow;

编译器足够聪明,知道第一个 属性 必须命名为 'red',第二个称为 'orange',给出以下 type。这使我们能够对任何类型为 typeof rainbow:

的对象使用自动完成
type rainbowType = {
    red: {
        description: string;
    };
    orange: {
        description: string;
    };
}

感谢打字稿!

所以现在我们已经确认我们可以创建一个具有动态属性的对象,并且它不会总是最终被键入为{ [key: string]: V }。当然把它放到一个方法中会使事情复杂化...

第一次尝试

所以现在第一次尝试创建我们的方法,看看我们是否可以欺骗编译器给出正确的结果。

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

这是一个函数,我将为其提供一个动态键名称(限制为三个选项之一)并为其分配一个值。

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

这将 return 一个运行时 { template: { something: true } } 的对象。

不幸的是,这个类型已经扩大了,你可能担心:

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

使用条件类型的解决方案

现在 是解决此问题的简单方法,如果您只有几个可能的键名:

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;
}

这使用 conditional type 强制 return 类型具有与传入的值相同的明确命名的键。

如果我调用 createObject('item', { color: 'red' }),我将返回一个 type 的对象:

{
    item: {
        color: string;
    };
}

此策略要求您在需要新键名时更新方法,但这并不总是有效。

这通常是讨论结束的地方!

新的 Typescript 功能怎么样?

我对此并不满意,想做得更好。所以我想知道 Template literals 等较新的 Typescript 语言功能是否有帮助。

您可以做一些非常聪明的事情,如下所示(这只是一个随机有趣的无关示例):

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);
}

这里的神奇之处在于模板文字 ${C}-${A}。那么让我们创造一个动物...

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

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

不幸的是,您实际上只是在创建一个 'smarter string type'。

我非常努力地尝试,但无法进一步使用模板文字。

Key Remapping via as呢...

我能否重新映射一个键并让编译器在 returned 类型中保留新的键名......

事实证明它有效,但有点糟糕:

键重映射仅适用于映射类型,而我没有映射类型。但也许我可以做一个。使用只有一个键的虚拟类型,然后映射它怎么样?

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 };

这里的魔法是 as ${ KEY_TYPE }`,它会更改密钥的名称。

编译器现在告诉我们:

type MappedType = {
    template: any;
}

这种类型 可以 从函数中 return 编辑,而不是转换成愚蠢的字符串索引对象。

认识 StrongKey

那么现在呢?我可以创建一个辅助类型来抽象出可怕的虚拟类型。

请注意,自动完成建议 _dummyProperty,因此我已将其重命名为 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>;
}

如果我现在执行以下操作,我最终将获得 obj 的自动完成!!!

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

如果我想使用字符串怎么办?

事实证明,我们可以更进一步,将键 type/name 更改为纯字符串,一切仍然有效!

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>;
}

合并生成的对象

如果我们不能将生成的对象组合起来,那么一切都是徒劳的。幸运的是以下作品:

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

并且typeof dynamicObject变成了

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

所以有时 Typescript 比它想象的要聪明。

我的实际问题是什么?这是我们能做到的最好的吗?我应该提出建议问题吗?我错过了什么吗?你学到什么了吗?我是第一个这样做的人吗?

你可以让 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 看起来像这样:

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

表示一个对象类型,其键类型为K,值类型为T。这基本上就是您的 StrongKey<K, T> 在不通过 keyof { dynamicKey: any }.

的虚拟中间人的情况下所做的事情

所以你的 createObject() 函数可以写成:

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

产生相同的输出:

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