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

Sim*_*ver 11 typescript mapped-types

动态对象键无需扩展至{ [key: string]: V }?

我正在尝试创建一个 Typescript 函数来生成一个具有动态键的对象,该动态键的名称在函数签名中提供,而返回类型不会扩展为{ [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';    
Run Code Online (Sandbox Code Playgroud)

在彩虹对象上创建两个动态属性:

const rainbow = {
    [color1]: { description: 'First color in rainbow' },
    [color2]: { description: 'Second color in rainbow' }
};
Run Code Online (Sandbox Code Playgroud)

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

 type rainbowType = typeof rainbow;
Run Code Online (Sandbox Code Playgroud)

编译器足够聪明,知道必须命名第一个属性'red'并调用第二个属性'orange',给出以下类型。这使我们能够对任何类型为以下的对象使用自动完成功能typeof rainbow

type rainbowType = {
    red: {
        description: string;
    };
    orange: {
        description: string;
    };
}
Run Code Online (Sandbox Code Playgroud)

谢谢打字稿!

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

第一次尝试

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

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

我将向该函数提供一个动态键名称(仅限于三个选择之一)并为其分配一个值。

const templateObject = createObject('template', { something: true });
Run Code Online (Sandbox Code Playgroud)

这将返回一个运行时值为的对象{ template: { something: true } }

不幸的是,正如您可能担心的那样,这种类型已经扩大了:

typeof templateObject = {
    [x: string]: {
        something: boolean;
    };
}
Run Code Online (Sandbox Code Playgroud)

使用条件类型的解决方案

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

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;
}
Run Code Online (Sandbox Code Playgroud)

这使用条件类型强制返回类型具有与传入的值相同的显式命名键。

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

{
    item: {
        color: string;
    };
}
Run Code Online (Sandbox Code Playgroud)

如果您需要新的密钥名称,此策略要求您更新方法,但这并不总是有效。

讨论通常就到此结束!

Typescript 的新功能怎么样?

我对此并不满意,还想做得更好。所以我想知道是否有任何较新的 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);
}
Run Code Online (Sandbox Code Playgroud)

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

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

// the type brownDogType is actually 'brown-dog' !!
type brownDogType = typeof brownDog;
Run Code Online (Sandbox Code Playgroud)

不幸的是,在这里您实际上只是创建了一个“更智能的字符串类型”。

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

通过...进行键重新映射as怎么样?

我可以重新映射一个键并让编译器在返回的类型中保留该新键名称吗......

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

键重新映射仅适用于映射类型,而我没有映射类型。但也许我可以做一个。如何采用只有一个键的虚拟类型并映射它?

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 };
Run Code Online (Sandbox Code Playgroud)

这里的神奇之处在于as ${ KEY_TYPE }` 它可以更改键的名称。

编译器现在告诉我们:

type MappedType = {
    template: any;
}
Run Code Online (Sandbox Code Playgroud)

这种类型可以从函数返回,并且不会转换为愚蠢的字符串索引对象。

认识 StrongKey<K, V>

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

请注意,自动完成建议_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>;
}
Run Code Online (Sandbox Code Playgroud)

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

const obj = createObject('type', { something: true });
Run Code Online (Sandbox Code Playgroud)

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

事实证明,我们可以更进一步,将密钥类型/名称更改为纯字符串,一切仍然有效!

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>;
}
Run Code Online (Sandbox Code Playgroud)

合并生成的对象

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

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

typeof dynamicObject成为

{
    animals: {
        animalTable: string[];
    };
    colors: {
        colorTable: string[];
    };
}
Run Code Online (Sandbox Code Playgroud)

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

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

jca*_*alz 8

您可以使映射类型迭代您想要的任何键或键的联合,而不必求助于 remapping-with- as。映射类型不需要看起来像[P in keyof T]...它们可以适用[P in K]于任何 keylike K。典型的例子是实用程序类型Record<K, T>定义如下:

type Record<K extends keyof any, T> = {
    [P in K]: T;
};
Run Code Online (Sandbox Code Playgroud)

并 表示一个对象类型,其键的类型为 类型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>;
}
Run Code Online (Sandbox Code Playgroud)

产生相同的输出:

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

/* const dynamicObject: {
    animals: {
        animalTable: string[];
    };
    colors: {
        colorTable: string[];
    };
} */
Run Code Online (Sandbox Code Playgroud)

Playground 代码链接