首页 > 解决方案 > 使用混合时,将参数约束为文字值的某个有限联合

问题描述

我需要一种方法将泛型函数的参数限制为文字值的联合,即

考虑这样的函数

declare function test<K extends string>(t: { [P in K]: string; }): void;

这个想法是我们希望每个可能的值K都有一个对应的属性 in t,这样我们就可以t根据一些K类型的值来查找 in 的值。

一些显示一切顺利的示例用法:

declare const foobar: { foo: 'bar'; };
test(foobar); // compiles, correct
test<'foo'>(foobar); // compiles, correct
test<'bar'>(foobar); // compiler error, correct
//          ^^^^^^
// Argument of type '{ foo: "bar"; }' is not assignable to parameter of type '{ bar: string; }'.
//  Property 'bar' is missing in type '{ foo: "bar"; }' but required in type '{ bar: string; }'.

但是,有一个问题:

test<string>(foobar); // compiles, INCORRECT

在这种test<string>情况下,{ [P in K]: string; }become { [P: string]: string; },它不能保证每个值实际上都有一个键(事实上,这是不可能的,除非有一些代理 getter 恶作剧)。因此,我不想{ [P: string]: string; }在我的函数中允许参数test,这相当于说我不希望我的test函数允许string作为K.

现在,人们可以绕过这种方式。考虑

declare function test<K extends string>(t: string extends K ? never : { [P in K]: string; }): void;

测试string extends K告诉我们是否string已传递给K,在这种情况下,我们会设置参数never,以防止将任何实际值传递给函数(最好在此处引发错误,但 Typescript 不支持)。所以

test<string>(foobar); // compiler error, correct
//           ^^^^^^
// Argument of type '{ foo: "bar"; }' is not assignable to parameter of type 'never'.

但是,当我们使用字符串混合时,这并不能解决问题,我们的项目就是这样做的。考虑

type StringExtended = string & { extension(): void; };
test<StringExtended>(foobar); // compiles, INCORRECT

因为string extends StringExtended是假的,我们的neverhack 没有被应用,我们回到了{ [P in K]: string; },奇怪的是现在推断为{}而不是{ [P: string]: string; }。但是,无论哪种方式,它仍然可以编译。我不希望这样,因为我们的项目使用了很多这样的扩展(特别是使用品牌原语K extends string),而且对于一些旨在让字符串文字代替接受这些类型的字符串扩展的约束来说,这太合理了,打破函数的类型安全。

对于一个更“现实”的例子,考虑

interface Keyed<K extends string, D> { key: K; data: D; }

function toNameOf<K extends string>(
    { key }: Keyed<K, any>,
    names: string extends K ? never : { [P in K]: string; },
): string {
    return names[key];
}

declare const k1: Keyed<'foo' | 'bar', number>;
toNameOf(k1, { foo: 'Foo', bar: 'Bar' });
toNameOf(k1, {});
//           ^^
toNameOf(k1, { foo: 'Foo' });
//           ^^^^^^^^^^^^^^
toNameOf(k1, { bar: 'Bar' });
//           ^^^^^^^^^^^^^^

declare const k2: Keyed<string, number>;
toNameOf(k2, { foo: 'Foo', bar: 'Bar' });
//           ^^^^^^^^^^^^^^^^^^^^^^^^^^
toNameOf(k2, {});
//           ^^
toNameOf(k2, { foo: 'Foo' });
//           ^^^^^^^^^^^^^^
toNameOf(k2, { bar: 'Bar' });
//           ^^^^^^^^^^^^^^

declare const k3: Keyed<StringExtended, number>;
toNameOf(k3, { foo: 'Foo', bar: 'Bar' });
toNameOf(k3, {});
toNameOf(k3, { foo: 'Foo' });
toNameOf(k3, { bar: 'Bar' });

k1除非我们得到一个names实际上涵盖所有可能性的论点,否则这些案例正确地失败了。在 中k2,我们只是拒绝一切;k2没有toNamesOf支持的类型。但是,尽管我尽了最大努力,但在这些k3情况下,所有编译都没有错误或警告。类似的结构在我们的代码中随处可见Keyed(正确地,在其他用例中)用StringExtended而不是任何特定的文字填充。

标签: typescriptgenericsconstraintsunion-types

解决方案


我找到了一个我认为足够强大的解决方案。它没有涵盖第四个要点,但如前所述,这对我来说不太重要。

type LikeLiteralUnion<K extends string | number | symbol> =
    string extends K ? never :
    number extends K ? never :
    symbol extends K ? never :
    [{}] extends [{ [P in K]: unknown; }] ? never :
    K;

我在问题中指出,当您使用 mixin 时,例如type K = string & { extension(): void; }{ [P in K]: unknown; }推断为{}而不是{ [P: string]: unknown; }. 事实证明,这是我们可以利用的两者之间的区别。

使用喜欢

declare function takeLiteralUnion<K extends string | number | symbol>(k: LikeLiteralUnion<K>): void;

推荐阅读