如何在TypeScript中实现一个valueOfArray?

514 阅读4分钟

现在有这样一个数组[1,2,3],我们想定义一个变量,这个变量的值只能在这个数组的值当中,那我们该怎么做呢?

理所当然的,我们会这么写:

const arr = [1, 2, 3];
type oneOfArr = 1 | 2 | 3;
const a: oneOfArr = 1; // 没毛病
const b: oneOfArr = 4; // ERROR:不能将类型“4”分配给类型“oneOfArr”。ts(2322)

这种写法当然也没问题,但如果我们的数组长度够长,或者元素比较复杂,代码就开始向屎山靠拢了。

const arr = [1, 2, 3, 4, 5, 6, 7, 8, { a: 1, b: 2 }];
type oneOfArr = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | { a: 1; b: 2 };
const a: oneOfArr = 1; // 没毛病
const b: oneOfArr = { a: 1, b: 2 }; // 没毛病
const c: oneOfArr = '1'; // ERROR:不能将类型“"1"”分配给类型“oneOfArr”。ts(2322)
const d: oneOfArr = { a: 2 }; // ERROR:不能将类型“"1"”分配给类型“oneOfArr”。ts(2322)

那么有没有什么优雅的写法可以实现这样的功能呢,如果有一个valueOf关键字提供了这样的功能就好了。type T = valueOf<typeof arr>; // 1 | 2| 3

很遗憾TypeScript并没有提供这样的关键字给我们,但是没关系,我们可以自己动手实现一个!

答案

const arr = [1, 2, 3, { a: 1, b: 2 }] as const;
type ValueOfArray<T> = T extends ReadonlyArray<infer U> ? U : never;
type value = ValueOfArray<typeof arr>;

const a: value = 1; // 没毛病
const b: value = { a: 1, b: 2 }; // 没毛病
const c: value = '1'; // ERROR:不能将类型“"1"”分配给类型“1 | 2 | 3 | { readonly a: 1; readonly b: 2; }”。ts(2322)
const d: value = { a: 2 }; // ERROR:不能将类型“2”分配给类型“1”。ts(2322)

对TypeScript了解比较多的同学到这里就可以复制粘贴拿去用了,不过可能还是有部分同学看得不太明白;要想理解上面的代码,我们需要对const断言、ReadonlyArray和infer有一定的了解。

const断言

在讲const断言这个TypeScript3.4版本推出的新特性前,我们需要一些前置的知识。

众所周知,TypeScript存在类型推断的机制,TypeScript 能根据一些简单的规则推断(检查)变量的类型。

let foo = 123; // foo 是 'number'
let bar = 'hello'; // bar 是 'string'

foo = bar; // Error: 不能将 'string' 赋值给 `number`

然而当我们用const声明变量时,变量的类型不会被拓展(123被拓展成number类型,'hello'被拓展成string类型),而是严格等于变量的值。

const x = 'x'; // has the type 'x' 
let y = 'x';   // has the type string

回到const断言,一个变量如果被const断言修饰了,会产生如下效果:

  • 字面量被类型推断时不会被拓展。
  • 对象字面量的所有key会被readonly修饰。
  • 数组字面量的所有值会被readonly修饰,且数组本身会被视为ReadonlyArray。

上面几句是对官方文档的直接翻译,可能有点不好理解,我们可以结合代码看看效果。

// Type '"hello"'
let x = "hello" as const;
// Type 'readonly [10, 20]'
let y = [10, 20] as const;
// Type '{ readonly text: "hello" }'
let z = { text: "hello" } as const;

值得一提的是,上述的readonly修饰是递归的,无限深度的。

掌握了关于const断言的知识,我们再回过头去看看最开始的代码,不难看出变量arr被const断言后,它会被TypeScript视为:

const arr: readonly [1, 2, 3, {
    readonly a: 1;
    readonly b: 2;
}]

// 对比没被const断言修饰的arr↓
const arr: (number | {
    a: number;
    b: number;
})[]

ReadonlyArray

关于ReadonlyArray的各种特性不是本文以及实现valueOfArray的重点,在这里我们只需要清楚这样一个特性:

The same way that ordinary tuples are types that extend from Array - a tuple with elements of type T(1), T(2), … T(n) extends from Array< T(1) | T(2) | … T(n) > - readonly tuples are types that extend from ReadonlyArray. So a readonly tuple with elements T(1), T(2), … T(n) extends from ReadonlyArray< T(1) | T(2) | … T(n).

翻译成人话就是说,跟Array一样,一个含有type[a,b,c,d,e]的ReadonlyArray可以被描述为extends ReadonlyArray<a|b|c|d|e>

// 由于const断言,这个变量被视为ReadonlyArray,且它的值的type不会被拓展为number,而是值本身
let testArr = [1, 2, 3, 4, 5] as const;

// true↓
type Test = typeof testArr extends ReadonlyArray<1 | 2 | 3 | 4 | 5> ? true : false;

Infer

infer关键字表示在 extends 条件语句中待推断的类型变量。它的语法如下:

type inferType<T> = T extends Array<infer U> ? U : never;
const arr = [1, 2, 3]; // typeof arr的值为Array<number>
// ↓等价于const arr2: number = 123;
const arr2: inferType<typeof arr> = 123; // 没毛病,这里的U为number,因为typeof arr为Array<number>>
const arr3: inferType<typeof arr> = 'a'; // ERROR: 不能将类型“string”分配给类型“number”。ts(2322)

总结

掌握了上面的那些知识,就应该可以看懂答案了。

// typeof arr: readonly [1, 2, 3, { readonly a: 1; readonly b: 2; }]
const arr = [1, 2, 3, { a: 1, b: 2 }] as const;

// 这里的T也就是typeof arr可以视为(符合)extends ReadonlyArray<1 | 2 | 3 | { a: 1; b: 2 }>
// 因此这里的U就是1 | 2 | 3 | { a: 1; b: 2 },也就是我们想要的valueOfArray
type ValueOfArray<T> = T extends ReadonlyArray<infer U> ? U : never;
type value = ValueOfArray<typeof arr>;

参考

杀手级的TypeScript功能:const断言 - 掘金

[译]如何在 Typescript 中实现通用的 Valueof< T> 辅助泛型 - 掘金

Creating types from values in array · Issue #28046 · microsoft/TypeScript

白话typescript中的【extends】和【infer】 - 掘金

Documentation - TypeScript 3.4

类型推断 | 深入理解 TypeScript

infer | 深入理解 TypeScript