【问题标题】:TypeScript generic type predicateTypeScript 泛型类型谓词
【发布时间】:2020-01-05 02:20:21
【问题描述】:

如何在 TypeScript 中编写泛型类型谓词?

在以下示例中,if (shape.kind == 'circle') 不会将类型缩小到 Shape<'circle'>/Circle/{ kind: 'circle', radius: number }

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  size: number;
}

type Shape<T = string> = T extends 'circle' | 'square'
  ? Extract<Circle | Square, { kind: T }>
  : { kind: T };

declare const shape: Shape;
if (shape.kind == 'circle') shape.radius;
// error TS2339: Property 'radius' does not exist on type '{ kind: string; }'.

我尝试编写一个泛型类型谓词来解决这个问题,但以下不起作用,因为the type parameter isn't available at runtime

function isShape1<T extends string>(shape: Shape): shape is Shape<T> {
  return shape.kind extends T;
}

以下确实有效,但前提是类型参数 T 是文字(在编译和运行时具有相同的值)

function isShape2<T extends string>(shape: Shape, kind: T): shape is Shape<T> {
  return shape.kind == kind;
}

if (isShape2(shape, 'circle')) shape.radius; // Works ✓

declare const kind: string;
if (!isShape2(shape, kind)) shape.kind;
// error TS2339: Property 'kind' does not exist on type 'never'.

更新 1

@jcalz 麻烦是我需要

declare const kind: string;
if (kind != 'circle' && kind != 'square') shape = { kind };

工作。正如您所指出的,我想使用有区别的工会,但不能。如果是有区别的联合,你能写一个泛型类型谓词吗?

type Shape<T = string> = Extract<Circle | Square, { kind: T }>;

仅当类型参数是文字时,以下仍然有效

function isShape3<T extends Shape['kind']>(shape: Shape, kind: T): shape is Shape<T> {
  return shape.kind == kind;
}

if (isShape3(shape, 'circle')) shape.radius; // Works ✓

declare const kind: Shape['kind']; // 'circle' | 'square'
if (!isShape3(shape, kind)) shape.kind;
// error TS2339: Property 'kind' does not exist on type 'never'.

唯一的区别是在这种情况下编译器已经提供了一个工作类型谓词

if (shape.kind != kind) shape.kind; // Works ✓

更新 2

@jcalz 在运行时它可以做与shape.kind == kind 相同的事情吗?

这里有一个更简洁的演示

declare const s: string;
declare const kind: 'circle' | 'square';
declare let shape: 'circle' | 'square';

if (s == kind) shape = s; // Works ✓
if (shape != kind) shape.length; // Works ✓

function isShape1(s: string, kind: 'circle' | 'square') {
  return s == kind;
}

if (isShape1(s, kind)) shape = s;
// error TS2322: Type 'string' is not assignable to type '"square" | "circle"'.
// https://github.com/microsoft/TypeScript/issues/16069

function isShape2(
  s: string,
  kind: 'circle' | 'square'
): s is 'circle' | 'square' {
  return s == kind;
}

if (isShape2(s, kind)) shape = s; // Works ✓
if (!isShape2(shape, kind)) shape.length;
// error TS2339: Property 'length' does not exist on type 'never'.

更新 3

感谢 @jcalz 和 @KRyan 的周到回答! @jcalz 的解决方案很有希望,特别是如果我不允许非缩小案例,而不是仅仅解除它(通过重载)。

但是它仍然受制于problem you point out(Number.isInteger(),不好的事情发生了)。考虑下面的例子

function isTriangle<
  T,
  K extends T extends K ? never : 'equilateral' | 'isosceles' | 'scalene'
>(triangle: T, kind: K): triangle is K & T {
  return triangle == kind;
}

declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
declare const kind: 'equilateral' | 'isosceles';

if (!isTriangle(triangle, kind)) {
  switch (triangle) {
    case 'equilateral':
    // error TS2678: Type '"equilateral"' is not comparable to type '"scalene"'.
  }
}

triangle 永远不会比 kind 窄,所以 !isTriangle(triangle, kind) 永远不会是 never,这要归功于条件类型 (????) 但是它仍然比应有的窄(除非 K 是文字)。

更新 4

再次感谢@jcalz 和@KRyan 耐心地解释这实际上是如何实现的,以及随之而来的弱点。我选择了@KRyan 的答案来提供虚假名义的想法,尽管您的综合答案非常有帮助!

我的结论是 s == kind(或 triangle == kindshape.kind == kind)的类型是内置的,并且(尚)不可供用户使用,以分配给其他事物(如谓词)。

我不确定这是否与one-sided type guards b/c 完全相同,s == kind 的错误分支在(一种)情况下会缩小

declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
if (triangle != 'scalene')
  const isosceles: 'equilateral' | 'isosceles' = triangle;

为了更好地激发这个问题

  1. 我有一个类型,它几乎是一个可区分的联合 (DNS RR),但我无法枚举所有判别式的值(通常它是 string | number,允许扩展)。因此,内置的 rr.rdtype == 'RRSIG' 行为不适用。除非我首先将其缩小为具有用户定义类型保护 (isTypedRR(rr) &amp;&amp; rr.rdtype == 'RRSIG') 的真正可区分联合,否则这不是一个糟糕的选择。
  2. 我可以为我可以枚举的每个 RR 类型实现用户定义的类型保护,但这是很多重复(function isRRSIG(rr): rr is RR&lt;'RRSIG'&gt;function isDNSKEY(rr): rr is RR&lt;'DNSKEY'&gt; 等)。可能这就是我将继续做的事情:重复但显而易见。
  3. 普通的泛型类型保护的问题在于,非字面量虽然不被允许但没有意义(不像s == kind/rr.rdtype == rdtype)。例如function isRR&lt;T&gt;(rr, rdtype: T): rr is RR&lt;T&gt;。因此提出了这个问题。

这使我无法说将isTypedRR(rr) &amp;&amp; rr.rdtype == rdtype 包装在function isRR(rr, rdtype) 中。在谓词 rr 内部合理地缩小了范围,但在外部唯一的选择是(当前)rr is RR&lt;T&gt;(或者现在是假名义)。

也许当type guards are inferred 时,合理地缩小谓词之外的类型也是微不足道的?或者当types can be negated 时,给定一个不可枚举的判别式,就有可能建立一个真正的判别联合。我确实希望s == kind 的类型(更方便:-P)可供用户使用。再次感谢!

【问题讨论】:

  • 我认为你的 Shape 类型是问题,而不是类型保护......也许你想要 type Shape&lt;T extends (Circle | Square)["kind"] = (Circle | Square)["kind"]&gt; = Extract&lt;Circle | Square, { kind: T }&gt;;...... 或者你应该只拥有 type Shape = Circle | Square 和类似 type ShapeKind&lt;T extends Shape["kind"]&gt; = Extract&lt;Shape, {kind: T}&gt;并且不要尝试对这两件事都使用单一的类型名称。 `
  • 另外,discriminated unions 仅在它们具有可在编译时区分联合的每个成员的判别属性时才有效。 {kind: string} 类型不能算作有区别的联合,甚至不能算作联合,所以它不会表现得很好。如您的代码所示,使用类型保护检查{kind: string} 与另一个{kind: string} 只会使其保持不变(在“真”情况下)或将其缩小到never(在“假”情况下)。
  • ????用户定义的类型保护在错误情况下与== 的工作方式不同(在真实情况下与!= 不同)。如果守卫(x: T) =&gt; x is U 返回false,则x 的类型将缩小为Exclude&lt;T, U&gt;,而当x == y 返回false 时,xy 根本不会缩小。如果不适合缩小失败范围,请不要使用用户定义的类型保护(或bad things happen)。如果TU 是同一类型(例如"square" | "circle"),那么这种缩小会导致never,这是不好的。
  • 或者,如果没有one-sided user defined type guards,我就无法按照您的意愿行事。而且我不知道我们是否会得到这些。
  • @jcalz 所以你做到了,我的错。看到这样一位 TS 专家这么说,我很惊讶,应该三重检查我的阅读是否正确。

标签: typescript


【解决方案1】:

关于缩小条件类型

因此,从根本上说,您的问题是缩小值不会为了映射或条件类型而缩小其类型。请参阅this issue on the GitHub bug tracker,特别是this comment,解释为什么这不起作用:

如果我没看错,我认为这是按预期工作的;在一般情况下,foobar 本身的类型不一定反映FooBar(类型变量)将描述给定实例的相同类型。例如:

function compare<T>(x: T, y: T) {
  if (typeof x === "string") {
    y.toLowerCase() // appropriately errors; 'y' isn't suddenly also a 'string'
  }
  // ...
}

// why not?
compare<string | number>("hello", 100);

使用 type-guards 可以帮助您实现目标:

interface Circle {
    kind: 'circle';
    radius: number;
}

interface Square {
    kind: 'square';
    size: number;
}

type Shape<T = string> = T extends 'circle' | 'square'
    ? Extract<Circle | Square, { kind: T }>
    : { kind: T };

declare const s: string;
declare let shape: Shape;

declare function isShapeOfKind<Kind extends string>(
    shape: Shape,
    kind: Kind,
): shape is Shape<Kind>;

if (s === 'circle' && isShapeOfKind(shape, s)) {
    shape.radius;
}
else if (s === 'square' && isShapeOfKind(shape, s)) {
    shape.size;
}
else {
    shape.kind;
}

但是您必须先检查s 的类型,然后才能使用isShapeOfKind 并期望它能够正常工作。那是因为在检查s === 'circle's === 'square' 之前,s 的类型是string,所以你得到isShapeOfKind&lt;string&gt;(shape, s) 的推论只告诉我们shape is Shape&lt;string&gt; 我们已经知道(以及错误的情况)是never,因为shape 被定义为Shape,即Shape&lt;string&gt;——它永远不会不是一个)。您希望发生的事情(但 Typescript 不做的事情)是让它变成类似 Shape&lt;typeof s&gt; 的东西,然后随着更多关于 s 的信息被确定,关于 shape 的知识也被确定。 Typescript 不会跟踪可能相互关联的单独变量的类型。

如果你真的需要的话,你可以这样做的另一种方法是让事物不是一个单独的变量。也就是说,定义几个接口,如

interface ShapeMatchingKind<Kind extends string> {
    shape: Shape<Kind>;
    kind: Kind;
}

interface ShapeMismatchesKind<ShapeKind extends string, Kind extends string> {
    shape: Shape<ShapeKind>;
    kind: Kind;
}

type ShapeAndKind = ShapeMatchingKind<string> | ShapeMismatchesKind<string, string>;

declare function isShapeOfKind(
    shapeAndKind: ShapeAndKind,
): shapeAndKind is ShapeMatchingKind<string>;

const shapeAndKind = { shape, kind: s };
if (isShapeOfKind(shapeAndKind)) {
    const pretend = shapeAndKind as ShapeMatchingKind<'circle'> | ShapeMatchingKind<'square'>;
    switch (pretend.kind) {
        case 'circle':
            pretend.shape.radius;
            break;
        case 'square':
            pretend.shape.size;
            break;
        default:
            shapeAndKind.shape.kind;
            break;
    }
}

即使在这里,您也必须使用 pretend 技巧——将变量转换为更窄的类型,然后当 pretendnever 时,您实际上知道原始变量 不属于那种较窄的类型。此外,较窄的类型必须是ShapeMatchesKind&lt;A&gt; | ShapeMatchesKind&lt;B&gt; | ShapeMatchesKind&lt;C&gt; 而不是ShapeMatchesKind&lt;A | B | C&gt;,因为ShapeMatchesKind&lt;A | B | C&gt; 可以有shape: Shape&lt;A&gt;kind: C。 (如果你有联合A | B | C,你可以使用条件类型实现你需要的分布式版本。)

在我们的代码中,我们经常将pretendotherwise 结合起来:

function otherwise<R>(_pretend: never, value: R): R {
    return value;
}

otherwise 的优点是您可以像这样编写您的default 案例:

default:
    otherwise(pretend, shapeAndKind.shape.kind);
    break;

现在otherwise 将要求pretendnever — 确保您的 switch 语句涵盖了pretend 的缩小类型中的所有可能性。如果您添加了要专门处理的新形状,这将非常有用。

显然,您不必在这里使用switchif/else if/else 链的工作方式相同。

关于不完美的字体保护

在您的最后一次迭代中,您的问题是isTrianglefalse 返回到typeof triangle &amp; typeof kind,而false 的真正含义是triangle kind 的值不匹配。所以你会遇到这样一种情况,Typescript 将 'equilateral''isosceles' 都排除在外,因为 typeof kind'equilateral' | 'isosceles'kind 的实际值只是这两件事之一。

您可以通过 fake nominal types 解决此问题,因此您可以执行类似的操作

class MatchesKind { private 'matches some kind variable': true; }

declare function isTriangle<T, K>(triangle: T, kind: K): triangle is T & K & MatchesKind;

declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
declare const kind: 'equilateral' | 'isosceles';

if (!isTriangle(triangle, kind)) {
    switch (triangle) {
        case 'equilateral': 'OK';
    }
}
else {
    if (triangle === 'scalene') {
//      ^^^^^^^^^^^^^^^^^^^^^^
//      This condition will always return 'false' since the types
//      '("equilateral" & MatchesKind) | ("isosceles" & MatchesKind)'
//      and '"scalene"' have no overlap.
        'error';
    }
}

请注意,我在这里使用了ifswitch 由于某种原因似乎不起作用,它允许 case 'scalene' 在第二个块中没有任何投诉,即使此时 triangle 的类型应该使这是不可能的。

然而,这似乎是一个非常非常糟糕的设计。这可能只是假设的插图场景,但我真的很难确定您为什么要以这种方式设计事物。完全不清楚为什么要检查 trianglekind 的值并让结果出现在类型域中,但没有将 kind 缩小到您实际上可以知道它的类型的程度(因此triangle)。最好先缩小kind,然后用它缩小triangle——在这种情况下,你没有问题。你似乎在某个地方颠倒了一些逻辑,而 Typescript ——我认为是合理的——对此感到不舒服。我当然是。

【讨论】:

    【解决方案2】:

    我将解决您的“更新 2”代码,但该建议应适用于一般问题。我认为这里的主要情况是isShape(s, k) 应该只在s 上充当类型保护,如果s 还不是比k 更窄的类型。否则,您不希望isShape(s, k)s 的类型做任何事情,因为在truefalse 情况下都没有暗示任何相关性(或至少没有可以在类型系统中表示)。

    因此我的建议是overload 函数,使其在“正确”情况下只是用户定义的类型保护,如下所示:

    type Kind = "circle" | "square";
    
    // isShape(s, k) should only act as a type guard if s is not of a narrower type than k
    function isShape<K extends Kind, S extends [S] extends [K] ? never : string>(
      s: S,
      kind: K
    ): s is S & K;
    // otherwise, isShape(s, k) is not a type guard but just a boolean test
    function isShape(s: string, kind: Kind): boolean;
    function isShape(s: string, kind: Kind): boolean {
      return s === kind;
    }
    

    第一个重载签名有效,因为 Sconstrainedconditional type [S] extends [K] ? : never : string。如果Ss的值推断为与kind相同或更窄的类型,则约束变为S extends never,这通常会失败,编译器会尝试下一个重载签名(将会成功)。否则,如果Ss 的值推断为更宽或不同的类型,则约束变为S extends string 并且推断将成功(假设S 可分配给string)并且函数将充当类型守卫。

    现在让我们看看它的行为:

    declare const s: string;
    declare const kind: Kind;
    declare let shape: Kind;
    
    // Use of type guard on string against Kind literal:
    if (isShape(s, "circle")) {
      const x: "circle" = s; // s is "circle"
    } else {
      const x: typeof s = "someString"; // s is string
    }
    
    // Use of type guard on Kind against Kind literal:
    if (isShape(shape, "circle")) {
      const x: "circle" = shape; // shape is "circle"
    } else {
      const x: "square" = shape; // shape is "square"
    }
    
    // Use of type guard on string against Kind:
    if (isShape(s, kind)) {
      const x: Kind = s; // s is Kind
    } else {
      const x: typeof s = "someString"; // s is string
    }
    
    // Use of type guard on Kind against Kind:
    if (isShape(shape, kind)) {
      const x: Kind = shape; // shape is Kind (no narrowing has taken place)
    } else {
      const x: Kind = shape; // shape is Kind (no narrowing has taken place)
    }
    

    我认为这涵盖了您的所有用例。这行得通吗?

    不过,如果您在已经知道s 的类型比k 更窄的情况下不使用isShape(s, k),那会更简单。当您将用户定义的类型保护用于可能存在误报的测试时(false 返回并不暗示有关受保护参数的类型的任何新内容),您是在自取其辱。上面的重载定义试图让isShape() 在你将其指向你的脚时自行解除武装,但对于所有相关人员来说,不需要这些东西会更容易。当sk 宽时,您可以使用isShape(s, k),否则只需使用s === k 或其他一些非类型保护测试。

    但无论如何,我希望这会有所帮助。祝你好运!

    Link to code


    更新

    您已将Kind 扩展为三个字面量,我现在看到,我对哪些情况是“正确”的缩小范围的想法并不完全正确。现在我的攻击计划是针对isTriangle(t, k),当k 是单个字符串文字类型而不是联合时,它应该是一个常规的类型保护only。这可以被类型系统检测到,但它并不漂亮:

    type _NotAUnion<T, U> = T extends any
      ? [U] extends [T] ? unknown : never
      : never;
    
    type IsSingleStringLiteral<
      T extends string,
      Y = T,
      N = never
    > = string extends T ? N : unknown extends _NotAUnion<T, T> ? Y : N;
    

    如果k 是类型的联合,那么您应该只在true 情况下进行缩小,而不是在false 情况下进行缩小。这是一个one-sided user-defined type guard,它在 TypeScript 中并不正式存在。但是,@KRyan notes,您可以通过将受保护类型缩小到 nominal or nominal-like type 来模拟单面类型保护。我将使用品牌,例如 type BrandedFoo = Foo &amp; {__brand: "Foo"}... 我不希望 __brand 属性在运行时实际存在,但编译器认为它在那里,并且可以使用它来区分 FooBrandedFoo .如果类型保护在真实情况下从Foo 缩小到BrandedFoo,那么在虚假情况下它将保持Foo,因为Exclude&lt;Foo, BrandedFoo&gt; 只是Foo

    我仍在使用重载来确定我们想要哪种类型的保护,基于kind的类型:

    type TriangleKind = "equilateral" | "isosceles" | "scalene";
    
    function isTriangle<K extends IsSingleStringLiteral<K, TriangleKind, never>>(
      triangle: string,
      kind: K
    ): triangle is K;
    function isTriangle<K extends TriangleKind>(
      triangle: string,
      kind: K
    ): triangle is K & { __brand: K };
    function isTriangle(triangle: string, kind: TriangleKind): boolean {
      return triangle == kind;
    }
    

    让我们逐步了解它:

    declare const triangle: "equilateral" | "isosceles" | "scalene";
    declare const twoKind: "equilateral" | "isosceles";
    declare const allKind: "equilateral" | "isosceles" | "scalene";
    declare const s: string;
    
    // Use of type guard on string against TriangleKind literal:
    if (isTriangle(s, "equilateral")) {
      const x: "equilateral" = s; // s is "equilateral"
    } else {
      const x: typeof s = "someString"; // s is string
    }
    
    // Use of type guard on string against union of two TriangleKind types:
    if (isTriangle(s, twoKind)) {
      const x: "equilateral" | "isosceles" = s; // s is "equilateral" | "isosceles"
    } else {
      const x: typeof s = "someString"; // s is still string, no narrowing
    }
    
    // Use of type guard on string against TriangleKind:
    if (isTriangle(s, allKind)) {
      const x: TriangleKind = s; // s is TriangleKind
    } else {
      const x: typeof s = "someString"; // s is still string, no narrowing
    }
    
    // Use of type guard on TriangleKind against TriangleKind literal:
    if (isTriangle(triangle, "equilateral")) {
      const x: "equilateral" = triangle; // triangle is "equilateral"
    } else {
      const x: "isosceles" | "scalene" = triangle; // triangle is "isosceles" | "scalene"
    }
    
    // Use of type guard on TriangleKind against union of two TriangleKind types:
    if (isTriangle(triangle, twoKind)) {
      const x: "equilateral" | "isosceles" = triangle; // triangle is "equilateral" | "isosceles"
    } else {
      const x: typeof triangle = allKind; // triangle is still TriangleKind, no narrowing
    }
    
    // Use of type guard on TriangleKind against TriangleKind:
    if (isTriangle(triangle, allKind)) {
      const x: TriangleKind = triangle; // triangle is TriangleKind
    } else {
      const x: typeof triangle = allKind; // triangle is still TriangleKind, no narrowing
    }
    

    这一切看起来基本正确。请注意,在几个真正的分支中,缩小事物的类型被标记,所以你得到("isosceles" &amp; {__brand: "isosceles"}) | ("scalene" &amp; {__brand: "scalene"}) 而不是"isosceles" | "scalene"。你几乎可以忽略这些品牌,但它们有点丑。

    所以你去。复杂和凌乱,我有最好的。

    Link to code

    再次祝你好运!

    【讨论】:

    • 我想知道习惯性地使用isShape(s, k) &amp;&amp; isShape(k, s) 是否能始终如一地获得正确的结果而不会超载。令人讨厌的冗余和运行时开销,因此不是一个很好的解决方案,但它可能不太容易出错。
    猜你喜欢
    • 1970-01-01
    • 2020-01-27
    • 2023-01-24
    • 1970-01-01
    • 2019-05-30
    • 1970-01-01
    • 1970-01-01
    • 2019-09-17
    • 2021-01-03
    相关资源
    最近更新 更多