【问题标题】:Typescript: Get value of nested object using a generic function打字稿:使用通用函数获取嵌套对象的值
【发布时间】:2021-11-09 21:25:48
【问题描述】:

我想使用泛型函数访问 typescript 中嵌套对象的值。

(TL;DR:帖子末尾带有游乐场链接的版本)。

设置

以下设置。我想通过连字 (CSS) 访问图标。另外我想给所有图标一个符号名,所以同一个图标的连字和符号名可以不同,例如:

symbolic name: 'home'
ligature     : 'icon-home'

还可以添加自定义图标字体来扩展图标集合。因此必须编辑打字稿。为了防止名称冲突并使这种扩展成为可能,我为图标定义了一个命名空间,名为icon-source

例如:用icon-source="car"调用engine返回engine,用icon-source="airplaine"调用engine返回turbine

我的方法

所以我们假设有 3 个已定义的图标集,其中 icon-set="standard" 是默认情况。

首先我定义了一个enum(字符串),其中包括所有可用的图标集。

const enum GlobalIconSources {
    'standard' = 'standard',
    'airplaine' = 'airplaine',
    'car' = 'car',
}

然后我为每个图标集定义另一个enum(字符串)和相应的类型。该类型的键仅限于枚举的字符串。

const enum GlobalIconSourcesStandard {
    'none' = 'none',
    'home' = 'home',
    'power' = 'power',
};

type ListOfStandardIcons = {
    [key in keyof typeof GlobalIconSourcesStandard]: string
}

之后我定义了一个接口和一个对应的包含所有图标的全局对象。

/**
 * Defines interface which includes all icon sources with their types.
 */

interface GlobalIcons {
    [GlobalIconSources.standard]: ListOfStandardIcons,
    [GlobalIconSources.airplaine]: ListOfSource1Icons,
    [GlobalIconSources.car]: ListOfSource2Icons,
}

/**
 * Defines global object in which all used icon names are defined.
 * [symbolic-name] : [css-name/ligature]
 */

const ICONS : GlobalIcons = {
    'standard': {
        'none': '',
        'home': 'icon-home',
        'power': 'icon-power'
    },
    'airplaine': {
        'wing': 'ap-wing',
        'turbine': 'ap-turbine',
        'landing-gear': 'ap-lg',
    },
    'car': {
        'brakes': 'car-brakes',
        'engine': 'car-engine',
        'car-tire': 'car-tire',
    },
};

访问值的函数

然后有下面的函数来访问全局对象的值。如果存在图标/图标源组合,则该函数应返回图标的值(连字)。否则函数返回undefined

/**
 * Returns the ligature of an icon.
 * @param {map} Global icon object.
 * @param {test} Symbolic icon name.
 * @param {source} Source, where icon is defined.
 * @returns Icon ligature when icon is defined, otherwise undefined.
 */
function getIcon<T extends GlobalIcons, K extends keyof GlobalIcons, S extends keyof T[K]>(map: T, test: unknown, source: Partial<keyof typeof GlobalIconSources> = GlobalIconSources.standard) : T[K] | undefined{
    if(typeof test === 'string' && typeof source === 'string' && typeof map === 'object'){
        if(map.hasOwnProperty(source)){
            const subMap =  map[source as K];
            if(subMap.hasOwnProperty(test)) return subMap[test as S];
        }
    }
    return undefined;
}

这可行但是: 函数的返回类型是ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons | undefined(参见 ts playground)。我希望 string 作为返回类型。

假设我用source="standard"test=""home 调用函数。 那么泛型应该是:

T       : GlobalIcons
T[K]    : ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons (assuming K is keyof T)
T[K][S] : string (assuming K is keyof T and S is keyof T[K]

我知道我要返回T[K] | undefined。我想返回T[K][S] | undefined,但返回的类型始终是undefined(根据TS 游乐场)。

任何人知道我如何处理这个函数,返回的类型是 subobject (ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons) 的正确类型?

TS 游乐场

TypeScript Playground Demo

编辑:设置已更改

我现在更改了设置并删除了枚举和使用对象。

// Defines all available icon sources 
const iconSources = {
    'standard': 'standard',
    'anotherSource': 'anotherSource',
} as const;

// List of icon sources corresponding to the iconSource object
type IconSource = Partial<keyof typeof iconSources>;

// Defines list icon of the "standard" icon source
const StandardIcons = {
    'none': '',
    'home': 'icon-home',
    'power': 'icon-power',
} as const;

// Defines list icon of the "anotherSource" icon source
const Source1Icons = {
    'car': 'my-car',
    'airplaine': 'my-airplaine',
} as const;

// Defines interface and global object
interface Icons {
    [iconSources.standard]: { [key in keyof typeof StandardIcons]: string },
    [iconSources.anotherSource]: { [key in keyof typeof Source1Icons]: string },
}

// Access icon ligatures using icons[iconSourceName][iconKey]
const icons: Icons = {
    'standard': StandardIcons,
    'anotherSource': Source1Icons,
};

我还更改了访问图标源的语法。现在我想传递 1 个参数,即"iconSource/iconName"。当字符串不包含/ 时,使用标准图标源。因此,现在需要 1 而不是 2 参数,但此 test 参数需要输入 unknown,因为它是迄今为止尚未验证的用户输入。

/**
 * This function was copied from here: https://fettblog.eu/typescript-hasownproperty/
 */
function hasOwnProperty<X extends {}, Y extends PropertyKey>
  (obj: X, prop: Y): obj is X & Record<Y, unknown> {
  return obj.hasOwnProperty(prop)
}

function getIcon<L extends Icons, Source extends keyof L, Icon extends keyof L[Source]>(list: L, test: unknown): L[Source][Icon] | undefined {
    if(typeof test === 'string'){
        let icon: string = test;
        let source: string = iconSources.standard; // Use the standard icon source, when no source is defined
        if(test.indexOf('/') > -1){
            const splitted = test.split('/');
            source = splitted[0];
            icon = splitted[1];
        }
        // If source is correct
        if(hasOwnProperty(list, source)){
            // If icon is correct return list[source][icon]
            if(hasOwnProperty(list[source as Source], icon)) return list[source as Source][icon as Icon];
        }
    }
    return undefined;
}

但我遇到了同样的问题,该函数总是返回类型undefined(返回值是正确的)。

// Test
const input1: unknown = 'home';
const input2: unknown = 'standard/none';
const input3: unknown = 'anotherSource/car';
const input4: unknown = 'abc';
const input5: unknown = 'anotherSource/abc';

// Expected results but type of all variables is undefined
const result1 = getIcon(icons, input1); // => 'icon-home' with typeof string
const result2 = getIcon(icons, input2); // => '' with typeof string
const result3 = getIcon(icons, input3); // => 'my-car' with typeof string
const result4 = getIcon(icons, input4); // => undefined 
const result5 = getIcon(icons, input5); // => undefined 

新游乐场

New Playground

【问题讨论】:

  • 您是否有理由要使用字符串枚举而不是字符串?让编译器尝试将字符串转换为等效的枚举值是相当烦人的,因为枚举的要点之一是消除代码中对此类值的依赖(例如,用户应始终编写 GlobalIconSources.standard 而永远不要编写 @987654361 @ 在代码中)。如果我保留枚举,那么 this 可能就是你想要的。如果我删除枚举,那么它会简化为this。如果其中任何一个满足您的需求,我可以写一个答案。
  • 也许我必须检查字符串枚举是否仍然是正确的选择。在第一次尝试中,我想使用字符串枚举来使用Object.values(enum) 访问平面对象。但是如果没有字符串枚举,也会返回索引,但是当someVarDefinedWhileRuntime 可能是索引(0、1、...)时,我需要防止Object.values(enum).includes(someVarDefinedWhileRuntime : unknown) == true
  • You can still use Object.values() with the code I changed it to。请根据您的用例测试该代码,如果它对您有用或不适用,请回复我。
  • 我更新了我的帖子,更改了设置并再次尝试,但遇到了同样的问题,即函数的返回类型未定义。在帖子底部添加了新的游乐场。也许你可以看看它。尝试使用您的解决方案并将其转移到我的新设置中,但我的尝试失败了。
  • 这是一个相当大的范围变化。 this 是否满足您的需求?如果是这样,我会写一个答案。如果没有,我愿意看更多,但如果范围再次显着扩大,我不会。

标签: typescript typescript-generics


【解决方案1】:

你需要做的就是重载你的函数:

console.clear();

/**
 * Defines all available icon sources.
 */

const enum GlobalIconSources {
    'standard' = 'standard',
    'airplaine' = 'airplaine',
    'car' = 'car',
}

/**
 * Defines standard icons with corresponding type.
 */

const enum GlobalIconSourcesStandard {
    'none' = 'none',
    'home' = 'home',
    'power' = 'power',
};

type ListOfStandardIcons = {
    [key in keyof typeof GlobalIconSourcesStandard]: string
}

/**
 * Defines custom icons with corresponding type.
 */

const enum GlobalIconSourcesSource1 {
    'wing' = 'wing',
    'turbine' = 'turbine',
    'landing-gear' = 'landing',
};

type ListOfSource1Icons = {
    [key in keyof typeof GlobalIconSourcesSource1]: string
}

/**
 * Defines more custom icons with corresponding type.
 */

const enum GlobalIconSourcesSource2 {
    'brakes' = 'brakes',
    'engine' = 'engine',
    'car-tire' = 'car-tire',
};

type ListOfSource2Icons = {
    [key in keyof typeof GlobalIconSourcesSource2]: string
}

/**
 * Defines interface which includes all icon sources with their types.
 */

interface GlobalIcons {
    [GlobalIconSources.standard]: ListOfStandardIcons,
    [GlobalIconSources.airplaine]: ListOfSource1Icons,
    [GlobalIconSources.car]: ListOfSource2Icons,
}

/**
 * Defines global object in which all used icon names are defined.
 * [symbolic-name] : [css-name/ligature]
 */

const ICONS = {
    'standard': {
        'none': '',
        'home': 'icon-home',
        'power': 'icon-power'
    },
    'airplaine': {
        'wing': 'ap-wing',
        'turbine': 'ap-turbine',
        'landing-gear': 'ap-lg',
    },
    'car': {
        'brakes': 'car-brakes',
        'engine': 'car-engine',
        'car-tire': 'car-tire',
    },
} as const;


const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

function getIcon<
    IconMap extends GlobalIcons,
    Source extends keyof IconMap,
    Test extends keyof IconMap[Source],
    >(map: IconMap, test: Test, source: Source): IconMap[Source][Test]
function getIcon<
    IconMap extends GlobalIcons,
    Source extends keyof IconMap,
    Test extends keyof IconMap[Source],
    >(map: IconMap, test: Test, source: Source) {
    if (typeof test === 'string' && typeof source === 'string' && typeof map === 'object') {
        if (hasProperty(map, source)) {
            return map[source][test]
        }
    }

    return undefined;
}

// Test
const a = getIcon(ICONS, 'home', 'standard');  // => "icon-home"
const b = getIcon(ICONS, 'turbine', 'airplaine'); // => ap-turbine
const c = getIcon(ICONS, 'engine', 'car');     // => 'car-engine'

console.log(a);
console.log(b);
console.log(c);

Playground

我使用as const 代替ICONS 来推断整个对象。如果不允许使用as const,则需要将文字对象作为参数而不是引用传递。

顺便说一句,您可能根本不想使用hasProperty,因为只允许使用文字参数。

附:你可以在我的blog中找到更多关于函数参数推断的信息

【讨论】:

  • 不幸的是我不能使用as const,因为我在运行时需要这个对象,因为有很多unknown 用户输入。我会看看你的博客。我还没有真正了解重载函数和在 TS 中处理嵌套内容的概念。 TS 文档中的示例非常简单易懂,但如果我想出更复杂的东西,我必须用谷歌搜索很多(至少对于正确的语法)。
  • 我相信@jcalz 会提供更好的答案。我不知道您可以使用字符串类型而不是枚举。每个用例都有自己的要求。不幸的是,我没有足够的时间提供更新。请等待jcalz的回答
【解决方案2】:

我的倾向是将getIcon() 声明为具有多个调用签名的overloaded function,对应于调用函数的多种方式。以下是调用签名:

// call signature #1
function getIcon<S extends keyof Icons, I extends string & keyof Icons[S]>(
  list: Icons, test: `${S}/${I}`): Icons[S][I];

如果您传入包含斜杠 (/) 字符的 test 参数,将调用第一个调用签名,其中斜杠之前的部分是 Icons 的键(编译器从中推断类型参数S),斜杠后面的部分是Icons[S]的键(编译器从中推断出类型参数I)。这是可能的,因为来自 TypeScript 4.1 中引入的template literal type 的推断。请注意,我们还需要将 constrain Istring 以使编译器对在模板文字类型中包含 I 感到满意。此调用的返回类型为Icons[S][I]

// call signature #2
function getIcon<I extends keyof Icons['standard']>(
  list: Icons, test: I): Icons['standard'][I];

如果第一个调用签名不是,并且如果您传入一个test 参数,该参数是Icons['standard'] 的键,编译器从中推断出类型参数I,则将调用第二个调用签名。它返回Icons['standard'][I]。此调用签名的行为类似于第一个调用签名,其中 S 参数已指定为 "standard"

// call signature #3, optional
function getIcon(list: Icons, test: string): undefined;

如果前两个不是,则调用最后一个调用签名,它接受test 的任何string 值,并返回undefined。这是当编译器尝试匹配调用签名但失败时发生的默认行为。这在技术上是您所要求的,但我认为包含此调用签名可能会更好

如果您将其注释掉,那么当有人调用getIcon(icons, "someRandomCrazyString") 时,编译器不会允许它并返回undefined,而是会警告您您调用getIcon() 错误。这是静态类型系统吸引力的一部分。在有机会在运行时执行之前捕获此类不需要的代码。


无论如何,一旦定义了这些调用签名,就可以实现该功能。以下实现与您的实现几乎相同,只是它不需要进行太多运行时检查。如果调用 getIcon() 的人正在编写 TypeScript,那么他们将收到警告,例如,如果他们为 test 传递了非 string 值,例如 getIcon(icons, 123)。在这里进行运行时检查的唯一原因是,如果您担心有人会从未经类型检查的 JavaScript 代码运行 getIcon()。这取决于你:

// implementation
function getIcon(list: any, test: string) {
    let source: string = iconSources.standard
    let icon: string = test;
    if (test.indexOf('/') > -1) {
        const splitted = test.split('/');
        source = splitted[0];
        icon = splitted[1];
    }
    return list[source]?.[icon];
}

所以,让我们测试一下。请注意,如果您不使用annotate iconsinput1 等类型,您将是最快乐的。类型注释(非union 类型)往往会使编译器忘记传入的任何实际值:

const icons = {
    'standard': StandardIcons,
    'anotherSource': Source1Icons,
};
const input1 = 'home';
const input2 = 'standard/none';
const input3 = 'anotherSource/car';
const input4 = 'abc';
const input5 = 'anotherSource/abc';

这里编译器知道input1是字符串literal type"home"而不是stringunknown。如果你注释为stringunknown,编译器将不知道getIcon()会返回什么,或者不让你调用getIcon()

好的,现在开始测试。如果您使用三个调用签名调用 getIcon(),这就是您得到的结果:

const result1 = getIcon(icons, input1); // const result1: string
const result2 = getIcon(icons, input2); // const result2: string
const result3 = getIcon(icons, input3); // const result3: string
const result4 = getIcon(icons, input4); // const result4: undefined
const result5 = getIcon(icons, input5); // const result5: undefined

如果你注释掉第三个,那么这就是你得到的:

const result1 = getIcon(icons, input1); // const result1: string
const result2 = getIcon(icons, input2); // const result2: string
const result3 = getIcon(icons, input3); // const result3: string
const result4 = getIcon(icons, input4); // compiler error!
const result5 = getIcon(icons, input5); // compiler error!

在这两种情况下,编译器都会识别input1input2input3 是有效输入。在前一种情况下,input4input5 被接受并返回undefined,在后一种情况下,input4input5 用红色波浪线加下划线,并警告您getIcon() 没有过载匹配那个调用。

Playground link to code

【讨论】:

    猜你喜欢
    • 2021-02-10
    • 2019-07-30
    • 2021-06-28
    • 2021-06-16
    • 1970-01-01
    • 1970-01-01
    • 2020-02-14
    相关资源
    最近更新 更多