在绝大部分编程语言中,函数都是一个非常重要的概念,如果缺少了函数,我们的代码可能会变得冗长晦涩,到处夹杂着重复的片段。而在函数中,最重要的概念则是参数,参数是一个函数向外界开放的唯一入口,随着入参的差异,函数可能也会表现出各不相同的行为。

泛型

类型别名能够充当一个变量,存放一组存在关联的类型:

1
type Status = 'success' | 'failure' | 'pending'

其实类型别名还能够充当函数的作用,但函数怎么能没有入参?我们可以这么来为类型别名添加一个入参,也就是泛型。

1
2
3
4
5
type Status<T> = 'success' | 'failure' | 'pending' | T

type CompleteStatus = Status<'offline'>
// CompleteStatus等价于:
type CompleteStatus = 'success' | 'failure' | 'pending' | 'offline'

Status 就像一个函数,它声明了自己有一个参数 T,即泛型,并会将这个参数 T 合并到自己内部的联合类型中。

泛型就是参数作用,只不过它接受的是一个类型而不是值,同时,我们可以把联合类型类比为类型的集合。

在 TypeScript 中,变量与函数都由类型别名来承担,而一个类型别名一旦声明了泛型,就会化身成为函数,此时严格来说我们应该称它为「工具类型」。

自动推导是泛型的强大之处。

示例:我们有一个这样的函数,它的出参与入参类型是完全一致的,比如给我个字符串,我就返回字符串类型,如果是数字,就返回数字类型,此时你会怎么对这个函数进行精确地类型标注,联合类型吗?

1
2
3
function factory(input: string | number): string | number {
// ...
}

首先,这么做会导致你丢失「出参与入参类型完全一致」这个信息,在你使用这个函数时,它只会提醒你返回值可能有字符串和函数,而不会根据你当前的入参给出唯一匹配的那个出参。其次,假设随着需求变更,可能的入参又多了一个布尔值类型,难不成你又要再加一次?一次两次还好,如果后面慢慢到十几个类型………

泛型出马

1
2
3
function factory<T>(input: T): T {
// ...
}

上面出现了 3 个 T,它们的作用分别是什么?
类似于类型别名中,<T>是声明了一个泛型,而参数类型与返回值类型标注中的 T 就是普通的类型标注了。这里的意思是:这个函数有一个泛型 T,当你的函数获得一个入参时,会根据这个入参的类型自动来给 T 赋值,然后同时作为入参与返回值的实际类型。

“自动赋值”以及“同时作为入参与返回值的实际类型”,前者意味着我们无需再操心到底会有哪些可能的类型输入了,后者意味着我们只需要在两处使用同一个泛型参数,就实现了入参与返回值的类型绑定。

1
2
3
4
5
6
function factory<'hello'>(input:"hello"):"hello"{}
factory('hello')

function factory<number[]>(input:number[]):number[] {
}
factory([1,2,3])

一个泛型参数不一定够用啊,万一我可能有多个参数都需要填充泛型,但只有其中的一个泛型参数会被作为返回值类型呢?
我们来声明多个泛型看看:

1
2
3
function factory<T1, T2, T3>(input: T1, arg1: T2, arg2: T3): T1 {
// ...
}

在你给这些参数赋值时,泛型参数 T1 T2 T3 会被分别进行赋值,而只有泛型 T1 会被作为返回值的参数。

这个例子只是为了向你展示如何提供多个泛型参数,本质上它只需要一个泛型参数即可——为什么?我们定义泛型参数是为了在未来的某一刻消费它,比如函数内部的逻辑,比如返回值的类型,在这里只有 T1 参数得到了应用,而 T2 T3 虽然会被填充,但却没有用武之地。因此,切记不要为了使用泛型而使用泛型,确保只在你需要进行上面例子中那样参数与返回值类型的关联时,才使用泛型。

类型世界也有参数。

**.d.ts

类型声明这个概念在 TypeScript 中,需要专门的 .d.ts 文件来进行书写,这里的 d 即是 declaration 声明之意。

1
2
3
4
5
6
declare module "lodash" {
camelCase(string?: string): string;
capitalize(string?: string): string;
endsWith(string?: string): string;
// ...
}

declare module “lodash”,可以称它为模块类型声明,它的作用其实就是告诉 TypeScript ,我们要为模块(module)lodash 进行类型声明(declare),在导入这个模块并访问属性时,你需要提示这个对象上具有 camelCase,capitalize 等方法。

而模块类型声明不仅仅可以声明三方模块,还可以为一些非 JS/TS 类型的文件提供类型声明,比如我们后面会了解到的在 Vite 初始化项目中,为 CSS Modules 提供了类型声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string }

declare module '*.module.css' {
const classes: CSSModuleClasses
export default classes
}
declare module '*.module.scss' {
const classes: CSSModuleClasses
export default classes
}
declare module '*.module.sass' {
const classes: CSSModuleClasses
export default classes
}
// ...

类型声明文件就是一种不包括任何实际逻辑,仅仅包含类型信息,并且无需导入操作,就能够被 TypeScript 自动加载的文件。也就是说,如果定义了类型声明文件,即使你都不知道这个文件放在哪里了,其中的类型信息也能够被加载,然后成为你开发时的类型提示来源。

除了模块声明以外,还有一种常见的声明是对变量的声明。

1
2
3
4
5
declare var window: Window & typeof globalThis

interface Window {
// ...
}

使用 declare var 这么个语法对 window 变量进行了类型声明。declare var 这个语法称为变量类型声明,我们知道 var 声明变量意味着这个变量在全局作用域可用,因此 declare var 自然也是将这个类型声明提供到全局,所以你才能在任何地方都访问到 window 这个变量的类型。

尝试访问 window 的类型,会跳转到 lib.dom.d.ts 文件,lib.xxx.d.ts 文件称为内置类型声明文件,由 ts 官方维护并提供,用于描述 js 语言内置的顶级对象、方法以及 DOM API 等等的类型声明文件。

使用 TypeScript 自带的编译器 tsc ,会发现当你编译一个 TS 文件时,它不仅仅会产生 JS 文件,还会产生一个 .d.ts 文件——也就是我们上面说到的类型声明文件。

1
2
3
4
5
6
7
8
9
10
export const name: string = 'jude'

export function handler(input: string): void {}

export interface IUser {
name: string
age: number
}

export const users: IUser[] = []
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// .js
export const name = 'jude';
export function handler(input) { }
;
export const users = [];

// .d.ts
export declare const name: string;
export declare function handler(input: string): void;
export interface IUser {
name: string;
age: number;
}
export declare const users: IUser[];

从 TS 编译到 JS 的过程中,类型并不是真的全部消失了,而是被放到了专门的类型声明文件里。这样做的目的就是为了和上面 npm 包类型定义一样的效果,也就是在别人是使用 JS 代码调用你的编译产物时,既可以保证直接能够运行,又可以通过类型声明提供完整的类型信息。