TypeScript泛型类型断言

10

如何在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; }'.

我尝试编写一个通用的类型谓词来解决这个问题,但是因为类型参数在运行时不可用,所以下面的代码无法正常工作。

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 };

我希望能够使用区分联合(discriminated union)来工作,但正如你所指出的那样,我不能这样做。如果它是一个区分联合,你能写一个通用类型谓词吗?

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的解决方案很有前途,尤其是如果我禁止非缩小情况,而不仅仅是通过重载来解除武装。

但是它仍然存在您指出的问题(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)的类型是内置的,用户无法将其分配给其他内容(例如谓词)。

我不确定这是否与单侧类型保护完全相同,因为s == kind的false分支在某种情况下会缩小。

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

为更好地激发这个问题,
  1. 我有一个类型,它几乎是一个可辨别联合(DNS RRs),除了我无法枚举所有辨别的值(通常是一个字符串 | 数字,允许扩展)。因此,内置的rr.rdtype == 'RRSIG'行为不适用。除非我先将它缩小到一个具有用户定义的类型保护的真实可辨别联合(isTypedRR(rr) && rr.rdtype == 'RRSIG'),这不是一个糟糕的选择。
  2. 我可以为我可以枚举的每个RR类型实现用户定义的类型保护,但那是很多重复的(function isRRSIG(rr): rr is RR<'RRSIG'>function isDNSKEY(rr): rr is RR<'DNSKEY'>等等)。也许这就是我将继续做的事情:它是重复的但明显的。
  3. 一个微不足道的泛型类型保护的问题在于,非文字不被禁止,但没有意义(与s == kind/rr.rdtype == rdtype不同)。例如function isRR<T>(rr, rdtype: T): rr is RR<T>。因此有了这个问题。

这使我不能将isTypedRR(rr) && rr.rdtype == rdtype包装在function isRR(rr, rdtype)中。在谓词内部,rr被合理地缩小了,但是在外部,唯一的选择(目前)是rr is RR<T>(或现在是一个虚拟名称)。

也许当类型保护被推断出来时,在谓词之外合理缩小类型将变得不起眼?或者当类型可以被否定时,可以创建一个真正的可辨别联合,给定一个非可枚举的辨别标准。我确实希望s == kind的类型对用户(更方便地:-P)可用。再次感谢!


此外,区分联合类型只有在它们具有一个可在编译时区分联合中每个成员的辨别属性时才有效。类型{kind: string}不算作区分联合类型,甚至不算作联合类型,因此它不会表现得很好。使用类型保护检查{kind: string}与另一个{kind: string}只会使其保持不变(在“true”情况下)或缩小为never(在“false”情况下),正如您的代码所示。 - jcalz
@jcalz 更新2 - nottheoilrig
1
用户定义的类型保护在false的情况下与“==”不同(或者在true的情况下与“!=”)。如果保护器(x:T)= > x为U返回false,则x的类型将被缩小为Exclude <T,U>,而当x == y返回false时,x或y根本就没有缩小。如果在失败时收窄不合适,请勿使用用户定义的类型保护(否则会发生糟糕的事情)。如果T和U是相同的类型(例如,“square”| “circle”),则这种缩小会导致永远不会发生,这是不好的。 - jcalz
1
或者,如果没有单向用户定义类型保护,我无法想出符合您要求的行为方式。而且我不知道我们是否会得到这些保护。 - jcalz
1
@jcalz,原来是我看错了。看到一位 TypeScript 方面的专家这么说,我有点惊讶。应该三思而后阅读。 - KRyan
显示剩余8条评论
2个回答

3

关于缩小条件类型

基本上,您在这里遇到的问题是缩小值不会为映射或条件类型而缩小其类型。请参见GitHub错误跟踪器上的此问题,特别是此评论解释了为什么这样做不起作用:

If I've read correctly, I think this is working as intended; in the general case, the type of foobar itself doesn't necessarily reflect that FooBar (the type variable) will describe identical types of a given instantiation. For example:

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);
使用类型保护可以帮助你完成部分工作:
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;
}

在使用isShapeOfKind并期望其正常工作之前,您必须检查s的类型。这是因为在检查s === 'circle's === 'square'之前,s的类型是string,所以您得到的推断是isShapeOfKind<string>(shape, s),这只告诉我们shape是Shape<string>,这点我们已经知道了(假设情况是never,因为shape被定义为一个Shape,也就是一个Shape<string>,它永远不会不是这个)。您希望发生的事情(但Typescript不会这样做)是将其更改为类似于Shape<typeof s>的东西,然后随着有关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”技巧——将变量转换为较窄的类型,当“pretend”为“never”时,你就知道原始变量实际上不是该较窄类型的一部分。此外,较窄的类型必须是“ShapeMatchesKind<A> | ShapeMatchesKind<B> | ShapeMatchesKind<C>”,而不是“ShapeMatchesKind<A | B | C>”,因为“ShapeMatchesKind<A | B | C>”可能具有“shape: Shape<A>”和“kind: C”的特征。(如果你有一个联合“A | B | C”,你可以使用条件类型来实现所需的分布式版本。)
在我们的代码中,我们经常将“pretend”与“otherwise”结合使用:
function otherwise<R>(_pretend: never, value: R): R {
    return value;
}
< p >使用 otherwise 的优点是您可以如下编写default语句:

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

现在,otherwise 会要求 pretendnever —— 确保你的 switch 语句涵盖了 pretend 的所有可能性。如果你想要处理一个新的形状,则这非常有用。
当然,你不必在这里使用 switch;一系列的 if/else if/else 同样有效。
关于不完美的类型保护
在最终版本中,你的问题是 isTriangletypeof triangle & typeof kind 上返回了 false,而实际上 false 的是 trianglekind 的值不匹配。因此,你会发现 Typescript 将 'equilateral''isosceles' 都视为被排除的选项,因为 typeof kind'equilateral' | 'isosceles',但 kind 的实际值只有其中之一。
你可以通过虚拟名称类型来解决这个问题,这样你就可以做像这样的事情:
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';
    }
}

请注意,我在这里使用了if——原因不明,switch似乎无法正常工作。即使此时的triangle类型使这似乎不可能,它仍然允许第二个块中出现case 'scalene'而没有投诉。 但是,这似乎是一个非常糟糕的设计。也许只是假想的情况,但我很难确定为什么你希望以这种方式设计事物。根本不清楚为什么要检查trianglekind的值,并使结果出现在类型域中,但没有缩小到可以实际知道其类型(从而知道triangle)。最好先缩小kind,然后再用它来缩小triangle,在那种情况下,你就没有问题了。你似乎在某个地方颠倒了一些逻辑,而Typescript对此感到不舒服,我也这样认为。

2
我将处理您的“更新2”代码,但建议适用于一般问题。我认为这里的主要情况是,如果`s`已经比`k`更窄,则`isShape(s, k)`只应该作为对`s`的类型保护。否则,由于在`true`或`false`情况下没有任何相关的内容被暗示(或者至少不能在类型系统中表示),因此您不希望`isShape(s, k)`对`s`的类型做任何事情。
因此,我的建议是重载该函数,使其仅在“正确”的情况下成为用户定义的类型保护,如下所示:
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;
}

那个第一个重载签名的原因是 S 被约束为 受限制类型 [S] extends [K] ? : never : string条件类型。如果根据 s 的值推断出 Skind 的类型相同或更窄,则约束变成了 S extends never,通常会失败,编译器将尝试下一个重载签名(将成功)。否则,如果根据 s 的值推断出 S 是更宽或不同的类型,则约束变成 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 或其他非类型守卫测试。

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

链接到代码


更新

您已将Kind扩展为三个文字,我现在明白了哪些情况是需要缩小范围的"正确"情况。现在我的攻击计划是,当k只是单个字符串文字类型而不是联合类型时,isTriangle(t, k)应该仅作为常规类型保护使用。虽然这可以被类型系统检测到,但它并不美观:

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情况下缩小。这是一种单侧用户定义类型保护,它在TypeScript中并不存在。但是,@KRyan 指出,您可以通过使受保护的类型缩小为名义或类似名义的类型来模拟单侧类型保护。我将使用品牌营销,例如type BrandedFoo = Foo & {__brand: "Foo"}... 在这里,我不希望__brand属性实际上存在于运行时,但编译器认为它存在,并且可以使用它来区分FooBrandedFoo。如果类型保护在真实情况下从Foo缩小到BrandedFoo,则在假情况下它将保持为Foo,因为Exclude<Foo,BrandedFoo>只是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
}

这看起来大部分是正确的。请注意,在几个true分支中,狭窄事物的类型是品牌的,因此您会得到(“等腰三角形”&{__brand:“等腰三角形”})|(“不等边三角形”&{__brand:“不等边三角形”})而不是“等腰三角形” | “不等边三角形”。您可以忽略这些品牌,但它们有点丑陋。
所以你明白了。复杂而混乱,这是我最好的办法。

链接到代码

祝你好运!再次加油!


我想知道是否习惯性地使用 isShape(s, k) && isShape(k, s) 能够在没有负担的情况下始终得到正确的结果。这种方法存在烦人的冗余和运行时开销,因此并不是一个很好的解决方案,但它可能会更少出错。 - KRyan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接