无法推断扩展基元类型联合的文本类型

Unable to Infer Literal Types that Extend a Union of Primitive Types

提问人:Hantoa Tenwhij 提问时间:8/30/2023 最后编辑:Hantoa Tenwhij 更新时间:8/30/2023 访问量:37

问:

我有以下组件和功能:

interface ILabel<T> {
  readonly label: string;
  readonly key: T
}

interface IProps<T> {
  readonly labels: Array<ILabel<T>>;
  readonly defaultValue: T;
  readonly onChange: (state: ILabel<T>) => void;
}

const testFunc = <T extends string | number>(labels: IProps<T>) => labels

我的目的是能够混合不同类型的键,所以有些可能是字符串,有些可能是数字,有些可能是布尔值。我的目的是从标签中推断出类型,并将其应用于其他道具,例如或受益于文字类型的道具。否则,需要类型保护来确保只有键是其他函数可接受的值,这使工程师对每个用例负责。我想避免这种情况。defaultKeyonSubmit

当所有键都属于同一类型时,我完全能够调用该函数。

testFunc({
  labels: [{
    label: 'whatever',
    key: 'a',
  }, {
    label: 'whatever',
    key: 'b',
  }, {
    label: 'whatever',
    key: 'c',
  }],
  defaultValue: 'a',
  onChange: (state) => {}
}) // Correctly assigns type IProps<'a' | 'b' | 'c'>

但是,每当我尝试混合类型时,打字稿都会认为这是第一个索引的常量,并对所有其他值抛出错误。例:T

testFunc({
  labels: [{
    label: 'whatever',
    key: 'a',
  }, {
    label: 'whatever',
    key: 'b',
  }, {
    label: 'whatever',
    key: 2,
  }],
  defaultValue: 'c',
  onChange: (state) => {}
}) // Errors because it thinks the type is IProps<'a'>
Type '"b"' is not assignable to type '"a"'.ts(2322)
TabBar.types.ts(79, 12): The expected type comes from property 'key' which is declared here on type 'ILabel<"a">'

有一种方法可以通过使用基元来混合它们。例如,以下工作原理:

enum TestEnum {
  ONE = 'one',
  TWO = 'two',
  THREE = 3,
}

enum TestEnum2 {
  FOUR = 'four',
  FIVE = 'five',
  SIX = 6,
}

testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: TestEnum.TWO,
  }, {
    label: 'whatever',
    key: TestEnum.THREE,
  }],
  defaultValue: TestEnum.TWO,
  onChange: (state) => {}
}) // Correctly works as IProps<TestEnum>

但是,所有键的类型都必须为 。与原始文本或其他枚举值混合也会导致相同的错误。TestEnum

// Mixing different enums
testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: TestEnum.TWO,
  }, {
    label: 'whatever',
    key: TestEnum2.FOUR,
  }],
  defaultValue: TestEnum.TWO,
  onChange: (state) => {}
}) // Errors because it infers as IProps<TestEnum.ONE>

// Mixing enum and literal
testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: 'two',
  }],
  defaultValue: 'two',
  onChange: (state) => {}
}) // Errors because it infers as IProps<TestEnum.ONE>

打字稿被混淆有什么解释?还是有合乎逻辑的解释?

我知道混合字符串和数字文字的用例非常好,但工程师可能会尝试混合不同的枚举,或将枚举与文字混合,并对这个模棱两可的错误感到困惑。

Playground 链接

打字稿 铸造

评论

1赞 jcalz 8/30/2023
欢迎来到 Stack Overflow! 不能有。您能否确保代码是一个最小的可重现示例,我们可以复制并粘贴到我们自己的 IDE 中,并查看您遇到的问题而不会被不相关的问题分散注意力?enumtrue
1赞 jcalz 8/30/2023
(续)TS 不会合成不同扩展类型的联合,因为人们通常想要一个错误(例如,人们想要拒绝)。请参阅此 SO 问答ms/TS#44312 中有一个允许联合的功能请求,但未实现。我的解决方法是推断一个完整的数组类型而不是其元素,如下所示。这是否完全解决了这个问题?如果这样做,我会写一个答案来解释;如果没有,我错过了什么?const f = <T>(x: T, y: T) => {}f("a", 1)
0赞 jcalz 8/30/2023
(1)这里的代码仍然不是一个最小的可重现示例,因为在语法上是无效的伪代码;也许你可以把它变成一个评论?(2) 您能否确认我之前的评论,以便我知道如何最好地进行此处?...otherProps<T>
0赞 Hantoa Tenwhij 8/30/2023
是的,这似乎与您讨论的问题有关。我已经编辑了帖子并在底部链接了游乐场网址。
0赞 jcalz 8/30/2023
请确保您的 Playground 链接与您的纯文本代码匹配。

答:

0赞 jcalz 8/30/2023 #1

TypeScript 倾向于不合成跨越扩大的文字边界的新联合类型(所以 from 和 “ 很好,因为两者都加宽为 ,但 from 和就不好了,因为一个加宽到 ,另一个加宽到 )。这是因为人们经常希望将发生这种情况的代码标记为错误。请参阅为什么类型参数未推断为联合类型?。规范示例是这样的"a" | "b""a""bstring"a" | 1"a"1stringnumber

declare function f<T extends string | number>(x: T, y: T): void;
f("a", "b") // okay
f(1, 2); // okay
f("a", 2); // error

虽然有些人会争辩说它应该成功,但更多的人似乎觉得它应该失败,事实也确实如此。f("a", 2)

microsoft/TypeScript#44312 上有一个开放的功能请求,允许使用一些修饰符声明类型参数,以表达在可能的情况下合成联合的愿望。如果它被实现,也许你可以写一些类似 or 的东西,但现在它不是语言的一部分,你需要解决它。declare function f<allowunion T extends string | number>(x: T, y: T): voidconst testFunc = <allowunion T extends string | number>(labels: IProps<T>) => labels


异构数组的常见解决方法是让 type 参数对应于数组类型本身(即单个类型),而不是数组元素类型(可能是联合)。这需要重新表述所有内容:

interface MyProps<T extends Array<string | number>> {
  readonly labels: { [I in keyof T]: ILabel<T[I]> };
  readonly defaultValue: T[number];
  readonly onChange: (state: ILabel<T[number]>) => void;
}

const testFunc = <T extends Array<string | number>>(
  labels: MyProps<T>
): IProps<T[number]> => labels;

因此,现在 type 参数对应于元素属性中的字符串/数字数组,当您调用编译器时,会从 的属性的映射数组类型推断。为了引用元素类型,我们只需使用 .TkeytestFuncTlabelsMyProps<T>number


让我们来测试一下:

const v = testFunc({
  labels: [
    { label: 'whatever', key: 'a', },
    { label: 'whatever', key: 'b', },
    { label: 'whatever', key: 'c', }
  ],
  defaultValue: 'a',
  onChange: (state) => { }
}); // okay
// const v: IProps<"a" | "b" | "c">

const w = testFunc({
  labels: [
    { label: 'whatever', key: 'a', },
    { label: 'whatever', key: 'b', },
    { label: 'whatever', key: 2, }
  ],
  defaultValue: 2,
  onChange: (state) => { }
}); // okay
// const w: IProps<"a" | "b" | 2>
    
const x = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: TestEnum.TWO, },
    { label: 'whatever', key: TestEnum.THREE, }
  ],
  defaultValue: TestEnum.TWO,
  onChange: (state) => { }
}); // okay
// const x: IProps<TestEnum>

const y = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: TestEnum.TWO, },
    { label: 'whatever', key: TestEnum2.FOUR, }
  ],
  defaultValue: TestEnum.TWO,
  onChange: (state) => { }
}); // okay
// const y: IProps<TestEnum.ONE | TestEnum.TWO | TestEnum2.FOUR>

const z = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: 'two', }
  ],
  defaultValue: 'two',
  onChange: (state) => { }
}); // okay
// const z: IProps<"two" | TestEnum.ONE>

这一切都如愿以偿。

Playground 代码链接

评论

0赞 Hantoa Tenwhij 9/1/2023
我试了一下,这在我更复杂的实际用例中就像预期的那样。非常感谢!
0赞 jcalz 9/1/2023
别客气。如果您认为这是正确的答案,请考虑投票和/或接受它。