【问题标题】:Implementing a generic function with a conditional return type使用条件返回类型实现泛型函数
【发布时间】:2021-06-27 01:03:45
【问题描述】:

我正在尝试一个非常基本的(人为的)条件类型函数并遇到意外错误:

function test<
  T
>(
  maybeNumber: T
): T extends number ? number : string {
  if (typeof maybeNumber === 'number') {
    return maybeNumber // Type 'T & number' is not assignable to type 'T extends number ? number : string'.
  }

  return 'Not a number' // Type '"Not a number"' is not assignable to type 'T extends number ? number : string'.
}

我认为这是条件类型的一种非常简单的用法,所以不确定发生了什么。有什么想法吗?

澄清一下,我并没有真正尝试实现这个特定的功能。我只是在试验条件类型,想更好地理解为什么这实际上不起作用。

【问题讨论】:

  • 这里已经有很多答案,但我想确保链接到microsoft/TypeScript#33912,这是一个 GitHub 问题,专门讨论泛型函数中的条件类型可分配性失败。这可能是回答“为什么这不起作用”的好地方。

标签: typescript


【解决方案1】:

正确答案和当前解决方法(在撰写本文时):

type MaybeNumberType<T extends number | string> = T extends number
  ? number
  : string;

function test<T extends number | string>(
  maybeNumber: T,
): MaybeNumberType<T> {
  if (typeof maybeNumber === 'number') {
    return <MaybeNumberType<T>>(<unknown>maybeNumber);
  }

  return <MaybeNumberType<T>>(<unknown>'Not a number');
}

test(3); // 3
test('s'); // Not a number

【讨论】:

    【解决方案2】:

    问题是maybeNumber 在运行时可以是一个数字,即使T 没有扩展number。在这种情况下,您的函数返回一个number,但它的类型声明说它应该返回string。考虑:

    function test_the_test(value: Object) {
        // no type error here, even though test(value) will return a number at runtime
        let s: string = test(value);
    }
    
    test_the_test(23);
    

    在这种情况下,T = Object 不会扩展 number - 它是超类型,而不是子类型 - 所以条件类型 T extends number ? number : string 解析为 string。我们可以用一个泛型类型来测试它:

    type Test<T> = T extends number ? number : string
    type TestObject = Test<Object>
    // TestObject is string
    

    所以,您的函数test 实际上至少在它声称将返回一个字符串的情况下返回一个数字。这意味着该函数不是类型安全的,出现类型错误是正确的。

    事实上,给定这些类型注释,您的函数没有明智的、类型安全的实现。 test&lt;number&gt;(23) 应该返回 number,但 test&lt;Object&gt;(23) 应该返回 string;但是两者都编译为相同的 Javascript,因此无法在运行时知道函数应该返回哪种类型。满足这两个约束的唯一方法是编写一个永不返回的函数(例如,通过无条件抛出异常);如果您从不返回值,则返回值永远不会有错误的类型。所以你的功能需要重新设计。


    在 Typescript 中有两种明智的方法来编写一个检查输入是否为数字的函数。一种是编写user-defined type guard,如果它返回true,则将其参数的类型缩小为number

    function isNumber(x: any): x is number {
        return typeof x === 'number';
    }
    

    另一种方法是编写一个assertion function,如果它不是数字则抛出错误,从而将其参数的类型缩小到number

    function assertNumber(x: any): asserts x is number {
        if(typeof x !== 'number') {
            throw new TypeError('Not a number');
        }
    }
    

    不幸的是,当输入不是数字时,这些都不会返回字符串。但两者都可能适合您的实际用例。

    【讨论】:

    • 那么这是否意味着您不能使用基于输入类型的条件返回类型,因为它同样可能是超类型?
    • "T = number | string" 不,不是,因为控制流分析将value的类型缩小到number,所以T实际上是number。即使这是真的:“条件类型T extends number ? number : string 解析为string”不,它没有,因为它distributes over unions in T。我不认为这一段应该保留 ? 可能你可以通过使用 unknown 而不是 string | number 来挽救它,但许多分配条件对 unknown 的作用很奇怪。
    • @jcalz 感谢您指出这一点。我已经编辑使用Object 作为超类型而不是number | string,以避免使用联合类型的问题。
    • @bingles 我不认为这意味着您永远不能以类型安全的方式将条件类型用作函数的返回类型 - 只是可以以一种可以使用的方式使用它t 可以安全地实现,而不会无条件地抛出异常。对于像 function foo&lt;S, T&gt;(x: S): T { /* ... */ } 这样声称返回任意类型 T 而在运行时无法访问 T 的函数同样是不可能的,但这并不意味着您永远不能使用泛型返回类型。
    【解决方案3】:

    根本问题是 TypeScript 的编译器没有通过控制流分析来缩小泛型类型变量的类型。检查(typeof maybeNumber === "number")时,编译器可以将maybeNumber缩小为number,但不会将类型参数T缩小为number .因此它无法验证将number 值分配给返回类型T extends number ? number : string 是否安全。编译器必须执行一些它目前不执行的分析,例如“好吧,如果typeof maybeNumber === "number",我们仅从maybeNumber 的类型推断T,那么在这个块内我们可以将T 缩小到number,因此我们应该返回一个 number extends number ? number : string 类型的值,也就是 number"。但这不会发生。

    对于具有条件返回类型的泛型函数来说,这是一个相当大的痛点。与此相关的规范开放 GitHub 问题可能是 microsoft/TypeScript#33912,但还有许多其他 GitHub 问题,这是主要问题。

    这就是“为什么这不起作用”的答案?


    如果您对重构不感兴趣,可以忽略其余部分,但知道在这种情况下该怎么做而不是等待语言更改可能仍然具有指导意义。

    这里维护您的调用签名的最直接的解决方法是使您的函数成为单个签名overload,其中实现签名不是通用的。这实质上放松了实现内部的类型安全保证:

    type MyConditional<T> = T extends number ? number : string;
    type Unknown = string | number | boolean | {} | null | undefined;
    
    function test<T>(maybeNumber: T): MyConditional<T>;
    function test(maybeNumber: Unknown): MyConditional<Unknown> {
      if (typeof maybeNumber === 'number') {
        const ret: MyConditional<typeof maybeNumber> = maybeNumber;
        return ret;
      }
      const ret: MyConditional<typeof maybeNumber> = "Not a number";
      return ret;
    }
    

    在这里,我已经尽我所能通过使用注释为 MyConditional&lt;typeof maybeNumber&gt; 的临时 ret 变量来保证类型安全,该变量使用控制流分析缩小类型 maybeNumber .如果您切换检查,这至少会抱怨(将=== 转换为!== 以进行验证)。但通常我只是做一些像这样更简单的事情,然后让筹码落在他们可能的地方:

    function test2<T>(maybeNumber: T): MyConditional<T>;
    function test2(maybeNumber: any): string | number {
      if (typeof maybeNumber === 'number') {
        return maybeNumber;
      }
      return "Not a number";
    }
    

    好的,希望对您有所帮助;祝你好运!

    Playground link to code

    【讨论】:

    • 您好,感谢您的回复,但是第一种方法不起作用,我有type ConditionalType&lt;T&gt; = T extends 'fields' ? {key: boolean} : boolean;,但是如果我返回{key: true} 如果key === 'fields',它会说Type '{ key: boolean; }' is not assignable to type 'boolean'.,什么我做错了吗?
    • @Ayfri 请检查this code,它有效。请注意,如本答案所述,我使用的是单个重载。如果此解决方案对您不起作用,请尝试生成 minimal reproducible example,我或其他人可以使用它来查看您在说什么。
    • 非常好的答案,我不知道有一个额外调用签名的过载用例。奇怪的是,TS 文档如此强烈地声明:“实现的签名从外部是不可见的。在编写重载函数时,你应该总是有 两个或以上函数实现的多个签名。”
    【解决方案4】:

    如果用重载实现你的函数会更好:

    function test(arg: number): number;
    function test(arg: unknown): string;
    function test(arg: any): string | number {
      if (typeof arg === 'number') {
        return arg;
      }
    
      return 'Not a number'
    }
    

    【讨论】:

    • 感谢您的回复。我并不是真的在寻找一种不同的实施方式。更多只是想了解条件类型。我已经修改了我的问题以澄清
    • 这不是一个很好的解决方案,因为条件类型可能有许多不同的返回类型,并且可能在不同的地方使用。使用方法重载会导致源代码库的可维护性大大降低,而且绝对没有那么灵活。虽然从技术上讲是一种解决方法,但它不是解决该问题的现实解决方案
    【解决方案5】:

    我相信你在错误的地方定义了返回类型:

    function test<T extends number | String>(maybeNumber: T) {
    if (typeof maybeNumber === 'number') {
      return maybeNumber
    }
    return 'Not a number'
    

    }

    【讨论】:

    • 所以这不太一样。我的意图是采用任何类型(不仅仅是数字或字符串)。我的目标是有一个条件返回类型,以便编译器可以根据输入类型推断结果类型。我认为在您的示例中,返回类型将始终被推断为 number |字符串,无论输入类型如何。
    猜你喜欢
    • 1970-01-01
    • 2021-05-23
    • 2016-10-16
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多