TypeScript 不完全中级指南 – 少些 any

1. 引言

TypeScript 为前端开发带来了静态类型检查,让我们可以写出更安全的代码。

但是对于是否使用 TypeScript,每个人都有自己的看法。社区中整体来看开源的库、框架大都会使用 TypeScript,以保证项目的质量和提供更友好的接入使用体验(TS 会提供声明文件,编辑器会有提示和自动补全功能,甚至有基于声明文件生成文档的工具 tsdoc)。

工作中自己参与的项目是否使用 TypeScript, 需要考虑的点有开发人员的能力、业务开发的时间、项目规模等等。对于笔者来说,本人会在 JS SDK 和少部分业务项目使用 TypeScript,其他大部分项目都是直接 JS 开发。

但是 TypeScript 的学习还是很有必要的,至少要有中级水平(能在项目中熟练使用,同时类型体操能完成或看懂中级难度的题目),这能让你前端开发水平更上一层楼。除此之外,鸿蒙 ArkTs 也是基于 TS 拓展,TS 的掌握对参与鸿蒙开发也有很大帮助。

对于 TypeScript 的学习,相信大部分开发都至少有入门水平,故本文的侧重点在于介绍 TypeScript 的中级知识和一些实践技巧。看完本文,希望你有以下提升:

  • 能理解 TypeScript 常见的知识点
  • 能够在项目中更好的使用 TypeScript,减少 any 的使用
  • 能完成简单和部分中等难度的 TypeScript 类型体操

2. 基础

2.1. 类型的父子关系

这块内容个人觉得比较重要,对后面内容理解也有帮助,故放在第一块内容来讲解。

父子类型的判断可分为两种情况:

  1. 类、对象类型:整体上来看子类型会拓展父类型,子类型会有更多的属性方法;类有明显的继承关系,对象是通过鸭子类型来判断。
  2. 联合类型:整体上来看是一个类型缩窄的过程,宽松为父类,类型比较窄为子类型;父子关系和类、对象相比直观上是个相反的过程。

TypeScript 不完全中级指南 - 少些 any

类型的父子关系判断可以通过 extends 来判断;或者实际赋值失败时 TS 会有提示,因为子类型可以赋值给父类型,父类型不能赋值给子类型。

type p1 = { name: string }
type s1 = { name: string, age: number }

type p2 = number | string | boolean
type s2 = number | boolean

type relation1 = s1 extends p1 ? true : false // true
type relation2 = s2 extends p2 ? true : false // true

const sObj: s1 = { name: 'Jack', age: 22 }
const pObj: p1 = sObj // 子类型可以赋值给父类型

const sUnion: s2 = 12
const pUnion: p2 = sUnion // 子类型可以赋值给父类型

TS 赋值关系示例

其他一些常见的父子关系:

  • 具体值是基础类型的子类型:比如 1 是 number 的子类型
  • never 类型是所有类型的子类型
  • undefined 在 tsconfig strictNullChecks 为 true 的情况下是 void 和 any 类型子类型,为 false 的情况下则除 never 的子类型
// 具体值是基础类型的子类型
const num1 = 1
const num: number = num1

// never 类型是所有类型的子类型
let num2 = 2
function returnNever(): never {
  throw new Error()
}
num2 = returnNever()

// undefined 相关
let a: undefined;
let b: number = 1;
let c: void;
let d: any = 'jack'
b = a; // strictNullChecks 为 true 下报错:undefined 不是其他类型子类型;strictNullChecks 为 false 则可以赋值
c = a; // undefined 是 void 类型子类型
d = a; // undefined 是 any 类型子类型

基础类型下,当子类型与父类型组成联合类型时,实际效果等于父类型:

type A = number | 1; // number
type B = never | string; // string

基础类型下,当子类型与父类型组成交集类型时,实际效果等于子类型:

type A = number & 1; // 1
type B = never & string; // never

2.2. 数据类型

数据类型分为以下两类:

  • 基础类型包括:numberstringbooleanbigintsymbolnullundefinedanyunnkonwnevervoid
  • 引用类型有 ArrayFunctionObjectEnumDateMapSetPromise 等等。

基础类型的使用相信大家都会,不再过多介绍,后续仅对 anyunnkonwnevervoid 这四个类型做个介绍。

数组和对象在开发中会经常使用到,声明方式如下:

// 数组声明
const array: number[] = [1, 2]
const array2: Array<number> = [1, 2]

// 元组,数量和类型严格匹配
const array3: [number, string] = [1, '2']

// 对象声明
interface MyObject {
    name: string;
    age?: number; // 可选属性
    readonly id: number; // 只读属性
    update(val: string): void // 方法声明
    update: (val: string) => void // 另一种方法声明
    (): { name: string } // 调用签名
    new (): { name: string } // 构造函数签名
    [prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
}

// 数组通过泛型和索引签名属性定义
interface Array<T> {
    length: number;
    toString(): string;
    [n: number]: T;
}

上述示例中 Array 的声明值得一看,通过 number 类型的索引签名属性定义。函数声明将在后续内容中讲解。

对象中除了 Enum 类型,其他在 JavaScript 中都存在。对于 Enum 类型,除非你非常熟悉它,不然尽量不要使用,你可以使用以下方式(as const)代替:

// Enum
const enum EDirection { Up, Down, Left, Right }

function walk(dir: EDirection) {}
walk(EDirection.Left);

// 通过 as const 来实现
const ODirection = { Up: 0, Down: 1, Left: 2, Right: 3 } as const;

type Direction = typeof ODirection[keyof typeof ODirection];
function run(dir: Direction) {}
run(ODirection.Right);

Enum 和 as const 示例

2.3. any, unknow, never, void

any

any 类型表示变量可以为任何类型,也意味着任何值都可以赋值给 any 类型的变量,当你想跳过 TS 类型检查就可以使用 any。实际开发中,当你不想写复杂类型声明或者不知道如何处理类型报错时,可以使用 any 来跳过类型检查,不过还是建议尽可能少用 any。

let obj: any = { x: 0 };
obj = 'ts'
obj = 3

unknow

unknow 类型代表任何值。它与 any 类型类似,但更安全,因为访问 unknow 的属性或方法都是不合法的:

// 可以重新赋值
let a:unknown = 2
a = 'foo'

function foo(a: any) {
    a.b(); // 类型检查不会报错
}

function f2(a: unknown) {
    // 不允许做操作
    a.b(); // 'a' is of type 'unknown'.'a' is of type 'unknown'.
}

当你的函数的入参或返回值是不确定的时候,你可以使用 unkown 来代替 any,这样当你想操作入参或者返回值,你需要先做类型判断。

function f2(a: unknown) {
    if (typeof a === 'string') {
        return a.substring(1, 2)
    }
    return a
}
f2(1) // 1
f2('abc') // 'bc'

never

never 代表永远不存在的值,如果一个函数执行时抛出了异常,那么这个函数就永远不会有值了(后续代码不会执行),这时函数的返回就是 never。

function fail(msg: string): never {
    throw new Error(msg);
}

还有一个常见的使用场景,在 switch 中使用,用在 default 分支,保证 switch 分支都会有 case 来处理,永远不会到 default 分支,如果代码执行到了 never 分支,静态检查就报错。

switch (animal) {
    case 'cat':
        // ...
        break;
    case 'dog:
        // ...
        break;
    case 'pig':
        // ...
        break;
    default:
        const check: never = letter; // 如果代码执行到这边,静态检查会报错,不能赋值给 never 类型。
        // ...
        break;
}

void

void 表示函数的没有返回值。当函数没有 return 语句,或 return 不带值时,就代表函数返回值为 void 类型。当函数没有返回值时,应该使用 void 而不是 any。

function voidFn(name: string): void {
    console.log(name)
}
const voidVar = voidFn('a')
voidVar.t // 报错

function anyFn(name: string): any {
    console.log(name)
}
const anyVar = anyFn('a')
anyVar.t // 静态检查不会报错

2.4. 函数 Function

函数的类型声明,你可以直接在函数上进行注解,或者通过 interface、type 来进行。

// 直接在函数上注解
function addAge(name: string, age: number, step = 1): number {
    return age + step
}

// 通过 interface 定义
interface IaddAge {
    (name: string, age: number, step?: number): number // 调用签名
}
const iaddAge: IaddAge = (name, age, step = 1) {
    return age + step
}

// 通过 type 定义
type TaddAge = {
    (name: string, age: number, step?: number): number // 调用签名
    new (s: string): { name: string } // 构造函数签名
}
const taddAge: TaddAge = (name, age, step = 1) {
    return age + step
}

// name, age 必传,step 可选
addAge('Tom', 22)
addAge('Tom', 22, 2)

在日常使用过程中,值得注意的是,为回调函数定义类型时,这边会涉及到函数赋值类型兼容性问题。

首先让我们来看下 Array.forEach 是怎么使用的:

const fruits = ['apple', 'banana', 'watermelon']
fruits.forEach((fruit) => console.log(fruit))

forEach 的使用很简单,我们会传入一个回调函数,函数中有三个参数 valueindexarray,上面例子中我们就用到 value,如果让我们来写回调函数类型声明,凭直觉我们可能会这么:

type MyArray = {
    forEach(callbackFn: (value: number, index?: number, array?: Int8Array) => void): void;
};

因为后两个参数可能不会用到,我们会把它们设置为可选参数,代表使用回调函数的时候,可以不用传。但是情况是,我们会发现在使用 indexarray 参数时,静态检查会提示我们需要判断 indexarray 是否为空。

const myArray: MyArray = {
    forEach(callbackFn) { callbackFn(1, 0, [1]) }
}

myArray.forEach((value, index, array) => {
    // 两个错误提示
    // 'array' is possibly 'undefined'
    // Type 'undefined' cannot be used as an index type
    console.log(array[index])
})

回调函数示例

这边就需要考虑到函数赋值兼容性。

函数赋值兼容性可以概括为以下几点(const func = fn):

  • 保证 fn 每一个参数在 func 参数中能找到,即 func 的参数个数大于 fn。
  • fn 参数必须是 func 参数的父类型,调用 func 时传参数最后实际是在 fn 中用,为了兼容,func 的参数必须是子类型,fn 为父类型,子类型才可以赋值给父类型。
  • fn 的返回值是 func 返回值的子类型,返回值则相反,fn 的返回值要兼容 func 返回值,故 fn 的为子类型。

具体可参考此文:一文了解 TS 函数赋值类型兼容性

故我们在定义 forEach 回调函数时,无需把后两个参数设置为可选,实际使用时的传入回调函数少于等于定义的就能兼容。最后可以看看 Array 的类型定义:

interface Array<T> {
    forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
}

除了函数赋值兼容性,泛型函数也需要有一定了解:

function firstEle<T>(arr: T[]): T | undefined {
    return arr[0];
}

通过 <T> 来声明泛型,并在参数和返回类型定义中使用。泛型也可以通过 extends 来限制范围:

type numOrString = number | string
function firstEle<T extends numOrString>(arr: T[]): T | undefined {
    return arr[0];
}

firstEle(['1', 2]) // 只允许是数字或字符串

最后提下函数重载:

// 最后一个函数负责实现,double 只支持 string 或者 number 的类型入参
function double(x: string): string
function double(x: number): number
function double(x: any) { return x + x }

2.5. 联合类型(Union Types)、交叉类型(Intersection Types)

  • 联合类型是由两个或多个其他类型组成的类型,代表的值可以是这些类型中的任意一个。每种类型之间使用 | 符号表示
  • 交叉类型是表示具有两种或多种类型的所有属性的值的类型。每种类型之间使用 & 符号表示。
let x: string | number;
x = 'hello'; // 有效
x = 123; // 有效

type X = { a: string; };
type Y = { b: string; };
type J = X & Y; // 交集
const j: J = {
    a: 'a',
    b: 'b',
};

2.6. 接口 Interfaces

JavaScript 中最常见的就是对象类型,接口 Interface 就是用来描述对象的形状,同时接口也支持继承、泛型和声明合并。

基本用法如下:

interface Base {
    version: number;
}

interface Product extends Base {
    payloadSize: number;
    outOfStock?: boolean; // 可选属性
    readonly body: string; // 只读属性
    
    update(val: string): void // 方法声明
    update(val: boolean): void // 方法声明,支持重载
    update: (val: string) => void // 另一种方法声明,不支持重载
    
    (matcher: boolean): { name: string } // 调用签名
    (matcher: string): { name: string } // 调用签名支持重载
    new (): { name: string } // 构造函数签名
    [prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
    
    get size(): number; // getter 
    set size(value: number | string); // setter
}

Interface 有支持泛型功能,这能让接口的使用更加灵活。

interface User<T> {
    data: T
}

// 支持限制泛型的类型
interface User<T extends { status: number }> {
    data: T
}

接口还支持接口合并,同名的接口会接口声明合并:

interface Box {
    height: number;
    width: number;
}
interface Box {
    scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

声明合并有些限制,除了函数成员外,如果出现同名字段,类型也必须一致,不然会报错,而同名函数支持合并,效果为重载。

interface Document {
    createElement(tagName: any): Element;
}

interface Document {
    createElement(tagName: "div"): HTMLDivElement;
    createElement(tagName: "span"): HTMLSpanElement;
}

interface Document {
    createElement(tagName: string): HTMLElement;
    createElement(tagName: "canvas"): HTMLCanvasElement;
}

// 合并效果,后面声明的重载集排序在前,如果参数类型是单一的字符串,会被提前。
interface Document {
    createElement(tagName: "canvas"): HTMLCanvasElement;
    createElement(tagName: "div"): HTMLDivElement;
    createElement(tagName: "span"): HTMLSpanElement;
    createElement(tagName: string): HTMLElement;
    createElement(tagName: any): Element;
}

更多内容可以查看 cheatsheet

2.7. 类型别名 Type Aliases

类型别名,顾名思义,给声明的类型起个别名。类型别名和接口部分功能上类似,不同点在于:

  • 接口仅用于描述对象的形状,类型别名功能更加丰富,可以支持基础类型、联合类型、交叉类型等等
  • 接口使用 extends 扩展,类型别名使用 & 扩展
  • 接口支持声明合并,同名的接口属性自动合并,类型别名不允许同名
  • 性能方面接口会更好些。

使用哪个取决于自己喜好,官方提供一个启发式建议,优先使用 interface,如果不满足你需求,则使用 type。

类型别名的基础使用方法:

 type Product = {
    payloadSize: number;
    outOfStock?: boolean; // 可选属性
    readonly body: string; // 只读属性
    
    update(val: string): void // 方法声明
    update(val: boolean): void // 方法声明,支持重载
    update: (val: string) => void // 另一种方法声明,不支持重载
    
    (matcher: boolean): { name: string } // 调用签名
    (matcher: string): { name: string } // 调用签名支持重载
    new (): { name: string } // 构造函数签名
    [prop: string]: string; // 索引签名属性,key 支持 string, number, symbol
    
    get size(): number; // getter 
    set size(value: number | string); // setter
}

可以看到,类型别名描述对象时和接口一致。不过类型别名可以支持更多声明:

// 基础值
type NoFound = 404;
type Input = string;

// 元组
type DATA = [string, number]

// 联合、交叉类型
type Size = 'big' | 'small'
type Rect = { x: number } & { y: number } 

// 从其他别名获取类型
type Response = { data: { items: string[] } }
type Data = Response["data"]

// 从具体值获取类型
const obj = { x: 2, y: 'test' }
type Data = typeof obj

更多内容可以查看 cheatsheet

2.8. 类 Class

TypeScript 中的类和 ES6 类使用上差不多,TS 拓展了抽象类、成员可见性(public、private、protected)、泛型等的能力。

有一点可以关注下,class 定义出来的既是一个值,也是一个类型。

class A {
  name: string = 'jack'
}

const a = new A() // A 作为正常 class 值使用
const a2:A = { name: 'obj' } // 作为类型使用,TS 是类型结构,可以正常赋值。

更多内容可以查看 cheatsheet

2.9. 类型断言 Type Assertions

有时候 TypeScript 无法获知的值类型信息。例如,如果你使用 document.getElementById,TypeScript 只知道它会返回某种 HTMLElement,并不知道具体的是什么 HTML 元素。

这种情况下,你可以使用类型断言来指定更具体的类型:

// 两种语法都行
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");

需要注意的是 TypeScript 只允许把类型转换为更具体或更不具体的版本,例如这种 const x = "hello" as number; 转换是不允许的,不过你也可以绕过他 const x = "hello" as any as number;

2.10. 类型缩小 Narrowing

如果你的函数支持多种类型的入参,在使用参数时就可能需要用到类型缩小来保证正确地使用参数。

function getLen(x: string | number): number {
    // Property 'length' does not exist on type 'string | number'.  
    // Property 'length' does not exist on type 'number'.
    return x.length
}

上述 getLen 方法中参数为 stringnumber,在尝试获取参数的 length 属性时,TS 就会给出报错提示,这时我们就需要类型缩小。

JS 中常用的三种类型判断方式:typeof, instanceof, Object.prototype.toString.call。TS 的类型缩小支持前两种方式,当然除此之外,TS 还有其他的类型缩小判断方式。

// typeof
function getLen(x: string | number): number {
    if (typeof x === 'string') { // 使用 typeof 将类型缩小到 string
        return x.length
    }
    return x.toString().length // 剩下的只可能是 number 类型
}

// instanceof
function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
  } else {
    console.log(x.toUpperCase());
  }
}

其他类型缩小方式还有:

  • in 操作符
  • 全等判断
  • 类型谓词 is
  • 根据对象某个属性判断
// in 操作符
type Dog = { run: () => void };
type Bird = { fly: () => void };

function move(animal: Dog | Bird) {
    if ("run" in animal) {
        return animal.run();
    }
    return animal.fly();
}

// 全等判断
function example(x: string | number, y: string | boolean) {
    if (x === y) { // 全等只可能出现在 x 和 y 都是 string 的情况下
        x.toUpperCase();
        y.toLowerCase();
    }
}

// 类型谓词 is,函数返回 true 的时候 pet 为 Dog
function isFish(pet: Dog | Bird): pet is Dog {
    return (pet as Dog).run !== undefined;
}

// 根据对象某个属性判断,称为标签联合
interface Circle {
    kind: "circle";
    radius: number;
}
interface Square {
    kind: "square";
    sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
  }
}

从最后一个标签联合示例来看,我们平时要使用接口的联合而不是联合的接口,接口的联合更有利于做类型缩小。

// 联合的接口,我们仅根据 kind 无法判断是否有 radius 或 sideLength 属性。
type Shape {
    kind: "circle" | "square";
    radius?: number;
    sideLength?: number;
}

3. 类型操作 Type Manipulation

TS 允许通过组合各种类型操作符来表达复杂的操作和值,这块内容的学习也是开启类型体操的基础。

TS 中内置很多工具类型,你可以查阅 Utility Types 文档 来了解,用的比较多的如 PickOmitExclude 等等,内置的字符串操作有 UppercaseLowercaseCapitalize, Uncapitalize

3.1. 泛型

泛型为 TS 提供了灵活的类型声明能力,使用时在变量名后边用 <> 标识:

function toArray<T>(arg: T): T[] {
    return [arg]
}

toArray(2) // number[]

// 多个泛型参数,也支持默认值
type Func<T, K = string> = {
    (arg: T): K
}

// 对泛型类型限制,类型必须有 length 参数
function getLen<T extends { length: number }>(arg: T): number {
    return arg.lenth
}
getLen({ length: 3 })
getLen([2])

3.2. 条件类型

条件类型的形式有点像 JavaScript 中的条件表达式 (condition ? trueExpression : falseExpression),不过条件类型通过 extends 关键字来处理前面的条件:

// 前面泛型有提到 extends 也可以用来约束泛型的类型
type NameOrId<T extends number | string> = T extends number
    ? { id: T }
    : { label: string };
  
// 传入的类型是数组,就返回数组元素的类型
type Flatten<T> = T extends any[] ? T[number] : T

上述例子条件判断会根据泛型类型,来决定最终的返回类型,关键点就是通过 extends 来实现。

当泛型实际是联合类型时,extends 会对每个联合类型项依次进行判断,最终也是返回联合类型。

type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>; // 返回为 string[] | number[]

// 如果你想要得到 string[] | number[]
type Arr = ToArray<string> | ToArray<number>

// 或者 ToArray 条件判断加个方括号,此时联合类型会作为一个整体,而不是一个个遍历。
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

extends 的常用的几个地方:

  • 类继承或者接口扩展 class A extends BaseCalss {}interface A extends baseA {}
  • 泛型限制:getLen<T extends { length: number }>
  • 条件类型:T extends number ? true : false

学到这里,你可以试试以下题目:

  • 实现 Exclude。回顾:基础类型下,当子类型与父类型组成联合类型时,实际效果等于父类型,type B = never | string // 等同 type B = string
  • 实现 If

3.3. keyof,typeof,类型索引访问

  • keyof 接受一个对象声明类型,返回其 key 值的联合。
type Point = { x: number; y: number };
type P = keyof Point; // 'x' | 'y'

const point = { x: 2, y: 2 }
// 只能作用于 TS 声明类型,不支持 JS/TS 具体值。
type P2 = keyof point; // 'point' refers to a value, but is being used as a type here.

type Mapish = { [k: string]: boolean };
// 对象是属性也可以通过数值访问 obj[0] === obj["0"]
type M = keyof Mapish; // string | number

keyof 语法只能接受 TS 类型,对于具体值使用该语法,则会报错。

  • typeof 接受一个具体的值,返回其类型结构,它作用是把值转换为类型。JS 中也有 typeof,它接受具体值,返回的也是值,而不是类型。
// 在正常代码中使用 typeof,此时是 JS 原生 typeof,返回的是个值,不可用于类型声明;
const type = typeof "Hello world" // string
let n: type; // 报错 'type' refers to a value, but is being used as a type here

const s = "hello";
// 在类型声明的位置使用,则是 TS typeof,返回的也是一个类型
let n: typeof s; // string

// as const 会推断到具体值
const arr = [1, 2] as const
type c = typeof arr // readonly [1, 2]

上述示例中可以看到 TS 一般会为你推断一个较为宽泛的类型,如果你使用 as const,这时就会推断到具体值类型。

  • 类型索引访问 即通过索引访问声明类型的属性,使用和 JS 对象访问类似,不过类型声明这边只能通过方括号[]访问。
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"]; // number
// 支持联合类型
type I1 = Person["age" | "name"]; // string | number

// 对于数组,我们可以使用 number 关键字来获取其所有类型,数组中有多个类型会返回联合类型
type Arr = [string, number] // 元组
type Second = Arr[1] // number
type ArrType = Arr[number] // string | number

// 使用 typeof 和 number 来获取数组元素的类型
const MyArray = [
    { name: "Alice", age: 15 },
    { name: "Bob", age: 23 },
    { name: "Eve", age: 38 },
];
type Person = typeof MyArray[number]; // { name: string; age: number; }

例子中可以看到类型索引访问,我们不仅可以一个字符串,也可以传入联合类型,同时也可以通过关键字 number 来获取数组所有元素类型的联合。

学到这里,你可以试试以下题目:

3.4. 映射类型

有时一个类型需要基于另一个类型,这时可以通过映射类型语法来实现,映射类型的语法以索引签名为基础。

// keyof 用于获取 Type 对象所有的 key 值,是个联合类型,再通过 in 语法遍历这些 key 值。
type OptionsFlags<Type> = {
    [Property in keyof Type]: boolean;
};

type Features = {
    darkMode: () => void;
    newUserProfile: () => void;
};

type FeatureOptions = OptionsFlags<Features>;
// FeatureOptions = { darkMode: boolean; newUserProfile: boolean }

在这过程你也可以对属性名称增加一些修饰符:

// 通过 readonly 将属性变成只读,Type[Property] 就是前面提到的索引类型访问
type CreateImmutable<Type> = {
    readonly [Property in keyof Type]: Type[Property];
};

// 通过 -readonly 移除属性的只读修饰
type CreateMutable<Type> = {
    -readonly [Property in keyof Type]: Type[Property];
};

// 通过 ? 将属性变成可选
type Option<Type> = {
    [Property in keyof Type]?: Type[Property];
};

// 通过 ?- 移除属性可选
type Concrete<Type> = {
    [Property in keyof Type]-?: Type[Property];
};

key 值可以通过 as 来改变:

// 将 Property 调整为 `get${Capitalize<string & Property>}`
type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
    name: string;
    age: number;
}

type LazyPerson = Getters<Person>;
// type LazyPerson = {  getName: () => string;  getAge: () => number;  }

Capitalize 为 TS 内置的工具类型,功能为首字母大写。

string & Property 保证入参是 string,前面提到交叉类型取子类型,不匹配为 never,never 的 key 值会自动过滤。

学到这里,你可以试试以下题目:

3.5. 类型推断

类型推断通过 infer 关键字进行,先看一段代码

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

type numberPromise = Promise<number>;
type n = numberPromise extends Promise<infer P> ? P : never; // number

上述代码作用是获取函数返回值的类型,通过 infer 来标记返回泛型,infer 必须在 extends 右侧使用,推断出的类型 R 在条件语句为 true 的分支中使用,false 分支中不能使用。

学到这里,你可以试试以下题目:

3.6. 数组解构和递归

数组解构的用法和 JS 的解构类似,一般会配合 infer 使用

type FirstType<T> = T extends [infer R, ...infer Rest] ? R : never;
type A = FirstType<[number, string]> // number

递归就是在函数中某个条件下再次调用函数本身,我们基于上述例子来实现获取所有元组类型的声明,当然元组类型获取你可以直接使用 T[number]

type GetType<T> = T extends [infer R, ...infer Rest]
    ? Rest extends [] ? R : (R | GetType<Rest> )
    : never;
type A = GetType<[number, string, boolean]> // type A = string | number | boolean

递归的关键在于判断 Rest 是否为空数组,为空情况下返回 R,不为空就继续调用 GetType,并将返回值和 R 组成联合类型。

学到这里,你可以试试以下题目:

3.7. 模板字面类型

模板字面类型以字符串字面类型为基础,并能通过联合扩展为多个字符串,语法与 JavaScript 中的模板字面字符串相同,直接看代码。

type Account = "21323" | "56578";
type Email = `${Account}@qq.com`; // "21323@qq.com" | "56578@qq.com"

type EmailSuffix = "qq.com" | "163.com"
// "21323@qq.com" | "56578@qq.com" | "21323@163.com" | "56578@163.com"
type EmailMore = `${Account}@${EmailSuffix}`

看代码即可明白模板字面类型用法,联合类型的每个值都会执行一次,多个联合类型的话就会交叉相乘。

字符串也有类似解构的能力:

// 使用 infer R 代表第一个字母,最后一个 infer 声明的 Rest 代表剩余的所有字母
// 和数组解构不同的是,无需使用 ...
type Rest<T extends string> = T extends `${infer R}${infer Rest}` ? Rest : '' 
type a = Rest<'abcd'> // typa a = 'bcd'

// 具体字符解析,唯一个 infer 声明的代表其余字母串
type endA<T extends string> = T extends `${infer Rest}a` ? T : ''
type c = endA<'cba'> // 'cba'
type c1 = endA<'bc'> // ''

学到这里,你可以试试以下题目:

4. 模块 Modules

和 ES6 一样,在 TypeScript 中,任何包含顶级 importexport 的文件都被视为模块。相反,没有顶级 importexport 就会被当作全局模块,所以你的项目有时会有个 global.d.ts 文件可提供全局声明。

如果你当前文件没有任何导入或导出,但你又希望它是一个模块,可以添加一行:

export {};

TS 的导入和导出的使用和 ES6 模块一致,不过额外支持了 import type 类型,让类型引入会更清晰。

// animals.ts
export type Cat = { breed: string; yearOfBirth: number };
export interface Dog {
    breeds: string[];
    yearOfBirth: number;
}

// app.ts
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

// app.ts
// 声明导入还支持 import type 语法
import type { createCatName } from "./animal.js"
// ts 4.5 以上可在花括号中加 type
import { createCatName, type Cat, type Dog } from "./animal.js"; 

在 ES6 之前,JavaScript 并没有官方的模块系统,所以 TypeScript 引入了命名空间和三斜线导入语法。

namespace foo {
    export function bar() {} // 通过 export 暴露对象
}

/// <reference path="other.ts />
// 其他文件引入使用
foo.bar()

想更多了解可以看这篇文章:在 TypeScript 中使用 namespace

正常情况下你无需使用这两个语法,应该使用 ES6 模块语法。

5. 编写声明文件

有时,会有一些依赖的第三方库没有提供 TS 声明文件(一般是早期的一些库,现代库基本都有提供 TS 声明文件),这时你就是自己为第三方库写一些类型声明。

编写的声明主要包括变量、函数、对象等,通过关键字 declare 进行。

console.log('welcome', lang)
// 变量通过 declare var 声明
declare var lang: string;

greet("hello, world")
// 函数通过 declare function 声明
declare function greet(greeting: string): void;

let result = myLib.makeGreeting("hello, world");
let count = myLib.numberOfGreetings;
// 对象通过 declare namespace 声明
declare namespace myLib {
    function makeGreeting(s: string): string;
    let numberOfGreetings: number;
}

// 声明 class
declare class Animal {
  constructor(name:string);
  eat():void;
  sleep():void;
}

如果你的 window 上挂了一些变量,你可以通过类型声明合并来扩展:

interface Window { test: string; }

window.test

如果我们使用 import 导进来使用的第三方库没有类型声明文件,这时可以通过 declare module 的方式为它定义类型。

import { Foo, readFile } from 'moduleA';

declare module 'moduleA' {
  // 声明接口
  interface Foo {
    custom: {
      prop1: string;
    }
  }
  // 声明方法
  function readFile(filename:string):string;
  // 变量
  let numberOfGreetings: number;
}

上节模块介绍中 namaspace 会使用 export 导出变量,不过对于 declare namespace, declare module 来说,加不加 export 关键字都可以。

当你 TS 中 import 导入图片使用时,也需要做个声明:

declare module "*.jpb"
declare module "*.png"
declare module "*.svg"

更多内容可以参考:声明文件

6. TSConfig

TSConfig 的配置项非常多,我们可以先了解一些常用的配置项。

strict 模式

strict 标志可以启用多种类型检查行为,从而更有力地保证程序的正确性。

strict 模式包含的最常见两个配置项为 strictNullChecksnoImplicitAny。一般来说,我们都会开启这两个配置。

  1. strictNullChecks

该配置项会影响 nullundefined 的使用。strictNullChecksfalse 时,nullundefined 可以赋值给任何值,同时静态检查也会忽略它们。这意味着以下代码可以正常通过静态检查的,但是实际代码是不安全的。

const users = [
  { name: "Oby", age: 12 },
  { name: "Heera", age: 32 },
];

const loggedInUsername = 'ts'
const loggedInUser = users.find((u) => u.name === loggedInUsername);
console.log(loggedInUser.age); // loggedInUser 可能为 undefined,这边就会报错

let str = 'strictNullChecks'
str = null // null 可以赋值给任何类型,调用 str.substring 时就会报错

可以看下 strictNullChecks 不同情况下 find 方法的返回值的定义:

// strictNullChecks: true
type Array = {
    find(predicate: (value: any, index: number) => boolean): S | undefined;
};

// strictNullChecks: false
type Array = {
    find(predicate: (value: any, index: number) => boolean): S;
};

可以看到 strictNullChecks=true 时返回类型为 S | undefined。故 TSConfig 配置中,strictNullChecks 正常情况下都会设置为 truestrictNullChecks 体验地址

  1. noImplicitAny

在某些没有类型注解的情况下,当 TypeScript 无法推断变量的类型时,它会判断为 any 类型。

function fn(s) {
  // No error?
  console.log(s.subtr(3));
}

fn(42);

如果开启它,TS 将对被隐式推断为 any 的变量发出错误信息。

function fn(s) {
    // ts 发出错误提示,s 为隐式的 any。
    console.log(s.subtr(3));
}

target

target 属性用于指定 TypeScript 应编译到哪个版本的 JavaScript ECMAScript 版本。对于现代浏览器,ES6是一个不错的选择,对于较旧的浏览器,建议使用 ES5。

编译也只转换语法,不处理 Promise 等 Polyfill,需要使用 corejs。

lib

lib 属性用于指定编译时要包含哪些声明文件,默认会根据 target 设置。

如果你 target 设置成 ES5,你在使用 Promise 的时候 TS 就会有报错,这时就需要设置 lib: ["ES2015"] 保证有全局的 Promise 声明。

{
    "target": "ES5",
    "lib": ["DOM", "ES6"], 
}
// a.ts
new Promise<boolean>((resolve) => {
  resolve(true)
})

// a.js
"use strict";
// 箭头函数被转化,Promise 需要自己做 polyfill
new Promise(function (resolve) {
    resolve(true);
});

esModuleInterop

ESM 和 CJS 的模块对于默认 default 有些差异,当 ESM 模块导入 CJS 模块时,TS 需要这么写:import * as React from 'react'

开启 esModuleInterop 配置后,TS 会帮你处理差异,你可以直接使用 import React from 'react'

查看更多: esModuleInterop 到底做了什么?

module

指定编译后代码使用的模块化规范,常见有 commonjs, umd, es6, es2020, esnext 等等。

ES 几个选项的差异:除了 ES2015/ES6 的基本功能外,ES2020 还增加了对动态导入和 import.meta 的支持,而 ES2022 则进一步增加了对顶层 await 的支持。

skipLibCheck

开启后,TS 会跳过对第三方包的类型检查,不过仍会根据这些包提供的类型定义检查您的代码。

files

指定被编译文件的列表,只有需要编译的文件少时才会用到,一般直接用 include 指定文件夹。

{
  "files": ["main.ts", "supplemental.ts"]
}

include

用来指定哪些 ts 文件需要被编译,路径是相对于 tsconfig.json 文件的目录解析。

{
  "include": ["src/**/*", "tests/**/*"]
}

支持的路径通配符:

  • * 可匹配零个或多个字符(不包括目录分隔符)
  • ?匹配任何一个字符(不包括目录分隔符)
  • **/ 匹配嵌套到任何层级的任何目录

exclude

不需要被编译的目录,也支持路径通配符

7. 总结

对于业务开发来说,TypeScript 的掌握够用就行,要是对自己有更好要求可以去学习更多高级用法,同时有时间的话通读 TypeScript官方文档 是极好的。

文章中内容如有错误,烦请指正。文中 TS 类型体操遇到困难也欢迎讨论。

原文链接:https://juejin.cn/post/7350957295192440841 作者:晓得迷路了

(0)
上一篇 2024年3月28日 上午10:21
下一篇 2024年3月28日 上午10:34

相关推荐

发表回复

登录后才能评论