TypeScript类型声明高级用法

最近看了一些分析TypeScript的文章,发现有很多自己不了解的地方,原来类型声明还有这么多高级用法,真是有点落伍了。于是重新补习了一下TS文档,整理了本篇文章。

<!--more-->

参考

1. 混合类型

JavaScript运行函数代码额外的属性和方法,参考:混合类型

比如下面这个方法

function counter(start){}
counter.interval = 123
counter.reset = function(){}

如果要对counter类型进行声明,就需要用到混合类型

interface Counter{
    (start:number): void;
    interval:number;
    reset:()=>void;
}

function getCounter():Counter{
    // 首先使用强制类型转换声明counter变量的类型
    // let counter = <Counter>function(start:number){}
    let counter = <Counter>((start:number)=>{}) // 后面的函数声明必须要加括号,表示<Counter>强制类型转换的是后面整个函数表达式
    counter.interval = 123
    counter.reset = function(){}
    return counter
}

2. 泛型

泛型可以帮助我们捕获用户传入的类型,换言之,我们定义的类型可以兼容未来用户指定的类型。

基本的用法如下

type Value<T> = T
type NumberValue = Value<number> // number
type StringValue = Value<string> // string

let s1: StringValue = 'hello'
// let s2: StringValue = 1 // type error

下面是一个泛型函数

function identity<T>(arg: T): T {
    return arg;
}
let a = identity(123) // a的类型为number

我们在声明类型的时候,也可以使用泛型类型。比如上面泛型函数identity本身的泛型类型

// 等价于
type IdentityType1 = typeof identity
// 对象字面量的调用签名,可以参考上面的混合类型
type IdentityType2 = {
    <T>(arg: T): T
}
type IdentityType3 = <T>(arg: T)=> T;
// IdentityType1IdentityType2IdentityType3 三种类型是相同的

let myIdentity:IdentityType1 = identity

如果我们想要为myIdentity函数的参数限定某个类型,则可以使用泛型接口

interface GenericIdentityFn<T> {
    (arg: T): T; // 跟上面IdentityType2的区别在于不再是描述函数泛型,而是描述泛型接口
}

let myIdentity: GenericIdentityFn<number> = identity; // 限定只能接收number
myIdentity(123)
identity("hello") // 不会影响identity
// myIdentity("hello") // error
let myIdentity2: GenericIdentityFn<string> = identity;
myIdentity2("hello")

同理,也可以创建泛型类

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let a = new GenericNumber<number>() // 在构造对象时才指定具体类型
a.add(100, 200)
// a.add('1', '2')// error

有时候虽然我们使用了泛型,但是我们希望用户只传入一些按照约定的类型,而非所有类型,此时可以使用泛型约束,使用T extends type的语法声明泛型约束

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);  // 正常
    return arg;
}
loggingIdentity(123) // error,参数类型T必须包含length属性

一种特殊的约束是我们希望参数是某个类,这时候需要需要使用new()

function create<T>(c: {new(): T; }): T {
    return new c();
}
function a(){}
class A{}
// create(a) // error
create(A) // right

3. 交叉类型 &

在JavaScript中一种常见的场景是合并两个对象,如$.fn.extendObject.assign等,这种场景下要求这些方法返回的是两个参数合并后的类型,因此需要使用交叉类型&

function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U>{};
    for (let id in first) {
        (<any>result)[id] = (<any>first)[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            (<any>result)[id] = (<any>second)[id];
        }
    }
    return result;
}

class Person {
    constructor(public name: string) { }
}
class ConsoleLogger {
    log() {}
}
var jim = extend(new Person("Jim"), new ConsoleLogger()); 
var n = jim.name;
jim.log();
// jim.xxx // error

4. 联合类型 |

如果某个函数希望传入的参数是某几种指定类型中的一种,可以使用联合类型

type padding = number | string

注意如果某个类型是联合类型时,由于在运行时该变量究竟是哪一种类型,我们只能访问此联合类型的所有类型里共有的成员

interface Bird {
    fly();
    layEggs();
}
interface Fish {
    swim();
    layEggs();
}
function getSmallPet(type: number = 1): Fish | Bird {
    let obj = {}
    return type === 1 ? <Fish>obj : <Bird>obj
}

let pet = getSmallPet();
pet.layEggs(); // okay
// pet.swim();    // errors

5. 字面量类型

枚举类型在使用时需要通过EnumType.EnumValue的方式进行,字面量类型可以实现类似的功能,并借助TS的类型检测机制避免出现“魔法变量”

type Easing = "ease-in" | "ease-out" | "ease-in-out";
function animate(type:Easing):void{}
// animate(123)// error
// animate('ease-xx')// errror
animate('ease-in') // 只能是指定的字符串

目前支持字符串字面量类型和数字字面量类型,字面量类型也被称为单例类型

6. 索引类型

在JavaScript中动态访问对象属性是一种很常见的操作,比如下面方法返回某个对象指定属性名的值列表

function pluck(o, names) {
    return names.map((n) => o[n]);
}
let o = { x: 1, y: 2, z: 3 };
let ans = pluck(o, ["x", "y"]); // [1, 2]

如何通过ts确定ans的类型呢?如何限定names的取值仅限于对象O上存在的属性列表呢?此时就可以用到索引类型

function pluck<T, K extends keyof T>(o:T, names:K[]):T[K][] {
    return names.map((n) => o[n]);
}

let o = { x: 1, y: '2', z: true };
let ans = pluck(o, ["x", "y"]); // ans的类型为 (string | number)[]
pluck(o, ["x", 'xxx']); // error  

这里用到了几个知识点

  • keyof获取类型T上已知的公共属性名的联合类型
  • T[K]是索引访问操作符,类似于o['x'],不过这里返回的是类型,这种类型被称为索引签名

其中索引签名直接获取某个类型下单个属性的类型,例如

type Test = {
    foo: number;
    bar: string
}
type N = Test['foo'] // number

7. 条件类型

TS v2.8引入了条件类型,能够表示非统一的类型

type IsNumber<T> = T extends number ? 'yes' : 'no';

type A = IsNumber<2> // yes
type B = IsNumber<'3'> // no

let a1: A = 'yes' // yes

这是一个非常强大的特性

8. 高级用法

8.1. 动态获取类型 typeof

typeof 获取类型,一种用法是获取类的别名,在我们需要动态地将某个类型赋值给新的变量时很有用

class Greeter{}
let greetMaker : typeof Greeter = Greeter // greetMaker的类型是 Greeter

8.2. 遍历属性类型 keyof

在JavaScript中动态获取属性是非常常见的

function getProperty(obj, key) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };
let a = getProperty(x, 'a')// a 的类型是any,无法对key参数进行约束,因此无法检测是否传入了错误的key

这个时候可以使用keyof,这是用于获取某个类型的属性类型

function getProperty<T>(obj: T, key: keyof T) {
    return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, "a"); // okay
// getProperty(x, "m"); // error: Argument of type 'm' isn't assignable to 'a' | 'b' | 'c' | 'd'.

8.3. infer

infer表示条件类型中的类型推断 ,必须在条件类型中出现。可以理解为在声明类型中的占位符,在后面类型推断时才确定具体类型

type GetParent<T> = T extends infer R ? R: never

type MyNumber = GetParent<number> // MyNumber = number
// 计算逻辑 type Get<number> = number extends infer number ? number: never

下面是一个获取函数参数列表类型Parameters的例子

type TArea = (width: number, height: number) => number;
// Parameters是ts内置类型方法,用于获取参数列表类型
type params = Parameters<TArea>; // params类型为[number, number]

我们可以手动实现Parameters

type Parameters2<T extends (...args: any) => any> = T extends ((...args: infer P) => any ) ? P : never;
// 需要理解的是因为Parameters2计算的是函数参数类型,所以其泛型约束是一个函数
// 在这函数类型约束里面通过infer P 占位,然后就可以获取参数类型了
type params2 = Parameters2<TArea>;

同理,我们可以实现一个ReturnType

type returnTypes = ReturnType<TArea> // 内置

type ReturnType2<T extends (...args: any) => any> = T extends ((...args: any) => infer R) ? R : any;
type returnTypes2 = ReturnType2<TArea>

9. 综合:Vue3中的ref

参考

ref是Vue3中新出现的一个类型。Vue3通过Proxy代理了对象的set和get等方法,从而实现响应式数据;但是对于基础类型而言,则需要通过Ref进行包装才能实现类似的功能。

const count:Ref = ref(2)
count.value // 获取count的值
count.value = 3 // 更新count的值

Ref类型的特殊在于

  • Ref类型可以嵌套,let a = ref(ref(ref(2)))返回的也是Ref类型,因此可以直接通过a.value访问到具体的值而无需使用a.value.value.value
  • 对象属性值可以是Ref类型,let a = ref({x:ref(2)}),却可以直接通过a.value.x访问到x

那么是如何保证typescript在编译时类型推断正确的呢?这一章节就来研究如何声明Ref类型。

首先是最简单的方式

function demo1() {
    // 这里用到了泛型的默认值语法 <T = any>
    type Ref<T = any> = {
        value: T
    }

    // 只看定义,先忽略实现,vscode依旧会帮助我们进行类型推断
    function ref<T>(value: T): Ref<T>

    // 基础用法
    let a = ref(1) // a的类型 Ref<number>

    let b = ref(ref(1)) // b的类型变成了Ref<Ref<number>>,我们希望得到原始的 Ref<number>而不是嵌套
}

然后实现Ref解包,可以通过使用类型约束和条件类型

function demo2() {
    type Ref<T = any> = {
        value: T
    }

    function ref<T>(value: T): T extends Ref ? T : Ref<T>

    let a = ref(ref(1)) // Ref<number>
    let b = ref(ref(ref(1))) // Ref<number>
    let c = ref(ref(ref({ x: 100 }))) // Ref<{x: number;}>

    let d = ref({ x: ref(1) }) // Ref<{x: Ref<number>;}> 然而我们希望对属性值也进行解包,得到 Ref<{x: number;}>
    d.value.x // Ref<{x: number;}>
}

然后处理属性为Ref类型的对象参数,在这一步我们借助infer实现UnwrapRef

function demo3() {
    type Ref<T = any> = {
        value: T
    }
    type UnwrapRef<T> = T extends Ref<infer R> ? R : T

    type x = UnwrapRef<Ref<number>> // number

    function ref<T>(value: T): T extends Ref ? T : Ref<UnwrapRef<T>>


    let d = ref({ x: ref(1) }) // 很可惜,做完这一步, d.value.x的类型还是Ref<number>,因为infer R中并没有判断处理R为Ref的情况,貌似是一个递归问题了
    d.value.x
}

然后处理infer R为Ref类型的情况

function demo4() {
    type Ref<T = any> = {
        value: T
    }
    // 由于TS不支持类型声明递归,可以通过下面这种取巧的方式实现
    // type UnwrapRef<T> = T extends Ref<infer R> ? UnwrapRef<R> : T // 这么写会报错
    type UnwrapRef<T> = {
        ref: T extends Ref<infer R> ? UnwrapRef<R> : T,
        other: T
    }[T extends Ref ? 'ref' : 'other'] // 绕开上面限制,使用索引签名


    type a = UnwrapRef<Ref<number>> // number
    // 首先 T extends Ref ,获取'ref'索引,
    // 然后 T extends Ref<infer R> ? UnwrapRef<R> : T,返回R类型为number,继续UnwrapRef<number>
    // T ex tends Ref 获取'ohter'索引,终止递归,返回类型a = number

    function ref<T>(value: T): T extends Ref ? T : Ref<UnwrapRef<T>>


    let d = ref({ x: ref(1) })
    d.value.x // Ref<number> 我们还需要对Object的每个属性进行UnwrapRef
}

最后,将上面得到的递归方案运用在对象属性上

function demo5(){
    type Ref<T = any> = {
        value: T
    }
    type UnwrapRef<T> = {
        ref: T extends Ref<infer R> ? UnwrapRef<R> : T,
        object: { [K in keyof T]: UnwrapRef<T[K]> }, // 将对象的每一个属性进行解包
        other: T
    }[T extends Ref ? 'ref' : T extends Object ? 'object' : 'other'] // 绕开上面限制,使用索引签名


    function ref<T>(value: T): T extends Ref ? T : Ref<UnwrapRef<T>>

    let d = ref({ x: ref(1) }) // d的类型为 Ref<{x: number;}>,bingo,目标达成
}

至此,就实现了Vue3中Ref类型的实现,可以看见类型声明的高级用法还是很有趣且有用的。

10. 小结

不得不说使用TS开发虽然繁琐了一点,但确实很香,之前一直都是在自己的项目中小打小闹写写TS,没有在正式业务中使用,以至于TS编码水平比较低下,打算接下来在现有项目中逐步引入TypeScript开发~