最近写代码的时候,遇到一个类型定义的问题,有一段类似下面这样的代码:

const tabs = [
  { id: 1, value: 'foo' },
  { id: 2, value: 'bar' },
]

tabs 是常量,里面的内容有可能会有变化,后面需要使用到 value 的类型,又不想单独写一个类型定义,这样 tabs 的内容变化,类型定义也需要修改,但是这个类型定义只会被 tabs 用到。最理想的办法是利用类型推断直接从 tabs 中提取 value 的类型:'foo' | 'bar' 。尝试写了一个类型方法把 value 的类型提取出来:


type getPropertyTypeOfArrayObject<T extends unknown, K extends string>= T extends Array<{[k in K]: infer R}> ? R : never;

const T1 = [
  { id: 1, value: 'foo' },
  { id: 2, value: 'bar' }
]

const T2 = [
  { id: 1, value: 'foo' },
  { id: 2, value: 3 },
]

type V1 = getPropertyTypeOfArrayObject<typeof T1, 'value'>; // string
type V2 = getPropertyTypeOfArrayObject<typeof T2, 'value'>; // string | number

getPropertyTypeOfArrayObject 的作用是从一个对象数组中,根据传入属性,返回属性在对象中的类型。我们定义了两个不同的值 T1 和 T2,value 值分别有点不一样,可以看到的是返回的是更加宽泛的类型 stringnumber ,而不是我们想要的字面量类型 'foo' | 'bar''foo' | 3 。要解释这个问题,我们需要先简单了解一下 TS字符串字面量 类型是如何使用的,以及是如何对其进行扩展的。

字符串字面量类型

这个 PR 详细地介绍了 TS 对字符串字面量的支持。所谓字符串字面量类型就是类型的文本跟字符串的值是一模一样的,也就是说 'foo' 的字面量类型就是 'foo' ,既然是字符串,所以它也是 string 的子类型。

类型扩展

为了提高开发体验,TS 能够自动从上下文推断出一些明显的类型,而不需要编程人员手动指定每一个类型。 例如:


let t1 = 'foo'; // string
const t2 = 'bar'; // 'bar'
let t3: 'foo' = 'foo'; // 'foo'

// { id: number, name: string }
const t4 = {
    id: 1,
    name: 'baz',
} 
t4.id = 2;

从上面的例子可以看出,除非显示定义了类型,对于 let 声明的变量,会被推断为 string ,对于 const 声明的原始类型值, TS 能够识别到其是不能改变的,就会被推断为更严格的类型 foo ,而对于 const 声明的复杂类型值,里面的属性值的类型同样被扩展了,因为对象的属性值是可以进行更改的。

再回到上面的例子中,为了获得 T1 的类型,我们使用了 typeof 进行类型查找:


const T1 = [
  { id: 1, value: 'foo' },
  { id: 2, value: 'bar' }
]

// { id: number, value: string }[]
type tt1 = typeof T1; 

这跟类型扩展的机制是一样的。为了得到更精确的 id value 的类型,我们还有另外一种方法,就是类型断言:

const T1 = [
  { id: 1 as 1, value: 'foo' as 'foo' },
  { id: 2 as 2, value: 'bar' as 'bar' }
]
/**
({
    id: 1;
    value: "foo";
} | {
    id: 2;
    value: "bar";
})[]
*/
type tt1 = typeof T1;

很显然这种方法很不友好,要写大量繁琐的断言,那么有没有一种方法可以让我们一次性对整个值进行断言呢?这就是 TypeScript 3.4 引入的const assertions

const assertions

const 断言顾名思义也是一种类型断言方式,不同于断言到具体的类型 'foo' as 'foo',它的写法是 'foo' as const 或者 { value: 'foo' } as const

它主要有以下特性:

1. `string number boolean`  字面量类型都不会被扩展;
2. 数组字面量会变成只读的元组;
3. 对象字面量的属性会变成只读的;
/**
{
    readonly name: "foo";
    readonly age: 12;
}
*/
const readonlyObj = { name: 'foo', age: 12 } as const

/**
readonly [1, "foo", "bar"]
*/
const readonlyTuple = [1, 'foo', 'bar'] as const;

我们回头修改一下之前的代码:

type getPropertyTypeOfArrayObject<T extends unknown, K extends string> = T extends Readonly<Array<{[k in K]: infer R}>> ? R : never;

const T1 = [
  { id: 1, value: 'foo' },
  { id: 2, value: 'bar' }
] as const;

// type V1 = "foo" | "bar"
type V1 = getPropertyTypeOfArrayObject<typeof T1, 'value'>; 

// type V2 = 1 | "foo" | 2 | "bar"
type V2 = getPropertyTypeOfArrayObject<typeof T1, 'value' | 'id'>;

需要注意的是 getPropertyTypeOfArrayObject 也做了一些修改,因为 const assertions 会把对象变成只读的,我们要使用内置的 Readonly 方法把条件语句的对象也变成只读的才会得到真值,从而推断到指定的属性值类型。

使用这个工具方法,结合 const assertions 我们就可以提取到指定的属性值类型。

基于 const assertions 的强大功能,推断出来的值都是确定的,我们不需要显式地声明更多定义来进行类型推断:

// Works with no types referenced or declared.
// We only needed a single const assertion.
function getShapes() {
  let result = [
    { kind: "circle", radius: 100 },
    { kind: "square", sideLength: 50 }
  ] as const;

  return result;
}

for (const shape of getShapes()) {
  // Narrows perfectly!
  if (shape.kind === "circle") {
    console.log("Circle radius", shape.radius);
  } else {
    console.log("Square side length", shape.sideLength);
  }
}

以上就是 const assertions 的用法和一些使用的场景。