网站搜索

Typescript:使用字符串字面量类型的类型保护


如果我有以下代码:

interface A {
    foo: string
}

interface B extends A {
    foo: "bar"
}

interface C extends A {
    foo: "baz"
}

interface D ...

也就是说,一个接口和若干扩展原始接口的其他接口,但将原始接口的某个特定属性固定为单个字符串字面量,我是否可以为扩展原始接口的接口设计一个类型保护,通过以通用的方式比较这个属性?类似如下的方式:

function isExtensionOfA<T extends A>(obj: A): obj is T {
    return (obj.foo === T.foo.literal_value)
}

答案 #1

当 TypeScript 编译为 JavaScript 时,TypeScript 的静态类型系统会被移除,因此泛型类型 T 在运行时不存在。这意味着在运行时没有所谓的“T.foo”可以检查。如果你想在运行时检查一个值,你需要实际提供这样的值。

这里有一种可能的方法,你可以显式传入你正在检查的 foo 值:

function isExtensionOfA<T extends A>(
  obj: A,
  foo: T["foo"]
): obj is T {
  return (obj.foo === foo);
}

你可以看到它是“有效”的:

function test(obj: A) {
  if (isExtensionOfA<B>(obj, "bar")) {
    obj.bProp.toFixed() // okay
  } else if (isExtensionOfA<C>(obj, "baz")) {
    obj.cProp.toUpperCase() // okay
  } else if (isExtensionOfA<D>(obj, "qux")) {
    obj.dProp // okay
  }
}

之所以用引号,是由于这种方法存在以下重大问题:

test({ foo: "bar" }); // okay at compile time, but:
// 💥 RUNTIME ERROR! obj.bProp is undefined

你可以看到 {foo: "bar"} 被 test() 接受,即使它不是 B。这是因为 TypeScript 中的类型是结构性的(基于数据的形状),而不是名义性的(基于名称或声明)。当你写 T extends A 时,它的意思是“T 的形状与 A 兼容”,所以你所知道的只是它有一个类型为 string 的 foo 属性。这并不意味着“T 是我明确声明为扩展 A 的接口之一”。因此 T 可以是除了 B、C 或 D 之外的各种类型,包括像对象字面量 {foo: "bar"} 的匿名类型。

哎呀。


如果你真的想将某些东西限制为某些列举的接口集合,你将需要明确地将此类型定义为一个联合类型:

type ExplicitA = B | C | D // | ...

一旦你拥有了那个,你就可以编写 isExtensionOfA(),使其输入仅限于 ExplicitA 对象:

function isExtensionOfA<K extends ExplicitA["foo"]>(
  obj: ExplicitA,
  foo: K
): obj is Extract<ExplicitA, { foo: K }> {
  return (obj.foo === foo);
}

现在我正在使用 Extract 工具类型来将 ExplicitA 过滤为仅对应传入的 foo 值的联合成员。现在我们可以让我们的 test() 函数使用它,而无需手动指定类型参数:

function test(obj: ExplicitA) {
  if (isExtensionOfA(obj, "bar")) {
    obj.bProp.toFixed() // okay
  } else if (isExtensionOfA(obj, "baz")) {
    obj.cProp.toUpperCase() // okay
  } else if (isExtensionOfA(obj, "qux")) {
    obj.dProp // okay
  }
}

现在编译器会抱怨使用某个碰巧与 A 兼容的匿名类型调用 test():

test({ foo: "bar" }); // error at compile time

所以,那是安全的,而且它都“有效”。哦,又出现了吓人的引号。


问题是我们现在为了做一些 TypeScript 本身就能原生完成的事情付出了很多额外努力。类型 ExplicitA 是一个判别联合类型,其中 foo 是 判别属性。我们不需要单独的 isExtensionOfA() [用户自定义类型保护函数](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)。我们可以直接检查判别属性:

function test(obj: ExplicitA) {
  if (obj.foo === "bar") {
    obj.bProp.toFixed() // okay
  } else if (obj.foo === "baz") {
    obj.cProp.toUpperCase() // okay
  } else {
    obj.dProp // okay
  }
}

所以你可能只是想从使用判别联合开始。这意味着你的A接口可能不是必需的(除非你还有其他想继承的基本属性),你可以将A重命名为联合类型:

interface B {
  foo: "bar"
  bProp: number;
}

interface C {
  foo: "baz"
  cProp: string;
}

interface D {
  foo: "qux"
  dProp: boolean
}

type A = B | C | D // | ...

function test(obj: A) {
  if (obj.foo === "bar") {
    obj.bProp.toFixed() // okay
  } else if (obj.foo === "baz") {
    obj.cProp.toUpperCase() // okay
  } else {
    obj.dProp // okay
  }
}

而现在事情真的

代码的 Playground 链接