TypeScript:断言签名和Object.defineProperty

891 阅读5分钟

在JavaScript中,你可以用Object.defineProperty ,即时定义对象属性。如果你希望你的属性是只读的或类似的,这很有用。想想看,一个存储对象有一个不应该被改写的最大值。

const storage = {
  currentValue: 0
}

Object.defineProperty(storage, 'maxValue', {
  value: 9001,
  writable: false
})

console.log(storage.maxValue) // 9001

storage.maxValue = 2

console.log(storage.maxValue) // still 9001

defineProperty 和属性描述符是非常复杂的。它们允许你用属性做一切通常为内置对象保留的事情。所以它们在大型代码库中很常见。TypeScript--在写这篇文章的时候--有一个小问题, 。defineProperty

const storage = {
  currentValue: 0
}

Object.defineProperty(storage, 'maxValue', {
  value: 9001,
  writable: false
})

// 💥 Property 'maxValue' does not exist on type...
console.log(storage.maxValue) 

如果我们不明确地进行类型转换,我们就不会得到maxValue 附带的类型storage 。然而,对于简单的用例,我们可以提供帮助

断言签名#

在TypeScript 3.7中,该团队引入了断言签名。想想看,一个assertIsNumber 函数,你可以确保一些值的类型是number 。否则,它会抛出一个错误。这类似于Node.js中的assert 函数。

function assertIsNumber(val: any) {
  if (typeof val !== "number") {
    throw new AssertionError("Not a number!");
  }
}

function multiply(x, y) {
  assertIsNumber(x);
  assertIsNumber(y);
  // at this point I'm sure x and y are numbers
  // if one assert condition is not true, this position
  // is never reached
  return x * y;
}

为了遵守这样的行为,我们可以添加一个断言签名,告诉TypeScript我们对这个函数之后的类型有更多了解。

- function assertIsNumber(val: any) {
+ function assertIsNumber(val: any) : asserts val is number
    if (typeof val !== "number") {
      throw new AssertionError("Not a number!");
    }
  }

这很像类型谓词,但没有基于条件的结构的控制流,如ifswitch

function multiply(x, y) {
  assertIsNumber(x);
  assertIsNumber(y);
  // Now also TypeScript knows that both x and y are numbers
  return x * y;
}

如果你仔细观察,你可以看到那些断言签名可以在飞行中改变参数或变量的类型。这也正是Object.defineProperty 所做的。

自定义Property#

免责声明:下面的帮助程序并不以100%准确或完整为目标。它可能会有错误,它可能不会处理defineProperty 规范的每一种边缘情况。然而,它可能会很好地处理很多用例。所以使用它的风险由你自己承担。

就像hasOwnProperty一样,我们创建一个模仿原始函数签名的辅助函数。

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc) {
  Object.defineProperty(obj, prop, val);
}

我们用3个泛型来工作。

  1. 我们要修改的对象,类型为Obj ,它是一个子类型。object
  2. 类型Key ,是PropertyKey (内置)的一个子类型,所以string | number | symbol
  3. PDesc,是PropertyDescriptor (内置)的一个子类型。这允许我们定义具有所有特性的属性(可写性、可枚举性、可重构性)。

我们使用泛型,因为TypeScript可以将它们缩小到一个非常具体的单元类型。例如,PropertyKey 是所有的数字、字符串和符号。但如果我使用Key extends PropertyKey ,我可以精确到prop ,例如,"maxValue" 的类型。如果我们想通过添加更多的属性来改变原来的类型,这就很有帮助。

Object.defineProperty 函数要么改变对象,要么在出错时抛出一个错误。这正是一个断言函数的作用。我们的自定义助手defineProperty ,因此也是这样做的。

让我们添加一个断言签名。一旦defineProperty 成功执行,我们的对象就有了另一个属性。我们要为此创建一些帮助器类型。签名第一。

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
-  (obj: Obj, prop: Key, val: PDesc) {
+  (obj: Obj, prop: Key, val: PDesc): 
+    asserts obj is Obj & DefineProperty<Key, PDesc> {
  Object.defineProperty(obj, prop, val);
}

obj 则是 (通过泛型缩小)的类型,以及我们新定义的属性。Obj

这就是DefineProperty 帮助类型。

type DefineProperty<
  Prop extends PropertyKey,
  Desc extends PropertyDescriptor> = 
    Desc extends { writable: any, set(val: any): any } ? never :
    Desc extends { writable: any, get(): any } ? never :
    Desc extends { writable: false } ? Readonly<InferValue<Prop, Desc>> :
    Desc extends { writable: true } ? InferValue<Prop, Desc> :
    Readonly<InferValue<Prop, Desc>>

首先,我们处理writeable 属性的PropertyDescriptor 。这是一组条件,用来定义一些边缘情况和原始属性描述符如何工作的条件。

  1. 如果我们设置writable 和任何属性访问器(get, set),我们就会失败。never 告诉我们,一个错误被抛出。
  2. 如果我们将writable 设置为false ,那么该属性就是只读的。我们推迟到InferValue 帮助器类型。
  3. 如果我们把writable 设置为true ,这个属性就不是只读的。我们也会延迟
  4. 最后,默认情况与writeable: false 相同,所以Readonly<InferValue<Prop, Desc>> 。(Readonly<T> 是内置的)

这是InferValue 的辅助类型,处理设置value 属性。

type InferValue<Prop extends PropertyKey, Desc> =
  Desc extends { get(): any, value: any } ? never :  
  Desc extends { value: infer T } ? Record<Prop, T> : 
  Desc extends { get(): infer T } ? Record<Prop, T> : never;

同样是一组条件。

  1. 如果我们有一个getter和一个set的值,Object.defineProperty 会抛出一个错误,所以永远不会。
  2. 如果我们已经设置了一个值,让我们推断这个值的类型,用我们定义的属性键和值的类型创建一个对象
  3. 或者我们从一个getter的返回类型来推断类型。
  4. 其他的:我们忘了。TypeScript不会让我们对对象进行处理,因为它正在变成never

在行动中#

很多辅助类型,但大概有20行代码就可以搞定了。

type InferValue<Prop extends PropertyKey, Desc> =
  Desc extends { get(): any, value: any } ? never :  
  Desc extends { value: infer T } ? Record<Prop, T> : 
  Desc extends { get(): infer T } ? Record<Prop, T> : never;

type DefineProperty<
  Prop extends PropertyKey,
  Desc extends PropertyDescriptor> =    
    Desc extends { writable: any, set(val: any): any } ? never :
    Desc extends { writable: any, get(): any } ? never :
    Desc extends { writable: false } ? Readonly<InferValue<Prop, Desc>> :
    Desc extends { writable: true } ? InferValue<Prop, Desc> :
    Readonly<InferValue<Prop, Desc>>

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc): 
    asserts  obj is Obj & DefineProperty<Key, PDesc> {
  Object.defineProperty(obj, prop, val)
}

让我们看看TypeScript是怎么做的。

const storage = {
  currentValue: 0
}

defineProperty(storage, 'maxValue', { 
  writable: false, value: 9001 
})

storage.maxValue // it's a number
storage.maxValue = 2 // Error! It's read-only

const storageName = 'My Storage'
defineProperty(storage, 'name', {
  get() {
    return storageName
  }
})

storage.name // it's a string!

// it's not possible to assing a value and a getter
defineProperty(storage, 'broken', {
  get() {
    return storageName
  },
  value: 4000
})

// storage is never because we have a malicious 
// property descriptor
storage 

如前所述,这很可能不会处理所有的边缘情况,但这是一个好的开始。如果你知道你在处理什么,你可以走得很远。