结合实例学习 Typescript

17,517 阅读23分钟

这篇文章将通过简单实例介绍开发中常见的问题,希望能帮助你更好理解 Typescript。由于介绍 Typescript 基础知识的文章很多,官方文档本身也很全面,关于 Typescript 的基础本文就不再赘述。

为什么要使用 Typescript?

在正文开始前,想思考一个问题,为什么要使用 Typescript?

在没有 Typescript 以前,大部分项目都是使用原生 Javascript 开发。而 Javascript 天生是一门“灵活”的语言。“灵活”表现在代码里可以肆无忌惮干任何事,比如拿数字和数组做求和运算,可以调用对象上不存在的方法,给函数传入不符合预期的参数等等,而这些显而易见的问题编码阶段不会有任何错误提示。

const number = 1;
const arr = [1, 2, 3];

console.log(number + arr);

const obj = {};
obj.functionNotExist();

function pow2(value) {
  return Math.pow(value, 2);
}
pow2("bazzzzzz");

在大型项目中,一个类型“小改动”可能会导致很多处代码需要跟着调整,光靠肉眼发现很难很难。我们使用 Typescript 的主要目的就是【类型安全】(type-safe),借助类型声明避免程序做错误的事情。

const number = 1;
const arr = [1, 2, 3];

console.log(number + arr); // 运算符“+”不能应用于类型“number”和“number[]”。

const obj = {};
obj.noExistFunction(); // 类型“{}”上不存在属性“noExistFunction”。

function pow(value: number) {
  return Math.pow(value, 2);
}
pow("bazzzzzz"); // 类型“string”的参数不能赋给类型“number”的参数。

微妙区别

在 Typescript 中一些关键字,概念存在一些微妙的区别,理解它们有助于编写更好的代码。

any vs unknown

any 表示任意类型,这个类型会逃离 Typescript 的类型检查,和在 Javascript 中一样,any 类型的变量可以执行任意操作,编译时不会报错。 unknown 也可以表示任意类型,但它同时也告诉 Typescript 开发者对其也是一无所知,做任何操作时需要慎重。这个类型仅可以执行有限的操作(==、=== 、||、&&、?、!、typeof、instanceof 等等),其他操作需要向 Typescript 证明这个值是什么类型,否则会提示异常。

let foo: any
let bar: unknown

foo.functionNotExist()
bar.functionNotExist() // 对象的类型为 "unknown"。

if (!!bar) { // ==、=== 、||、&&、?、!、typeof、instanceof
  console.log(bar)
}

bar.toFixed(1) // Error

if (typeof bar=== 'number') {
  bar.toFixed(1) // OK
}

any 会增加了运行时出错的风险,不到万不得已不要使用。表示【不知道什么类型】的场景下使用 unknown。

{} vs object vs Object

object 表示的是常规的 Javascript 对象类型,非基础数据类型。

declare function create(o: object): void;

create({ prop: 0 }); // OK
create(null); // Error
create(undefined); // Error
create(42); // Error
create("string"); // Error
create(false); // Error
create({
  toString() {
    return 3;
  },
}); // OK

{} 表示的非 null,非 undefined 的任意类型。

declare function create(o: {}): void;

create({ prop: 0 }); // OK
create(null); // Error
create(undefined); // Error
create(42); // OK
create("string"); // OK
create(false); // OK
create({
  toString() {
    return 3;
  },
}); // OK

Object 和 {} 几乎一致,区别是 Object 类型会对 Object 原型内置的方法(toString/hasOwnPreperty)进行校验。

declare function create(o: Object): void;

create({ prop: 0 }); // OK
create(null); // Error
create(undefined); // Error
create(42); // OK
create("string"); // OK
create(false); // OK
create({
  toString() {
    return 3;
  },
}); // Error

如果需要一个对象类型,但对对象的属性没有要求,使用 object。{} 和 Object 表示的范围太泛尽量不要使用。

type vs interface

两者都可以用来定义类型。

interface(接口) 只能声明对象类型,支持声明合并(可扩展)。

interface User {
  id: string
}
 
interface User {
  name: string
}
 
const user = {} as User
 
console.log(user.id);
console.log(user.name);

type(类型别名)不支持声明合并、行为有点像const, let 有块级作用域。

type User = {
  id: string,
}

if (true) {
  type User = {
    name: string,
  }

  const user = {} as User;
  console.log(user.name);
  console.log(user.id) // 类型“User”上不存在属性“id”。
}

type 更为通用,右侧可以是任意类型,包括表达式运算,以及后面会提到的映射类型等等。

type A = number
type B = A | string
type ValueOf<T> = T[keyof T];

如果你是在开发一个包,模块,允许别人进行扩展就用 interface,如果需要定义基础数据类型或者需要类型运算,使用 type。

enum vs const enum

默认情况下 enum 会被编译成 Javascript 对象,并且可以通过 value 反向查找。

enum ActiveType {
  active = 1,
  inactive = 2,
}

function isActive(type: ActiveType) {}
isActive(ActiveType.active);

// ============================== compile result:
// var ActiveType;
// (function (ActiveType) {
//     ActiveType[ActiveType["active"] = 1] = "active";
//     ActiveType[ActiveType["inactive"] = 2] = "inactive";
// })(ActiveType || (ActiveType = {}));
// function isActive(type) { }
// isActive(ActiveType.active);

ActiveType[1]; // OK
ActiveType[10]; // OK!!!

cosnt enum 默认情况下不会生成 Javascript 对象而是把使用到的代码直接输出 value,不支持 value 反向查找。

const enum ActiveType {
  active = 1,
  inactive = 2,
}

function isActive(type: ActiveType) {}
isActive(ActiveType.active);

// ============================== compile result:
// function isActive(type) { }
// isActive(1 /* active */);

ActiveType[1]; // Error
ActiveType[10]; // Error

enum 中括号索引取值的方式容易出错,相对 enum,const enum 是更安全的类型。

脚本模式和模块模式

Typescript 存在两种模式,脚本模式(Script)一个文件对应一个 html 的 script 标签,模块模式(Module)下一个文件对应一个 Typescript 的模块。区分的逻辑是,文件内容包不包含 import 或者 export 关键字。

了解这两种模式的区别有助于理解编写演示代码时的一些“怪异”现象。

脚本模式下,所有变量定义,类型声明都是全局的,多个文件定义同一个变量会报错,同名 interface 会进行合并。而模块模式下,所有变量定义,类型声明都是模块内有效的。

两种模式在编写类型声明时也有区别,例如脚本模式下直接 declare var GlobalStore 即可为全局对象编写声明。

GlobalStore.foo = "foo";
GlobalStore.bar = "bar"; // Error

declare var GlobalStore: {
  foo: string;
};

而模块模式下,要为全局对象编写声明需要 declare global

GlobalStore.foo = "foo";
GlobalStore.bar = "bar";

declare global {
  var GlobalStore: {
    foo: string;
    bar: string;
  };
}

export {}; // export 关键字改变文件的模式

类型运算

这一章介绍 Typescript 中常见的类型运算符。

集合运算

& 在 JS 中表示位与运算符,在 Typescript 中用来计算两个类型的交集。

type Type1 = "a" | "b";
type Type2 = "b" | "c";
type Type3 = Type1 & Type2; // 'b'

| 在 JS 中表示位或运算符,在 Typescript 中用来计算两个类型的并集。

type Type1 = "a" | "b";
type Type2 = "b" | "c";
type Type3 = Type1 | Type2; // 'a' 'b' 'c'

索引签名

索引签名可以用来定义对象内的属性、值的类型,例如定义一个 React 组件,允许 Props 可以传任意 key 为 string,value 为 number 的 props。

interface Props {
  [key: string]: number
}

<Component count={1} /> // OK
<Component count={true} /> // Error
<Component count={'1'} /> // Error

类型键入

类型键入允许 Typescript 像对象取属性值一样使用类型。

type User = {
  userId: string
  friendList: {
    fristName: string
    lastName: string
  }[]
}

type UserIdType = User['userId'] // string
type FriendList = User['friendList'] // { fristName: string; lastName: string; }[]
type Friend = FriendList[number] // { fristName: string; lastName: string; }

在上面的例子中,我们利用类型键入的功能从 User 类型中计算出了其他的几种类型。FriendList[number] 这里的 number 是关键字,用来取数组子项的类型。在元组中也可以使用字面量数字得到数组元素的类型。

type Tuple = [number, string]
type First = Tuple[0] // number
type Second = Tuple[1] // string

typeof value

typeof 关键字在 JS 中用来获取变量的类型,运算结果是一个字符串(值)。而在 TS 中表示的是推算一个变量的类型(类型)

let str1 = 'fooooo'
type Type1 = typeof str1 // type string

const str2 = 'fooooo'
type Type2 = typeof str2 // type "fooooo"

typeof 在计算变量和常量时有所不同,由于常量时不会变的,所以 Typescript 会使用严格的类型,例如下面 Type2 的例子,str2 的是个 'fooooo' 类型的字符串。而变量会是宽松的字符串类型。

keyof Type

keyof 关键字可以用来获取一个对象类型的所有 key 类型。

type User = {
  id: string;
  name: string;
};

type UserKeys = keyof User; //"id" | "name"

enum 在 Typescript 中有一定的特殊性(有时表示类型,又是表示值),如果要获取 enum 的 key 类型,需要先把它当成值,用 typeof 再用 keyof。

enum ActiveType {
  Active,
  Inactive
}

type KeyOfType = keyof typeof ActiveType // "Active" | "Inactive"

extends

extends 关键字同样存在多种用途,在 interface 中表示类型扩展,在条件类型语句中表示布尔运算,在泛型中起到限制的作用,在 class 中表示继承。

// 表示类型扩展
interface A {
  a: string
}

interface B extends A { // { a: string, b: string }
  b: string
}

// 条件类型中起到布尔运算的功能
type Bar<T> = T extends string ? 'string' : never
type C = Bar<number> // never
type D = Bar<string> // string
type E = Bar<'fooo'> // string

// 起到类型限制的作用
type Foo<T extends object> = T
type F = Foo<number> // 类型“number”不满足约束“object”。
type G = Foo<string> // 类型“string”不满足约束“object”。
type H = Foo<{}> // OK

// 类继承
class I {}
class J extends I {}

使 A extends B 在布尔运算或泛型限制中成立的条件是 A 是 B 的子集,也就是 A 需要比 B 更具体,至少是跟 B 一样。

type K = '1' extends '1' | '2' ? 'true' : 'false' // "true"
type L = '1' | '2' extends '1' ? 'true' : 'false' // "false"

type M = { a: 1 } extends { a: 1, b: 1 } ? 'true' : 'false' // "false"
type N = { a: 1, b: 1 } extends { a: 1 } ? 'true' : 'false' // "true"

is

is 关键字在 Typescript 用作用户类型防护,可以用来告诉 Typescript 如何辨别类型。例如下面的例子,isFish 方法后面跟着 pet is Fish,这是在告诉 Typescript 当方法运行结果返回 true 时,证明 pet 是用户验证过的 Fish 类型,可以安全地把它认定为 Fish。而返回 false 则表明 pet 不是 Fish,当成 Fish 来使用的时候要长点心。

interface Fish {
  swim: () => {}
}

function isFish(pet: any): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

let pet = {} as unknown

if (isFish(pet)) {
  pet.swim() // OK
} else {
  pet.swim() // 类型“Bird”上不存在属性“swim”
}

其他可用来判断类型的关键字还有 typeof,instanceof, in 等等。

泛型

泛型是 Typescript 中非常重要的知识点。接下来从一个 filter 方法入手介绍泛型。

假设 filter 方法传入一个数字类型的数组,及一个返回布尔值的方法,最终过滤出想要的结果返回,声明大致如下。

declare function filter(
  array: number[],
  fn: (item: unknown) => boolean
): number[];

过了一段时间,需要使用 filter 方法来过滤一些字符串,可以使用 Typescript 的函数重载的功能,filter 内部代码不变,只需要添加类型定义。

declare function filter(
  array: string[],
  fn: (item: unknown) => boolean
): string[];
declare function filter(
  array: number[],
  fn: (item: unknown) => boolean
): number[];

又过了一段时间,需要用 filter 来过滤 boolean[], 过滤 object[], 过滤其他具体类型,如果仍然使用重载的方法将会出现非常多重复的代码。这时候就可以考虑使用泛型了,Dont repeat yourself。

泛型就像 Typescript “语言” 中的“方法”,可以通过“传参”来得到新的类型。日常开发中经常用到的泛型有 Promise、Array、React.Component 等等。

使用泛型来改造 filter 方法:

declare function filter<T>(
  array: T[],
  fn: (item: unknown) => boolean
): T[];

只需要在方法名后面加上尖括号<T>,表示方法支持一个泛型参数,(这里的 T 可以改为任意你喜欢的变量名,大部分人的偏好是从 T、U、V...开始命名),array: T[] 表示传入的第一个参数是泛型模板类型的数组,:T[] 表示方法会返回模板类型的数组。Typescript 将会自动根据传参类型辨别出 T 实际代表的类型,这样就可以保留类型的同时,避免重复代码了。

filter([1, 2, 3], () => true) // function filter<number>(array: number[], fn: (item: unknown) => boolean): number[]
filter(['1', '2', '3'], () => true) // function filter<string>(array: string[], fn: (item: unknown) => boolean): string[]

把泛型比喻成“方法”之后,很多行为都很好理解。“方法”可以传参,可以有多个参数,可以有默认值,泛型也可以。

type Foo<T, U = string> = { // 多参数、默认值
  foo: Array<T> // 可以传递
  bar: U
}

type A = Foo<number> // type A = { foo: number[]; bar: string; }
type B = Foo<number, number> // type B = { foo: number[]; bar: number; }

别忘了,泛型参数还可以有限制,例如下面的例子 extends 的作用是限制 T 至少是个 HTMLElement 类型。

type MyEvent<T extends HTMLElement = HTMLElement> = {
   target: T,
   type: string
}

Typescript 自带了一些泛型工具,下面逐个介绍并附上实现代码。

映射类型

关键字 in

in 关键字在类型中表示类型映射,和索引签名的写法有些相似。下面的例子中声明一个 Props 的类型,key 类型为 'count' | 'id' 类型,value 为 number 类型。

type Props = {
  [key in 'count' | 'id']: number
}

const props1: Props = { // OK
  count: 1,
  id: 1
}

const props2: Props = {
  count: '1', // ERROR
  id: 1
}

const props3: Props = {
  count: 1,
  id: 1,
  name: 1 // ERROR
}

Record

Record 定义键类型为 Keys、值类型为 Values 的对象类型。

示例:

enum ErrorCodes {
  Timeout = 10001,
  ServerBusy = 10002,
  
}

const ErrorMessageMap: Record<ErrorCodes, string> = {
  [ErrorCodes.Timeout]: 'Timeout, please try again',
  [ErrorCodes.ServerBusy]: 'Server is busy now'
}

类型映射还可以用来做全面性检查,例如上面的例子中如果漏了某个 ErrorCodes,Typescript 同样会抛出异常。

enum ErrorCodes {
  Timeout = 10001,
  ServerBusy = 10002,
  AuthFailed = 10003
}

// 类型 "{ 10001: string; 10002: string; }" 中缺少属性 "10003",但类型 "Record<ErrorCodes, string>" 中需要该属性
const ErrorMessageMap: Record<ErrorCodes, string> = { 
  [ErrorCodes.Timeout]: 'Timeout, please try again',
  [ErrorCodes.ServerBusy]: 'Server is busy now'
}

代码实现:

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

Partial

Partial 可以将类型定义的属性变成可选。

示例:

type User = {
  id?: string,
  gender: 'male' | 'female'
}

type PartialUser =  Partial<User>  // { id?: string, gender?: 'male' | 'female'}

function createUser (user: PartialUser = { gender: 'male' }) {}

User 类型对于 gender 属性是要求必须有的(: 用户必须有性别才行。而在设计 createUser 方法时,为了方便程序会给 gender 赋予默认值。这时候可以将参数修改成 Partial<User>,使用者就可以不用必须传 gender 了。

代码实现:

type Partial<T> = {
  [U in keyof T]?: T[U];
};

Required

Required 和 Partial 的作用相反,是将对象类型的属性都变成必须。

示例:

type User = {
  id?: string,
  gender: 'male' | 'female'
}

type RequiredUser = Required<User> // { readonly id: string, readonly gender: 'male' | 'female'}

function showUserProfile (user: RequiredUser) {
  console.log(user.id) // 不需要加 !
  console.log(user.gender)
}

任然使用 User 类型,id 属性定义的时候是可选的(要创建了才有 id),而展示的时候 User id 肯定已经存在了,这时候可以使用 Required<User>,那么调用 showUserProfile 时 User 所有属性都必须非 undefined。

代码实现:

type Required<T> = {
  [U in keyof T]-?: T[U];
};

-? 符号在这里表示的意思是去掉可选符号 ?。

Readonly

Readonly 是将对象类型的属性都变成只读。

示例:

type ReadonlyUser = Readonly<User> // { readonly id?: string, readonly gender: 'male' | 'female'}

const user: ReadonlyUser = {
  id: '1',
  gender: 'male'
}

user.gender = 'femail' // 无法分配到 "gender" ,因为它是只读属性。

代码实现:

type Readonly<T> = {
  readonly [U in keyof T]: T[U];
};

Pick

Pick 是挑选类型中的部分属性。

示例:

type Location = {
  latitude: number
  longitude: number
  city: string
  address: string
  province: string
  district: string
}

type LatLong = Pick<Location, 'latitude' | 'longitude'> //  { latitude: number; longitude: number; }

const region: LatLong = {
  latitude: 22.545001,
  longitude: 114.011712
}

已有一个 Location 类型,而现在只需要经纬度的数据,使用 Pick<Location, 'latitude' | 'longitude'> 创建新的 LatLong 类型。

代码实现:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Omit

Omit 结合了 Pick 和 Exclude,将忽略对象类型中的部分 keys。

示例:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Omit<Todo, "description">; // { title: string; completed: boolean; }

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

代码实现:

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

条件类型

三目运算符

Typescript 类型运算也支持“三目运算符”,称之为条件类型,一般通过 extends 关键字判断条件成不成立,成立的话得到一个类型,不成立的话返回另一个类型。条件类型通常是与泛型同时出现的(:因为如果是已知固定类型就没必要再判断了。

type IsString<T> = T extends string ? true : false

type A = IsString<number> // false
type B = IsString<string> // true

在处理并集时,条件类型还具有条件分配的逻辑,number | string 做条件运算等价于 number 条件运算 | string 条件运算

type ToArray<T> = T[]
type A = ToArray<number | string> // (string | number)[]

type ToArray2<T> = T extends unknown ? T[] : T[];
type B = ToArray2<number | string>; // string[] | number[]

infer

除了显示声明泛型参数,Typescript 还支持动态推导泛型,用到的是 infer 关键字。什么场景下还需要动态推导?通常是需要通过传入的泛型参数去获取新的类型,这和直接定义一个新的泛型参数不一样。

例如现在定义了 ApiResponse 的两个具体类型 UserResponse 和 EventResponse,如果想得到 User 实体类型和 Event 实体类型需要怎么做?

type ApiResponse<T> = {
  code: number
  data: T
};

type UserResponse = ApiResponse<{
  id: string,
  name: string
}>

type EventResponse = ApiResponse<{
  id: string,
  title: string
}>

当然可以拎出来单独定义新的类型。

type User = {
  id: string,
  name: string
}

type UserResponse = ApiResponse<User>

但如果类型是由其他人提供的就不好处理了。这时可以尝试下使用 infer,代码如下:

type ApiResponseEntity<T> = T extends ApiResponse<infer U> ? U : never;

type User = ApiResponseEntity<UserResponse>; // { id: string; name: string; }
type Event = ApiResponseEntity<EventResponse>; // { id: string; title: string; }

示例中,判断传入的类型 T 是不是 T extends ApiResponse<infer U> 的子集,这里的 infer 既是让 Typescript 尝试去理解 T 具体是那种类型的 ApiResponse,生成新的泛型参数 U。如果满足 extends 条件则将 U 类型返回。

充分理解了条件类型和 infer关键字之后,Typescript 自带的条件泛型工具也就很好理解了。

ReturnType

Returntype 用来获取方法的返回值类型

示例:

type A = (a: number) => string
type B = ReturnType<A> // string

代码实现:

type ReturnType<T> = T extends (
  ...args: any[]
) => infer R ? R : any;

Parameters

Parameters 用来获取方法的参数类型

示例:

type EventListenerParamsType = Parameters<typeof window.addEventListener>;
// [type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined]

代码实现:

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any
  ? P : never;

Exclude

Exclude 用来计算在 T 中而不在 U 中的类型

示例:

type A = number | string
type B = string
type C = Exclude<A, B> // number

代码实现:

type Exclude<T, U> = T extends U ? never : T;

Extract

Extract 用来计算 T 中可以赋值给 U 的类型

示例:

type A = number | string
type B = string
type C = Extract<A, B> // string

代码实现:

type Extract<T, U> = T extends U ? T : never;

NonNullable

从类型中排除 null 和 undefined

示例:

type A = {
  a?: number | null
}
type B = NonNullable(A['a']) // number

代码实现:

type NonNullable<T> = T extends null | undefined ? never : T;

NPM相关

Typescript 和 npm 一起怎么工作?

如何发布一个 Typescript 编写的 npm package?

使用 Typescript 来编写 npm 模块可以为使用者提供声明文件,这样写起代码来编译器就能提供类型检查以及代码提示了。只需要做以下几个步:

  1. 在 tsconfig.json 文件中,配置 declaration: true,这样每次 Typescript 编译时都会自动生成声明文件。
  2. 在 package.json,中配置 prepublishOnly script 每次执行 npm publish 时编译 Typescript 代码;
  3. 调整 package.json 中的 main、types 字段指向最终代码路径
// tsconfig.json
{
  "compilerOptions": {
    "declaration": true // 自动生成声明文件 d.ts
  }
}

// package.json
{
  "name": "@scope/awesome-typescript-package",
  "version": "0.0.1",
  "main": "dist/index.js",
  "types": "dist/index.d.ts", // 模块 types 路径
  "scripts": {
    "tsc": "tsc -p ./tsconfig.json",
    "prepublishOnly": "npm run tsc" // 每次执行 npm publish 之前,编译代码
  }
}

顺利发布之后,用户在 import 我们模块代码的时候就会有正确的类型提示了。

code-hint

扩展三方模块补充类型声明

由于 Typescript 社区的火热,大部分流行的 npm package 都会有类型声明文件,或者社区提供的 @types 包 DefinitelyTyped。然而还是会偶尔遇上缺少类型定义的包,或者支持插件扩展的包,这些场景下我们需要添加补充类型声明。

首先修改 tsconfig.json 中的 compilerOptions.types,添加我们自己定义的类型文件路径。例如所有类型文件都在 types 路径下,可以配置 "types": ["./types/*.d.ts"]。

然后在声明文件中,利用声明合并的功能,对相应的模块进行扩展。

// 扩展全局 jQuery,添加插件
declare global {
  interface JQuery {
    myPlugin: MyPlugin
  }
}
 
// JSX 中扩展 Image 特性,可以在不更新版本的情况下使用 Web 新特性
import 'react'

declare module 'react' {
  interface ImgHTMLAttributes<T> {
    loading?: 'lazy2' | 'eager'
  }
}

注意这里需要扩展的是类型,而不是值。例如在 react-native 中,通过 native 端通过 NativeModules(一个 Object) 模块暴露属性或方法给 RN 端。默认情况下它的所有属性都是 any 类型。

import { NativeModules } from 'react-native'
NativeModules.MApplication.version // 默认情况下是 any 类型

想要对 NativeModules 对象进行类型扩展,需要进到 react-native 类型源码中查看,找到它的类型,NativeModulesStatic。然后再在声明文件中补充类型声明。

import { NativeModules } from 'react-native'
 
declare module 'react-native' {
  interface NativeModulesStatic {
    MApplication: {
     version: string
    }
  }
}

加上声明文件之后,Typescript 就能正确识别类型了。

import { NativeModules } from 'react-native'
NativeModules.MApplication.version // string

WTFFFFFF?

在使用 Typescript 的过程中时常会遇到一些奇怪行为,这里记录下经常遇到的问题,并尝试去解释为什么。

为什么 enum/class 可以作为 type 也可以作为 value?

使用 enum/class 的时候是否会感到困惑,为什么它们既可以作为类型,又可以当成值来用。开发者能不能自己实现同样的效果?

const enum ActiveType {
  active = 1,
  inactive = 2,
}

function isActive(type: ActiveType) {} // 类型
isActive(ActiveType.active); // 值

实际上,Typescript 支持类似伴生对象的模式,即把类型和对象配对绑定在一起。使用者可以一起导入二者。

type Currency = {
  unit: "EUR" | "GBP" | "JPY";
  value: number;
};

const Currency = {
  from: (value: number, unit: Currency["unit"] = "EUR"): Currency => {
    return {
      unit,
      value: value,
    };
  },
};

const currency: Currency = Currency.from(10);

实现一个类似 enum 效果的对象

// enum-like.ts
const ActiveTypes = {
  active: "active" as const,
  inactive: "inactive" as const,
};

type ValueOf<T> = T[keyof T];
type ActiveTypes = ValueOf<typeof ActiveTypes>;

export { ActiveTypes };

// index.ts
import { ActiveTypes } from "./enum-like";

function isActive(value: ActiveTypes) {
  console.log(value);
}

isActive(ActiveTypes.active);
isActive('zzzzzzzz'); // Error

为什么字面量会报错,而将字面量赋值之后就不会了

假设现在定义了一个 request 方法,这个方法支持传一个 Options 类型的参数。

type Options = {
  url: string;
  method?: "get" | "post";
};

declare function request(options: Options): void;

request({
  url: "https://juejin.com/api/user",
  foo: "foo", // Error
});

const options1 = {
  url: "https://juejin.com/api/user",
  foo: "foo",
};

request(options1); // OK

对比其他写法,调用 request 方法直接用对象字面量的形式传递参数会报异常,而将对象赋值给临时变量,再将变量传给 request 方法时,异常就消失了。(从 Options 类型上看,理论上主要满足 { url: string } 的类型都可以赋值给 options。)

原因是 `Typescript 会对对象字面量的写法开启【多余属性检查】,进行多余属性检查的好处是可以避免开发者手抖写错参数名。

例如下面这样,开发者错误地将 method 拼写成 mothed,如果没有 Typescript 的报错,排查起来是有点困难的(自己写的单词自己怎么看都顺眼)。

request({
  url: "https://juejin.com/api/user",
  mothed: "post"
});

如果确实需要中间变量传递,则可以在变量声明时给定类型,多余属性检查也同样可以生效。

const options2: Options = {
  url: "https://juejin.com/api/user",
  foo: "foo", // Error
};

为什么无法将 'right' 赋值给 “boolean | "right" | "left" | undefined” 类型

常见的字面量传递导致类型异常的另一个例子,使用 antd Table 组件时的 ‘fixed’ 字段。

const columns = [
  {
    title: 'Available',
    dataIndex: 'available',
    fixed: 'right',
    render: (value: boolean) => {
      return value ? 'Y' : 'N'
    },
  },
]

return (
  <Table
    columns={columns} // 不能将类型“string”分配给类型“boolean | "right" | "left" | undefined”
    dataSource={dataSource}
    rowKey="id"
  />
}

发现经过 columns 的传递,我们发现字符串 'right' 无法给 “boolean | "right" | "left" | undefined” 类型赋值。原因是 Typescript 在做类型推导时会进行【类型拓宽】,故意推导出一个更宽泛的类型。修复的方法就是告诉 Typescript,这里的 'right' 是个常量,不要在传递的过程中将其理解为 string 类型。

// 方式一,加上 as const 类型断言
const columns = [
  {
    title: 'Available',
    dataIndex: 'available',
    fixed: 'right' as const, // right 是个常量
    render: (value: boolean) => {
      return value ? 'Y' : 'N'
    },
  },
]

// 方式二,给变量 columns 加上类型声明,避免 Typescript 自行推断
const columns: ColumnsType<Data>  = [
  {
    title: 'Available',
    dataIndex: 'available',
    fixed: 'right'
    render: (value: boolean) => {
      return value ? 'Y' : 'N'
    },
  },
]

// 方式三,直接赋值给组件,不要中间值处理
<Table
  columns={[
    {
      title: 'Available',
      dataIndex: 'available',
      fixed: 'right'
      render: (value: boolean) => {
        return value ? 'Y' : 'N'
      },
    },
  ]}
  dataSource={dataSource}
  rowKey="id"
/>

为什么 Typescript 分辨不出并集类型

假设现在有一个用户事件处理方法,支持处理两种事件类型,UserInputEvent value 是 string 类型,UserMouseEvent 是 [number, number],当判断 event.value 的类型时为 string 时,直观理解 target 应该就是 HTMLInputElement,然而实际上在 if 代码块里面继续访问 target 时,发现 Typescript 并不无法区分出 target 类型。

type UserInputEvent = {
  value: string;
  target: HTMLInputElement;
};

type UserMouseEvent = {
  value: [number, number];
  target: HTMLElement;
};

type UserEvent = UserInputEvent | UserMouseEvent;

function handle(event: UserEvent) {
  if (typeof event.value === "string") {
    event.value; // string
    event.target; // HTMLInputElement | UserMouseEvent
  }
}

原因是 A | B 在 Typescript 的理解里面不只是 A 或者 B,还有可能是 A,B 混合的类型。在下面的例子中可以为 Cat | Dog 类型的对象同时赋予一个 Cat 的属性,和一个 Dog 的属性。

type Cat = {
  name: string;
  purrs: boolean;
};

type Dog = {
  name: string;
  barks: boolean;
};

// 并集类型中的某一个成员还可以同时属于每个成员
type CatOrDogOrBoth = Cat | Dog;

const a: CatOrDogOrBoth = {  // OK
  name: "foo",
  purrs: false,
  barks: false,
};

为了能正确区分类型,需要使用字面量(字符串、数字、布尔值等)的标记来告诉 Typescript 类型之间是互斥的。

type UserInputEvent = {
  type: "UserInputEvent";
  value: string;
  target: HTMLInputElement;
};

type UserMouseEvent = {
  type: "UserMouseEvent";
  value: [number, number];
  target: HTMLElement;
};

type UserEvent = UserInputEvent | UserMouseEvent;

function handle(event: UserEvent) {
  // 并集类型需要更加明确的推断
  if (typeof event.value === "string") {
    event.value; // string
    event.target; // HTMLInputElement | UserMouseEvent
  }

  if (event.type === "UserMouseEvent") {
    event.value; // string
    event.target; // HTMLInputElement
  }
}

为什么判断了类型,setTimeout 里面仍然会提示错误

假设现在有一个 User 类型,id 为 undefined | string,在 logUserInfo 里面判断了 user.id 的存在,然而继续在 setTimeout 里面执行时却遭到 Typescript 抵赖,认为 id 可能不存在。

type User = {
  id?: string;
};

function logUserInfo(user: User) {
  if (!user.id) {
    return;
  }

  setTimeout(() => {
    log(user.id); //   不能将类型“undefined”分配给类型“string”
  });
}

function log(id: string) {
  console.log(id);
}

原因是在 JS 的事件循环中,setTimeout、方法是在 macroTask 中执行的,和前面判断 !user.id 不在一个调用栈里面,等到方法被执行时,Typescript 无法确信 user 这个引用类型的 id 不会被修改。于是忽略了 if (!user.id) 的判断。

解决方法是可以是加上!断言 id 不会是 undefined

setTimeout(() => {
  log(user.id!);
});

不过这种方式可能会导致多出需要断言(比如有多出需要用到 user.id)

type UserWithoutId = {};

type UserWithId = {
  id: string;
};

type User = UserWithoutId | UserWithId;

function logUserInfo(user: User) {
  if (!("id" in user)) {
    return;
  }

  setTimeout(() => {
    log(user.id);
  });
}

function log(id: string) {
  console.log(id);
}

为什么写了 as 类型断言,Typescript 任然报错?

Typescript 开发过程中经常会使用 as 来做类型断言(通常是 as any),这种操作容易让开发者认为 as 无所不能,类型可以随便断言。其实不是的,例如下面的例子,存在两个类型 Cat(name, purrs)、RobotCat(name, serial)。我们想复用 sayCatName 方法(要求传入一个 Cat 类型),在调用方法前先对 doraemon 做类型断言 as Cat。

这时候 Typescript 会抛出异常,不允许这样的转换!

type Cat = {
  name: string;
  purrs: boolean;
};

type RobotCat = {
  name: string;
  id: string;
}

function sayCatName (cat: Cat) {
  console.log(cat.name)
}

const doraemon: RobotCat = {
  id: '10000',
  name: 'Doraemon'
}

sayCatName(doraemon as Cat) // 类型 "RobotCat" 中缺少属性 "purrs",但类型 "Cat" 中需要该属性

原因是只有用在一个类型是另一个类型的子类型时才可以使用类型断言。由于 any 是万精油一样的类型,任何其他类型都可以它的子类型,所以 any as Cat, Cat as any 都是可以被允许的。而 Cat 和 RobotCat 不能完全重合,Typescript 认为这个类型断言不安全。

在明确代码不会出现异常的情况下,可以进行两次类型断言来避免报错。

sayCatName(doraemon as unknown as Cat)

更好的方式调整方法的类型限制,例如使用 Pick 类型生成 Cat 的父类型,这样 Typescript 只会验证属性 name。

function sayCatName (cat: Pick<Cat, 'name'>) {
  console.log(cat.name)
}

sayCatName(doraemon)

结语

在重新学习 Typescript 的过程中补上以前没留意到的知识点,认识到了一些理解上的误区,同时对某些 Typescript “怪异的行为”有了比较好的认识。希望看完这篇文章也能让你从 Partial<Typescript> 到 Typescript 更近一步。

由于 Typescript 内容实在太多以及笔者个人原因,文章中可能出现瑕疵,遗漏,完整地学习 Typescript 请以官方文档为准。

同时感谢 Typescript 编程这本书,让我学到了很多。

推荐阅读