TypeScript学习笔记(1)

691 阅读14分钟

1、环境搭建

IDE使用VS Code进行安装:

npm i -g typescript
tsc -v // 查看安装的ts版本

npm i -g ts-node //用来直接运行ts文件的node环境

Demo编写:在练习目录下输入tsc --init可以快速创建一个 tsconfig.json 文件或者手动新建一个tsconfig.json 文件。默认配置如下:

{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Basic Options */
    // "incremental": true,                         /* Enable incremental compilation */
    "target": "es5",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
    "module": "commonjs",                           /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    // "lib": [],                                   /* Specify library files to be included in the compilation. */
    // "allowJs": true,                             /* Allow javascript files to be compiled. */
    // "checkJs": true,                             /* Report errors in .js files. */
    // "jsx": "preserve",                           /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
    // "declaration": true,                         /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                      /* Generates a sourcemap for each corresponding '.d.ts' file. */
    // "sourceMap": true,                           /* Generates corresponding '.map' file. */
    // "outFile": "./",                             /* Concatenate and emit output to single file. */
    // "outDir": "./",                              /* Redirect output structure to the directory. */
    // "rootDir": "./",                             /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                           /* Enable project compilation */
    // "tsBuildInfoFile": "./",                     /* Specify file to store incremental compilation information */
    // "removeComments": true,                      /* Do not emit comments to output. */
    // "noEmit": true,                              /* Do not emit outputs. */
    // "importHelpers": true,                       /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,                  /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,                     /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true,                                 /* Enable all strict type-checking options. */
    // "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
    // "strictNullChecks": true,                    /* Enable strict null checks. */
    // "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
    // "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    // "strictPropertyInitialization": true,        /* Enable strict checking of property initialization in classes. */
    // "noImplicitThis": true,                      /* Raise error on 'this' expressions with an implied 'any' type. */
    // "alwaysStrict": true,                        /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    // "noUnusedLocals": true,                      /* Report errors on unused locals. */
    // "noUnusedParameters": true,                  /* Report errors on unused parameters. */
    // "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
    // "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
    // "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
    // "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */

    /* Module Resolution Options */
    // "moduleResolution": "node",                  /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    // "baseUrl": "./",                             /* Base directory to resolve non-absolute module names. */
    // "paths": {},                                 /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
    // "typeRoots": [],                             /* List of folders to include type definitions from. */
    // "types": [],                                 /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,        /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                        /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,                    /* Do not resolve the real path of symlinks. */
    // "allowUmdGlobalAccess": true,                /* Allow accessing UMD globals from modules. */

    /* Source Map Options */
    // "sourceRoot": "",                            /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                               /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,                     /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                       /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,              /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,               /* Enables experimental support for emitting type metadata for decorators. */

    /* Advanced Options */
    "skipLibCheck": true,                           /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true        /* Disallow inconsistently-cased references to the same file. */
  }
}

然后,在练习目录新建一个hello.ts,输入如下代码:

function sayHello(person: string) {
    return "Hello, " + person;
}

console.log(sayHello("user"));

使用 tsc(TypeScript Compiler) 命令将 .ts 文件转译为 .js 文件:

tsc hello.ts

得到hello.js:

function sayHello(person) {
    return "Hello, " + person;
}

console.log(sayHello("user"));

也可以直接使用 ts-node直接运行hello.ts

ts-node hello.ts // Hello, user

注意:指定转译的目标文件后,tsc 将忽略当前应用路径下的 tsconfig.json 配置,因此我们需要通过显式设定如下所示的参数,让 tsc 以严格模式检测并转译 TypeScript 代码。

tsc HelloWorld.ts --strict --alwaysStrict false
tsc HelloWorld.ts --strict --alwaysStrict false --watch // 监听文件内容变更,实时进行类型检测和代码转译

VS Code天然支持TS,注意自带的TS版本和工作区的TS版本差异!!!如果两个版本之间存在不兼容的特性,就会造成开发阶段和构建阶段静态类型检测结论不一致的情况,因此,我们务必将 VS Code 语言服务配置成使用当前工作区的 TS版本。

此外,还可以使用官方提供了的在线开发 TypeScript 的云环境——Playground。无须在本地安装环境,只需要一个浏览器即可随时学习和编写 TypeScript,同时还可以方便地选择 TypeScript 版本、配置 tsconfig,并对 TypeScript 实时静态类型检测、转译输出 JavaScript 和在线执行。

2、简单基础类型

TypeScript 其实就是类型化的 JavaScript,它不仅支持 JavaScript 的所有特性,还在 JavaScript 的基础上添加了静态类型注解扩展。可以这么理解:TypeScript 其实就是 JavaScript 的超集

基本语法

let num: number = 1;

上面的number表示数字类型,:来分割变量和类型的分隔符。number可以换成其他JS原始类型:number、string、boolean、null、undefined、symbol 等。

原始类型

在 JavaScript 中,原始类型指的是非对象且没有方法的数据类型,它包括 string、number、bigint、boolean、undefined 和 symbol 这六种 (补充:BigInt也是原始类型,且注意它和number类型不兼容)

静态类型检测

在编译时期,静态类型的编程语言即可准确地发现类型错误,可以将很多低级错误早发现早治疗。

const goodStr: string = 'abc';
const badStr: string = 123; // 不能将类型“number”分配给类型“string”。ts(2322)

3、复杂基础类型

数组和元组

数组类型(Array)

const arrayOfNumber: number[] = [1, 2, 3];
const arrayOfString: string[] = ['a', 'b', 'c'];

// 或者使用泛型。但是更推荐上面的写法,因为泛型写法与JSX语法冲突
const arrayOfNumber: Array<number> = [1, 2, 3];
const arrayOfString: Array<string> = ['a', 'b', 'c'];

元组类型(Tuple)

元组最重要的特性是可以限制数组元素的个数和类型,它特别适合用来实现多值返回。

但是,在 JavaScript 中并没有元组的概念,作为一门动态类型语言,它的优势是天然支持多类型元素数组。但是TypeScript 的元组类型正好弥补了这个不足,使得定义包含固定个数元素、每个元素类型未必相同的数组成为可能。

注意:数组类型的值只有显示添加了元组类型注解后(或者使用 as const,声明为只读元组),TypeScript 才会把它当作元组,否则推断出来的类型就是普通的数组类型(第 4 讲会介绍类型推断)。

特殊类型

any

any 指的是一个任意类型,它是官方提供的一个选择性绕过静态类型检测的作弊方式。【不推荐使用!Any is Hell(Any 是地狱)】除非有充足的理由,否则我们应该尽量避免使用 any ,并且开启禁用隐式 any 的设置。

我们可以把任何类型的值赋值给 any 类型的变量,也可以把 any 类型的值赋值给任意类型(除 never 以外)的变量。

let anything: any = {};
anything.doAnything(); // 不会提示错误
anything = 1; // 不会提示错误
anything = 'x'; // 不会提示错误
let num: number = anything; // 不会提示错误
let str: string = anything; // 不会提示错误

unknown

unknown 是 TypeScript 3.0 中添加的一个类型,它主要用来描述类型并不确定的变量。

比如在多个 if else 条件分支场景下,它可以用来接收不同条件下类型各异的返回值的临时变量,如下代码所示:

let result: unknown;

if (x) {
  result = x();
} else if (y) {
  result = y();
} ...

在 3.0 以前的版本中,只有使用 any 才能满足这种动态类型场景。

与 any 不同的是,unknown 在类型上更安全。比如我们可以将任意类型的值赋值给 unknown,但 unknown 类型的值只能赋值给 unknown 或 any,如下代码所示:

let result: unknown;
let num: number = result; // 提示 ts(2322)
let anything: any = result; // 不会提示错误

使用 unknown 后,TypeScript 会对它做类型检测。但是,如果不缩小类型(Type Narrowing),我们对 unknown 执行的任何操作都会出现如下所示错误:

let result: unknown;
result.toFixed(); // 提示 ts(2571)

而所有的类型缩小手段对 unknown 都有效,如下代码所示:

let result: unknown;

if (typeof result === 'number') {
  result.toFixed(); // 此处 hover result 提示类型是 number,不会提示错误
}

void、undefined、null

它们实际上并没有太大的用处,三个废柴。

void 类型,它仅适用于表示没有返回值的函数,即如果该函数没有返回值,那它的类型就是 void。在 strict 模式下,声明一个 void 类型的变量几乎没有任何实际用处,因为我们不能把 void 类型的变量值再赋值给除了 any 和 unkown 之外的任何类型变量。

undefined 类型 和 null 类型,它们是 TypeScript 值与类型关键字同名的唯二例外。

let undeclared: undefined = undefined; // 鸡肋

let nullable: null = null; // 鸡肋

undefined 的最大价值主要体现在接口类型上,它表示一个可缺省、未定义的属性。

null 的价值主要体现在接口制定上,它表明对象或属性可能是空值。

此外,undefined 和 null 类型还具备警示意义,它们可以提醒我们针对可能操作这两种(类型)值的情况做容错处理

我们需要类型守卫(Type Guard)在操作之前判断值的类型是否支持当前的操作。类型守卫既能通过类型缩小影响 TypeScript 的类型检测,也能保障 JavaScript 运行时的安全性,如下代码所示:

const userInfo: {
  id?: number;
  name?: null | string
} = { id: 1, name: 'Captain' };

if (userInfo.id !== undefined) { // Type Guard
  userInfo.id.toFixed(); // id 的类型缩小成 number
}

不建议随意使用非空断言来排除值可能为 null 或 undefined 的情况,因为这样很不安全。

userInfo.id!.toFixed(); // ok,但不建议
userInfo.name!.toLowerCase() // ok,但不建议

而比非空断言更安全、类型守卫更方便的做法是使用单问号(Optional Chain)、双问号(空值合并),我们可以使用它们来保障代码的安全性,如下代码所示:

userInfo.id?.toFixed(); // Optional Chain
const myName = userInfo.name?? `my name is ${info.name}`; // 空值合并。当左侧的操作数为 null 或者 undefined 时,返回其右侧操作数,否则返回左侧操作数。

never

never 表示永远不会发生值的类型。

// 这个统一抛出错误的函数永远不会有返回值,所以它的返回值类型就是 never
function ThrowError(msg: string): never {
  throw Error(msg);
}

// 死循环的函数,永远不会有返回值
function InfiniteLoop(): never {
  while (true) {}
}

never 是所有类型的子类型,它可以给所有类型赋值,如下代码所示:

let Unreachable: never = 1; // ts(2322)
Unreachable = 'string'; // ts(2322)
Unreachable = true; // ts(2322)

let num: number = Unreachable; // ok
let str: string = Unreachable// ok
let bool: boolean = Unreachable// ok

但是反过来,除了 never 自身以外,其他类型(包括 any 在内的类型)都不能为 never 类型赋值。

在恒为 false 的类型守卫条件判断下,变量的类型将缩小为 never(never 是所有其他类型的子类型,所以是类型缩小为 never,而不是变成 never)。因此,条件判断中的相关操作始终会报无法更正的错误(我们可以把这理解为一种基于静态类型检测的 Dead Code 检测机制),如下代码所示:

const str: string = 'string';

if (typeof str === 'number') {
  str.toLowerCase(); // Property 'toLowerCase' does not exist on type 'never'.ts(2339)
}

基于 never 的特性,我们还可以使用 never 实现一些有意思的功能。比如我们可以把 never 作为接口类型下的属性类型,用来禁止写接口下特定的属性,示例代码如下:

const props: {
  id: number,
  name?: never
} = {
  id: 1
}

props.name = null; // ts(2322))
props.name = 'str'; // ts(2322)
props.name = 1; // ts(2322)

无论我们给 props.name 赋什么类型的值,它都会提示类型错误,实际效果等同于 name 只读 。

object

object 类型表示非原始类型的类型,也是个没有什么用武之地的类型,如下所示的一个应用场景是用来表示 Object.create 的类型。

declare function create(o: object | null): any;

create({}); // ok

create(() => null); // ok

create(2); // ts(2345)

create('string'); // ts(2345)

类型断言(Type Assertion)

const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = arrayNumber.find(num => num > 2); // 提示 ts(2322)

静态类型对运行时的逻辑无能为力。即使我们知道上面有大于2的元素3和4,但是,在 TypeScript 看来,greaterThan2 的类型既可能是数字,也可能是 undefined,所以上面的示例中提示了一个 ts(2322) 错误,此时我们不能把类型 undefined 分配给类型 number。

我们可以使用一种笃定的方式——类型断言(类似仅作用在类型层面的强制类型转换)告诉 TypeScript 按照我们的方式做类型检查。

比如,我们可以使用 as 语法做类型断言,如下代码所示:

const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = arrayNumber.find(num => num > 2) as number;

或者是使用尖括号 + 类型的格式做类型断言,如下代码所示:

const arrayNumber: number[] = [1, 2, 3, 4];
const greaterThan2: number = <number>arrayNumber.find(num => num > 2); //<>与JSX语法冲突,推荐as的写法

常量断言:“字面量值 + as const”语法

/** str 类型是 '"str"' */
let str = 'str' as const;

/** readOnlyArr 类型是 'readonly [0, 1]' */
const readOnlyArr = [0, 1] as const;

4、字面量类型、类型推断、类型拓宽和类型缩小

在很多情况下,TypeScript 会根据上下文环境自动推断出变量的类型,无须我们再写明类型注解。

{
  let str = 'this is string'; // 等价str: string
  let num = 1; // 等价num: number
  let bool = true; // 等价bool: boolean
}

{
  const str = 'this is string'; // 不等价str: string, 等价str: 'this is string'
  const num = 1; // 不等价num: number, 等价num: 1
  const bool = true; // 不等价bool: boolean, 等价bool: true
}

类型推断

TypeScript 中,具有初始化值的变量、有默认值的函数参数、函数返回的类型都可以根据上下文推断出来。

{
  /** 根据参数的类型,推断出返回值的类型也是 number */
  function add1(a: number, b: number) {
    return a + b;
  }

  const x1= add1(1, 1); // 推断出 x1 的类型也是 number

  /** 推断参数 b 的类型是数字或者 undefined,返回值的类型也是数字 */
  function add2(a: number, b = 1) {
    return a + b;
  }

  const x2 = add2(1);
  const x3 = add2(1, '1'); // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number | undefined
}

上下文推断

变量的类型除了可以通过被赋值的值进行推断,某些特定的情况下,也可以通过变量所在的上下文环境推断变量的类型,具体示例如下:

{

  type Adder = (a: number, b: number) => number;

  const add: Adder = (a, b) => {
    return a + b;
  }

  const x1 = add(1, 1); // 推断出 x1 类型是 number
  const x2 = add(1, '1');  // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number
}

字面量类型

TypeScript 中,字面量不仅可以表示值,还可以表示类型,即所谓的字面量类型。包括字符串字面量类型、数字字面量类型、布尔字面量类型,对应的字符串字面量、数字字面量、布尔字面量分别拥有与其值一样的字面量类型。

{
  let specifiedStr: 'this is string' = 'this is string';
  let specifiedNum: 1 = 1;
  let specifiedBoolean: true = true;
}

字面量类型是集合类型的子类型,它是集合类型的一种更具体的表达。

字面量类型真正的应用场景是可以把多个字面量类型组合成一个联合类型,用来描述拥有明确成员的实用的集合。

type Direction = 'up' | 'down';

function move(dir: Direction) {
  // ...
}

move('up'); // ok
move('right'); // ts(2345) Argument of type '"right"' is not assignable to parameter of type 'Direction'

Literal Widening

所有通过 let 或 var 定义的变量、函数的形参、对象的非只读属性,如果满足指定了初始值未显式添加类型注解的条件,那么它们推断出来的类型就是指定的初始值字面量类型拓宽后的类型,这就是字面量类型拓宽

{

  let str = 'this is string'; // 类型是 string

  let strFun = (str = 'this is string') => str; // 类型是 (str?: string) => string;

  const specifiedStr = 'this is string'; // 类型是 'this is string'

  let str2 = specifiedStr; // 类型是 'string'

  let strFun2 = (str = specifiedStr) => str; // 类型是 (str?: string) => string;

}

基于字面量类型拓宽的条件,我们可以通过如下所示代码添加显示类型注解控制类型拓宽行为。

{

  const specifiedStr: 'this is string' = 'this is string'; // 类型是 '"this is string"'

  let str2 = specifiedStr; // 即便使用 let 定义,类型是 'this is string'
    
  str2 = 'aaa' // 不能将类型“"aaa"”分配给类型“"this is string"”。ts(2322)
}

Type Widening

比如对 null 和 undefined 的类型进行拓宽,通过 let、var 定义的变量如果满足未显式声明类型注解被赋予了 null 或 undefined 值,则推断出这些变量的类型是 any:

{

  let x = null; // 类型拓宽成 any

  let y = undefined; // 类型拓宽成 any

  /** -----分界线------- */

  const z = null; // 类型是 null

  /** -----分界线------- */

  let anyFun = (param = null) => param; // 形参类型是 null, 因为开启了 strictNullChecks=true

  let z2 = z; // 类型是 null, 因为开启了 strictNullChecks=true

  let x2 = x; // 类型是 null, 因为开启了 strictNullChecks=true

  let y2 = y; // 类型是 undefined, 因为开启了 strictNullChecks=true

}

Type Narrowing

通过某些操作将变量的类型由一个较为宽泛的集合缩小到相对较小、较明确的集合,这就是 "Type Narrowing"(类型缩小)。

比如,我们可以使用类型守卫(可以理解成条件判断)将函数参数的类型从 any 缩小到明确的类型,具体示例如下:

{
  let func = (anything: string | number) => {
    if (typeof anything === 'string') {
      return anything; // 类型是 string 
    } else {
      return anything; // 类型是 number
    }
  };
}

同样,我们可以使用类型守卫将联合类型缩小到明确的子类型,具体示例如下:

{
  let func = (anything: string | number) => {
    if (typeof anything === 'string') {
      return anything; // 类型是 string 
    } else {
      return anything; // 类型是 number
    }
  };
}

也可以通过字面量类型等值判断(===)或其他控制流语句(包括但不限于 if、三目运算符、switch 分支)将联合类型收敛为更具体的类型,如下代码所示:

{
  type Goods = 'pen' | 'pencil' |'ruler';
  const getPenCost = (item: 'pen') => 2;
  const getPencilCost = (item: 'pencil') => 4;
  const getRulerCost = (item: 'ruler') => 6;
  const getCost = (item: Goods) =>  {
    if (item === 'pen') {
      return getPenCost(item); // item => 'pen'
    } else if (item === 'pencil') {
      return getPencilCost(item); // item => 'pencil'
    } else {
      return getRulerCost(item); // item => 'ruler'
    }
  }
}

5、函数类型

TypeScript 里,我们可以通过 function 字面量和箭头函数的形式定义函数,示例如下:

function add() {}

const add = () => {}

也显式指定函数参数和返回值的类型,示例如下:

// 显示指定函数的返回类型是number
const add = (a: number, b: number): number => {
     return a + b;
}

注意,函数没有返回类型虽然返回值是undefined,但是不能定义函数的返回类型是undefined应该使用void

function fn() {
  // TODO
}
console.log(fn()); // => undefined

function fn(): undefined { // ts(2355) A function whose declared type is neither 'void' nor 'any' must return a value
  // TODO
}

function fn1(): void {
}
fn1().doSomething(); // ts(2339) Property 'doSomething' does not exist on type 'void'.

可以使用类似箭头函数的语法表示函数类型的参数和返回值类型,=>左侧是函数的参数类型,右侧是函数的返回值类型

type Adder = (a: number, b: number) => number; // TypeScript 函数类型定义,type是类型别名

const add: Adder = (a, b) => a + b; // ES6 箭头函数

可缺省和可推断的返回值类型

函数返回值的类型可以在 TypeScript 中被推断出来,即可缺省。

函数内是一个相对独立的上下文环境,我们可以根据入参对值加工计算,并返回新的值。

function computeTypes(one: string, two: number) {
  const nums = [two];
  const strs = [one]
  return {
    nums,
    strs
  } // 返回 { nums: number[]; strs: string[] } 的类型 
}

函数返回值的类型推断结合泛型可以实现特别复杂的类型计算(本质是复杂的类型推断,这里称之为计算是为了表明其复杂性),比如 Redux Model 中 State、Reducer、Effect 类型的关联。

参数类型

可选参数

function log(x?: string) { // 类型标注的:前添加?表示 log 函数的参数 x 就是可缺省的。即x是undefined或者string
  return x;
}

log(); // => undefined
log('hello world'); // => hello world

类型标注的:前添加?表示 log 函数的参数 x 就是可缺省的。

注意区别!

function log(x?: string) { // log可以没有入参,或者传string类型
  console.log(x);
}

function log1(x: string | undefined) { // log1必须传一个string或者undefined参数
  console.log(x);
}

log();
log(undefined);

log1(); // ts(2554) Expected 1 arguments, but got 0
log1(undefined);

默认参数

ES6 中支持函数默认参数的功能,而 TypeScript 会根据函数的默认参数的类型来推断函数参数的类型。

function log(x = 'hello') {
    console.log(x);
}

log(); // => 'hello'
log('hi'); // => 'hi'
log(1); // ts(2345) Argument of type '1' is not assignable to parameter of type 'string | undefined'

对于默认参数,TypeScript 也可以显式声明参数的类型(一般默认参数的类型是参数类型的子集时,我们才需要这么做)。不过,此时的默认参数只起到参数默认值的作用,如下代码所示:

function log1(x: string = 'hello') {
    console.log(x);
}

// ts(2322) Type 'string' is not assignable to type 'number'
function log2(x: number = 'hello') {
    console.log(x);
}

log2();
log2(1);
log2('1'); // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number | undefined'

函数的默认参数类型必须是参数类型的子类型

function log3(x: number | string = 'hello') { // 字符串类型是联合类型 number | string 的子类型
    console.log(x);
}

剩余参数

ES6 中,JavaScript 支持函数参数的剩余参数,它可以把多个参数收集到一个变量中。同样,在TypeScript 中也支持这样的参数类型定义,如下代码所示:

function sum(...nums: number[]) {
    return nums.reduce((a, b) => a + b, 0);
}

sum(1, 2); // => 3
sum(1, 2, 3); // => 6
sum(1, '2'); // ts(2345) Argument of type 'string' is not assignable to parameter of type 'number'


function sum(...nums: (number | string)[]): number {
    return nums.reduce<number>((a, b) => a + Number(b), 0);
}
sum(1, '2', 3); // 6

this

通过指定 this 的类型(严格模式下,必须显式指定 this 的类型),当我们错误使用了 thisTypeScript 就会提示我们,如下代码所示:

function say() {
    console.log(this.name); // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation
}

say();

TypeScript 中,我们只需要在函数的第一个参数中声明 this 指代的对象(即函数被调用的方式)即可:

function say(this: Window, name: string) {
    console.log(this.name);
}

window.say = say;
window.say('hi');

const obj = {
    say
};

obj.say('hi'); // ts(2684) The 'this' context of type '{ say: (this: Window, name: string) => void; }' is not assignable to method's 'this' of type 'Window'.

注意:如果我们直接调用 say(),this 实际上应该指向全局变量 window,但是因为 TypeScript 无法确定 say 函数被谁调用,所以将 this 的指向默认为 void,也就提示了一个 ts(2684) 错误。

say('captain'); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Window'

如果我们直接调用 say(),this 实际上应该指向全局变量 window,但是因为 TypeScript 无法确定 say 函数被谁调用,所以将 this 的指向默认为 void,也就提示了一个 ts(2684) 错误。

同样,我们也可以显式限定类函数属性中的 this 类型,TypeScript 也能检查出错误的使用方式。

函数重载

function convert(x: string): number;
function convert(x: number): string;
function convert(x: null): -1;

function convert(x: string | number | null): any {
    if (typeof x === 'string') {
        return Number(x);
    }

    if (typeof x === 'number') {
        return String(x);
    }

    return -1;
}

const x1 = convert('1'); // => number
const x2 = convert(1); // => string
const x3 = convert(null); // -1

注意:函数重载列表的各个成员(即示例中的 1 ~ 3 行)必须是函数实现(即示例中的第 4 行)的子集,例如 “function convert(x: string): number”是“function convert(x: string | number | null): any”的子集

在 convert 函数被调用时,TypeScript从上到下查找函数重载列表中与入参类型匹配的类型,并优先使用第一个匹配的重载定义。因此,我们需要把最精确的函数重载放到前面。

类型谓词(is)

TypeScript 中,函数还支持另外一种特殊的类型描述,如下示例 :

function isString(s): s is string { // 类型谓词
  return typeof s === 'string';
}

function isNumber(n: number) {
  return typeof n === 'number';
}

function operator(x: unknown) {
  if(isString(x)) { // ok x 类型缩小为 string
  }

  if (isNumber(x)) { // ts(2345) unknown 不能赋值给 number
  }
}

在上述代码中,在添加返回值类型的地方,我们通过“参数名 + is + 类型”的格式明确表明了参数的类型,进而引起类型缩小,所以类型谓词函数的一个重要的应用场景是实现自定义类型守卫。

类型谓词和as很相似,但是类型谓词只能用来定义自定义类型守卫,实际上是告诉引擎,当守卫返回 true 的时候,将被守卫的类型缩小到 is 指定的更明确的类型。

6、类类型

JavaScriptES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java类似的语法定义声明类。TypeScript 作为 JavaScript 的超集,自然也支持 class 全部特性,并且还可以对类的属性、方法等进行静态类型检测。

// ES5  麻烦,且缺少静态类型检测
function Dog(name: string) {
  this.name = name; // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation.
}

Dog.prototype.bark = function () {
  console.log('Woof! Woof!');
};


const dog = new Dog('Q'); // ts(7009) 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
dog.bark(); // => 'Woof! Woof!'


// ES6的class语法
class Dog {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

const dog = new Dog('Q');
dog.bark(); // => 'Woof! Woof!'

继承

使用 extends 关键字就能很方便地定义类继承的抽象模式:

class Animal { // 父类(超类)
  type = 'Animal';
  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal { // 派生类(子类)
  bark() {
    console.log('Woof! Woof!');
  }
}

const dog = new Dog();
dog.bark(); // => 'Woof! Woof!'
dog.say('Q'); // => I'm Q!
dog.type; // => Animal

派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。super 函数会调用基类的构造函数】

class Animal {
  weight: number;
  type = 'Animal';
    
  constructor(weight: number) {
    this.weight = weight;
  }

  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super(); // ts(2554) Expected 1 arguments, but got 0.
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

公共、私有与受保护的修饰符

TypeScript 中就支持 3 种访问修饰符,分别是 publicprivateprotected

  • public 修饰的是在任何地方可见、公有的属性或方法;【缺省情况】
  • private 修饰的是仅在同一类中可见、私有的属性或方法;
  • protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法。

注意TypeScript 中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略 TypeScript 类型的检查错误,转译且运行 JavaScript 时依旧可以获取到 private属性,这是因为 JavaScript 并不支持真正意义上的私有属性。

小结:public可以在类、子类内部以及类的实例上访问和修改;private只能在类的内部访问和修改,子类无法访问,类的实例也无法访问;protected可以在类和子类内部访问和修改,但是不能在实例上访问

只读修饰符

如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性:

class Son {
  public readonly firstName: string;
  
  constructor(firstName: string) {
    this.firstName = firstName;
  }
}

const son = new Son('Tony');
son.firstName = 'Jack'; // ts(2540) Cannot assign to 'firstName' because it is a read-only property.

注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。

存取器

TypeScript 中可以通过gettersetter截取对类成员的读写访问。

class Son {
  public firstName: string;

  protected lastName: string = 'Stark';

  constructor(firstName: string) {
    this.firstName = firstName;
  }
}

class GrandSon extends Son {
  constructor(firstName: string) {
    super(firstName);
  }

  get myLastName() {
    return this.lastName;
  }

  set myLastName(name: string) {
    if (this.firstName === 'Tony') {
      this.lastName = name;
    } else {
      console.error('Unable to change myLastName');
    }
  }
}

const grandSon = new GrandSon('Tony');
console.log(grandSon.myLastName); // => "Stark"
grandSon.myLastName = 'Rogers';
console.log(grandSon.myLastName); // => "Rogers"

const grandSon1 = new GrandSon('Tony1');
grandSon1.myLastName = 'Rogers'; // => "Unable to change myLastName"

静态属性

以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法(即:类本身的属性和方法,不需要实例化即可直接访问)。

因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:

class MyArray {
  static displayName = 'MyArray';
  static isArray(obj: unknown) {
    return Object.prototype.toString.call(obj).slice(8, -1) === 'Array';
  }
}

console.log(MyArray.displayName); // => "MyArray"
console.log(MyArray.isArray([])); // => true
console.log(MyArray.isArray({})); // => false

基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。

抽象类

抽象类是一种不能被实例化仅能被子类继承的特殊类。

abstract class Adder { // 任何继承 Adder 的派生类都需要实现这些抽象属性和方法。
  abstract x: number;
  abstract y: number;
  abstract add(): number;
  displayName = 'Adder';
  addTwice(): number {
    return (this.x + this.y) * 2;
  }
}

class NumAdder extends Adder {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    super();
    this.x = x;
    this.y = y;
  }
  add(): number {
    return this.x + this.y;
  }
}

const numAdder = new NumAdder(1, 2);
console.log(numAdder.displayName); // => "Adder"
console.log(numAdder.add()); // => 3
console.log(numAdder.addTwice()); // => 6

抽象类的作用其实就是对基础逻辑的封装和抽象。

类的类型

在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。如下代码所示:

class A {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const a1: A = {}; // ts(2741) Property 'name' is missing in type '{}' but required in type 'A'.
const a2: A = { name: 'a2' }; // ok

7、接口类型与类型别名

接口类型

接口定义

语法格式是在 interface 关键字的空格后+接口名字,然后属性与属性类型的定义用花括弧包裹。

/ ** 关键字 接口名称 */
interface ProgramLanguage {
  /** 语言名称 */
  name: string;

  /** 使用年限 */
  age: () => number;
}

// interface定义函数入参的类型
function NewStudy(language: ProgramLanguage) {
  console.log(`ProgramLanguage ${language.name} created ${language.age()} years ago.`);
}

// interface定义变量的类型
let TypeScript: ProgramLanguage;
// 传入的对象必须符合ProgramLanguage的定义,否则会报错。这里必须传name和age,且类型与接口描述一致
TypeScript = {
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
}

可缺省属性

可以在属性名之后通过添加如下所示的? 语法来标注可缺省的属性或方法。

/** 关键字 接口名称 */
interface OptionalProgramLanguage {
  /** 语言名称 */
  name: string;

  /** 使用年限 */
  age?: () => number;
}

let OptionalTypeScript: OptionalProgramLanguage = {
  name: 'TypeScript'
}; // ok

age被标注为可缺省后,它的类型就变成了显式指定的类型与 undefined 类型组成的联合类型,这里是(() => number) | undefined,但是并不完全等价!

/** 关键字 接口名称 */
interface OptionalProgramLanguage2 {
  /** 语言名称 */
  name: string;

  /** 使用年限 */
  age: (() => number) | undefined;
}

与函数可缺省参数和参数类型可以是 undefined 一样,可缺省意味着可以不设置属性键名,类型是 undefined 意味着属性键名不可缺省。

只读属性

可以在属性名前通过添加 readonly 修饰符的语法来标注 为只读属性。

interface ReadOnlyProgramLanguage {
  /** 语言名称 */
  readonly name: string;

  /** 使用年限 */
  readonly age: (() => number) | undefined;
}


let ReadOnlyTypeScript: ReadOnlyProgramLanguage = {
  name: 'TypeScript',
  age: undefined
}

/** ts(2540)错误,name 只读 */
ReadOnlyTypeScript.name = 'JavaScript';

注意,这仅仅是静态类型检测层面的只读,实际上并不能阻止对对象的篡改。因为在转译为 JavaScript 之后,readonly 修饰符会被抹除。因此,任何时候与其直接修改一个对象,不如返回一个新的对象,这会是一种比较安全的实践。

定义函数类型

接口类型不仅能用来定义对象的类型,还可以用来定义函数的类型 (备注:仅仅是定义函数的类型,而不包含函数的实现),具体示例如下。

interface StudyLanguage {
  (language: ProgramLanguage): void
}

/** 单独的函数实践 */
let StudyInterface: StudyLanguage 
  = language => console.log(`${language.name} ${language.age()}`);

上面的接口StudyLanguage有一个函数类型的匿名成员,函数参数类型 ProgramLanguage,返回值的类型是 void,通过这样的格式定义的接口类型又被称之为可执行类型,也就是一个函数类型。

实际上,我们很少使用接口类型来定义函数的类型,更多使用内联类型或类型别名配合箭头函数语法来定义函数类型,具体示例如下:

type StudyLanguageType = (language: ProgramLanguage) => void

我们给箭头函数类型指定了一个别名 StudyLanguageType,在其他地方就可以直接复用 StudyLanguageType,而不用重新声明新的箭头函数类型定义。

索引签名

let LanguageRankMap = {
  1: 'TypeScript',
  2: 'JavaScript',
  ...
};

let LanguageMap = {
  TypeScript: 2012,
  JavaScript: 1995,
  ...
};

我们可以使用索引签名来定义上边的对象映射结构,并通过 “[索引名: 类型]”的格式约束索引的类型。

interface LanguageRankInterface {
  [rank: number]: string;
}

interface LanguageYearInterface {
  [name: string]: number;
}

{
  let LanguageRankMap: LanguageRankInterface = {
    1: 'TypeScript', // ok
    2: 'JavaScript', // ok
    'WrongINdex': '2012' // ts(2322) 不存在的属性名
  };

  let LanguageMap: LanguageYearInterface = {
    TypeScript: 2012, // ok
    JavaScript: 1995, // ok
    1: 1970 // ok
  };
}

注意:在上述示例中,数字作为对象索引时,它的类型既可以与数字兼容,也可以与字符串兼容,这与 JavaScript 的行为一致。因此,使用 0 或 '0' 索引对象时,这两者等价。

同样,我们可以使用 readonly 注解索引签名,此时将对应属性设置为只读就行,如下代码所示:

{
  interface LanguageRankInterface {
    readonly [rank: number]: string;
  }

  interface LanguageYearInterface {
    readonly [name: string]: number;
  }
} 

注意:虽然属性可以与索引签名进行混用,但是属性的类型必须是对应的数字索引或字符串索引的类型的子集,否则会出现错误提示。

{
  interface StringMap {
    [prop: string]: number;
    age: number; // ok
    name: string; // ts(2411) name 属性的 string 类型不能赋值给字符串索引类型 number
  }

  interface NumberMap {
    [rank: number]: string;
    1: string; // ok
    0: number; // ts(2412) 0 属性的 number 类型不能赋值给数字索引类型 string
  }

  interface LanguageRankInterface {
    name: string; // ok
    0: number; // ok
    [rank: number]: string;
    [name: string]: number;
  }
}

另外,由于上边提到了数字类型索引的特殊性,所以我们不能约束数字索引属性与字符串索引属性拥有截然不同的类型,具体示例如下:

{
  interface LanguageRankInterface {
    [rank: number]: string; // ts(2413) 数字索引类型 string 类型不能赋值给字符串索引类型 number
    [prop: string]: number;
  }
}

我们确实需要使用 age 是 number 类型、其他属性类型是 string 的对象数据结构,应该如何定义它的类型且不提示错误呢???

这个要用到两个接口的联合类型及类型缩减,这个问题的核心在于找到一个既是 number 的子类型,这样 age 类型缩减之后的类型就是 number;同时也是 string 的子类型,这样才能满足属性和 string 索引类型的约束关系。那只能是never

// 等价于 age 属性的类型是由 number 和 never 类型组成的联合类型  
type UnionInterce =
  | {
      age: number;
    }
  | ({
      age: never;
      [key: string]: string; // 字符串索引签名。never是string类型的子类型,所以age属性的类型和字符串索引签名类型不冲突
    });

  const O: UnionInterce = {
    age: 2,
    string: 'string'
  };

继承与实现

TypeScript 中,接口类型可以继承和被继承,比如我们可以使用如下所示的 extends 关键字实现接口的继承。

{
  interface DynamicLanguage extends ProgramLanguage {
    rank: number; // 定义新属性
  }

 
  interface TypeSafeLanguage extends ProgramLanguage {
    typeChecker: string; // 定义新的属性
  }

  /** 继承多个 */
  interface TypeScriptLanguage extends DynamicLanguage, TypeSafeLanguage {
    name: 'TypeScript'; // 用原属性类型的兼容的类型(比如子集)重新定义属性
  }
}

注意:我们仅能使用兼容的类型覆盖继承的属性:

{
  /** ts(6196) 错误的继承,name 属性不兼容 */
  interface WrongTypeLanguage extends ProgramLanguage {
    name: number;
  }
}

Type 类型别名

接口类型的一个作用是将内联类型抽离出来,从而实现类型可复用。其实,我们也可以使用类型别名接收抽离出来的内联类型实现复用。

可以通过如下所示“type别名名字 = 类型定义”的格式来定义类型别名

/** 类型别名 */
{
  type LanguageType = {
    /** 以下是接口属性 */
      
    /** 语言名称 */
    name: string;

    /** 使用年限 */
    age: () => number;
  }
}

针对接口类型无法覆盖的场景,比如组合类型、交叉类型,我们只能使用类型别名来接收:

{

  /** 联合 */
  type MixedType = string | number;

  /** 交叉 */
  type IntersectionType = { id: number; name: string; }  & { age: number; name: string };

  /** 提取接口属性类型 */
  type AgeType = ProgramLanguage['age'];  
}

注意:类型别名,诚如其名,即我们仅仅是给类型取了一个新的名字,并不是创建了一个新的类型。

Interface 与 Type 的区别

在大多数的情况下使用接口类型和类型别名的效果等价,但是在某些特定的场景下这两者还是存在很大区别。比如,重复定义的接口类型,它的属性会叠加,这个特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展,如下代码所示:

{
  interface Language {
    id: number;
  }
  
  interface Language {
    name: string;
  }

  let lang: Language = {
    id: 1, // ok
    name: 'name' // ok
  }
}
{

  /** ts(2300) 重复的标志 */
  type Language = {
    id: number;
  }

  /** ts(2300) 重复的标志 */
  type Language = {
    name: string;
  }

  let lang: Language = {
    id: 1,
    name: 'name'
  }
}

8、联合类型与交叉类型

联合类型

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。通过“|”操作符分隔类型的语法来表示联合类型。这里,我们可以把“|”类比为 JavaScript 中的逻辑或 “||”,只不过前者表示可能的类型。

// 未使用联合类型
function formatPX(size: unknown) { // 推荐用unknown,而不是any
  if (typeof size === 'number') {
    return `${size}px`;
  }

  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }

  throw Error(` 仅支持 number 或者 string`);
}

formatPX(13);
formatPX('13px');

// 使用联合类型
function formatPX(size: number | string) {
  // ...
}

formatPX(13); // ok
formatPX('13'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型
function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  // ...
}

formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)

也可以使用类型别名抽离上边的联合类型,然后再将其进一步地联合,如下代码所示:

type ModernUnit = 'vh' | 'vw';
type Unit = 'px' | 'em' | 'rem';
type MessedUp = ModernUnit | Unit; // 类型是 'vh' | 'vw' | 'px' | 'em' | 'rem'

也可以把接口类型联合起来表示更复杂的结构,如下所示示例:

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

const getPet: () => Bird | Fish = () => {

  return {

   // ...

  } as Bird | Fish;

};

const Pet = getPet();

Pet.layEggs(); // ok

Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性

在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。但是,如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫来区分不同的成员类型。

if (typeof Pet.fly === 'function') { // ts(2339)
  Pet.fly(); // ts(2339)
}

if ('fly' in Pet) {
  Pet.fly(); // ok
}

因为 Pet 的类型既可能是 Bird 也可能是 Fish,这就意味着在第 1 行可能会通过 Fish 类型获取 fly 属性,但 Fish 类型没有 fly 属性定义,所以会提示一个 ts(2339) 错误。

交叉类型

交叉类型(Intersection Type)可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性

TypeScript 中,我们可以使用“&”操作符来声明交叉类型:

{
  type Useless = string & number;
}

如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型,比如既是 string 类型又是 number 类型。因此,在上述的代码中,类型别名 Useless 的类型就是个 never。

合并接口类型

联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型理解为求并集),如下代码所示:

  type IntersectionType = { id: number; name: string; } & { age: number };

  const mixed: IntersectionType = {
    id: 1,
    name: 'name',
    age: 18
  }

如果合并的多个接口类型存在同名属性会是什么效果呢?

1、同名属性的类型不兼容

  type IntersectionTypeConfict = { id: number; name: string; }  & { age: number; name: number; };

  const mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
    age: 2
  };

我们赋予 mixedConflict 任意类型的 name 属性值都会提示类型错误。而如果我们不设置 name 属性,又会提示一个缺少必选的 name 属性的错误。在这种情况下,就意味着上述代码中交叉出来的 IntersectionTypeConfict 类型是一个无用类型。

2、同名属性的类型兼容

合并后的结果是两者中的子类型

  type IntersectionTypeConfict = { id: number; name: 2; } & { age: number; name: number; };

  let mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ok
    age: 2
  };

  mixedConflict = {
    id: 1,
    name: 22, // '22' 类型不能赋给 '2' 类型
    age: 2
  };

合并联合类型

我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。这里,我们也可以将合并联合类型理解为求交集

  type UnionA = 'px' | 'em' | 'rem' | '%';
  type UnionB = 'vh' | 'em' | 'rem' | 'pt';
  type IntersectionUnion = UnionA & UnionB;

  const intersectionA: IntersectionUnion = 'em'; // ok
  const intersectionB: IntersectionUnion = 'rem'; // ok
  const intersectionC: IntersectionUnion = 'px'; // ts(2322)
  const intersectionD: IntersectionUnion = 'pt'; // ts(2322)

如果多个联合类型中没有相同的类型成员,交叉出来的类型就是 never ,如下代码所示:

  type UnionC = 'em' | 'rem';
  type UnionD = 'px' | 'pt';
  type IntersectionUnionE = UnionC & UnionD;
  const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型

联合、交叉组合

联合、交叉类型可以直接组合使用,涉及 |、& 操作符的优先级问题,和 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致 。

联合操作符 | 的优先级低于交叉操作符 &,同样,我们可以通过使用小括弧 () 来调整操作符的优先级。

  type UnionIntersectionA = { id: number; } & { name: string; } | { id: string; } & { name: number; }; // 交叉操作符优先级高于联合操作符

  type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级


  type UnionIntersectionC = ({ id: number; } & { name: string; } | { id: string; }) & { name: number; };

  type UnionIntersectionD = { id: number; } & { name: string; } & { name: number; } | { id: string; } & { name: number; }; // 满足分配率

  type UnionIntersectionE = ({ id: string; } | { id: number; } & { name: string; }) & { name: number; }; // 满足交换律

类型缩减

如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果?效果就是类型缩减成 string 了。

同样,对于 number、boolean(其实还有枚举类型)也是一样的缩减逻辑,如下所示示例:

  type URStr = 'string' | string; // 类型是 string
  type URNum = 2 | number; // 类型是 number
  type URBoolen = true | boolean; // 类型是 boolean
  enum EnumUR {
    ONE,
    TWO
  }
  type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。

可是这个缩减,却极大地削弱了 IDE 自动提示的能力,如下代码所示:

  type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。 不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}”即可。

  type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留

此时,其他字面量类型就不会被缩减掉了,在 IDE 中字符串字面量 black、red 等也就自然地可以自动提示出来了。

9、枚举类型

枚举类型

JavaScript 原生语言中并没有与枚举匹配的概念,而 TypeScript 中实现了枚举类型(Enums),这就意味着枚举也是 TypeScript 特有的语法(相对于 JavaScript)。

TypeScript 中,我们可以使用枚举定义包含被命名的常量的集合,比如 TypeScript 支持数字、字符两种常量值的枚举类型。

语法: enum + 枚举名字 + 一对花括弧,花括弧里则是被命名了的常量成员。

  enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }  

在上述示例中,Day 既可以表示集合,也可以表示集合的类型,所有成员(enum member)的类型都是 Day 的子类型。

JavaScript 中其实并没有与枚举类型对应的原始实现,而 TypeScript 转译器会把枚举类型转译为一个属性为常量、命名值从 0 开始递增数字映射的对象:

var Day;
(function (Day) {
    Day[Day["SUNDAY"] = 0] = "SUNDAY";
    Day[Day["MONDAY"] = 1] = "MONDAY";
    Day[Day["TUESDAY"] = 2] = "TUESDAY";
    Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
    Day[Day["THURSDAY"] = 4] = "THURSDAY";
    Day[Day["FRIDAY"] = 5] = "FRIDAY";
    Day[Day["SATURDAY"] = 6] = "SATURDAY";
})(Day || (Day = {}));

下面我们就来详细介绍一下 7 种常见的枚举类型:数字类型、字符串类型、异构类型、常量成员和计算(值)成员、枚举成员类型和联合枚举、常量枚举、外部枚举。

数字枚举

在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果我们希望枚举值从其他值开始递增,则可以通过“常量命名 = 数值” 的格式显示指定枚举成员的初始值,如下代码所示:

  // 从 1 开始递增
  enum Day {
    SUNDAY = 1,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }

我们可以给 SUNDAY 指定任意类型(比如整数、负数、小数等)、任意起始的数字,其后未显示指定值的成员会递增加 1。上边的示例转译为 JavaScript 之后,则是一个属性值从 1 开始递增的对象。

我们也可以给任意位置的成员指定值,如下所示示例:

  enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY = 5
  } 

转成JavaScript后:

var Day;
(function (Day) {
    Day[Day["SUNDAY"] = 0] = "SUNDAY";
    Day[Day["MONDAY"] = 1] = "MONDAY";
    Day[Day["TUESDAY"] = 2] = "TUESDAY";
    Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
    Day[Day["THURSDAY"] = 4] = "THURSDAY";
    Day[Day["FRIDAY"] = 5] = "FRIDAY";
    Day[Day["SATURDAY"] = 5] = "SATURDAY";
})(Day || (Day = {}));

MyDay.FRIDAYMyDay.SATURDAY 的值此时都是数字 5!在switch-case中会逻辑错误!

由于枚举默认的值自递增且完全无法保证稳定性,所以给部分数字类型的枚举成员显式指定数值或给函数传递数值而不是枚举类型作为入参都属于不明智的行为:

  enum Day {
    ...

    SATURDAY = 5 // bad
  } 

  work(5); // bad

此外,常量命名、结构顺序都一致的两个枚举,即便转译为 JavaScript 后,同名成员的值仍然一样(满足恒等 === )。但在 TypeScript 看来,它们不相同、不满足恒等,如下代码所示:

  enum MyDay {
    SUNDAY,
    ...
  } 
  

  Day.SUNDAY === MyDay.SUNDAY; // ts(2367) 两个枚举值恒不相等
  work(MyDay.SUNDAY); // ts(2345) 'MyDay.SUNDAY' 不能赋予 'Day'

不仅仅是数字类型枚举,所有其他枚举都仅和自身兼容,这就消除了由于枚举不稳定性可能造成的风险,所以这是一种极其安全的设计。

字符串枚举

定义值是字符串字面量的枚举称之为字符串枚举,字符串枚举转译为 JavaScript 之后也将保持这些值。

异构枚举(Heterogeneous enums)

TypeScript 支持枚举类型同时拥有数字和字符类型的成员,这样的枚举被称之为异构枚举。比较鸡肋,没什么应用场景。

常量成员和计算(值)成员

前面涉及的枚举成员的值都是字符串、数字字面量和未指定初始值从 0 递增数字常量,都被称作常量成员

另外,在转译时,通过被计算的常量枚举表达式定义值的成员,也被称作常量成员,比如如下几种情况:

  • 引用来自预先定义的常量成员,比如来自当前枚举或其他枚举;
  • 圆括弧 () 包裹的常量枚举表达式;
  • 在常量枚举表达式上应用的一元操作符 +、 -、~ ;
  • 操作常量枚举表达式的二元操作符 +、-、*、/、%、<<、>>、>>>、&、|、^。

除以上这些情况之外,其他都被认为是计算(值)成员。

  enum FileAccess {
    // 常量成员
    None,
    Read = 1 << 1,
    Write = 1 << 2,
    ReadWrite = Read | Write,

    // 计算成员
    G = "123".length,
  }

注意:关于常量成员和计算成员的划分其实比较难理解,实际上它们也并没有太大的用处,只是告诉我们通过这些途径可以定义枚举成员的值。因此,我们只需记住缺省值(从 0 递增)、数字字面量、字符串字面量肯定是常量成员就够了。

枚举成员类型和联合枚举

对于不需要计算(值)的常量类型成员,即缺省值(从 0 递增)、数字字面量、字符串字面量这三种情况(这就是为什么我们只需记住这三种情况),被称之为字面量枚举成员

枚举值和类型是一体的,枚举成员的类型是枚举类型的子类型。

枚举成员和枚举类型之间的关系分两种情况: 如果枚举的成员同时包含字面量和非字面量枚举值,枚举成员的类型就是枚举本身(枚举类型本身也是本身的子类型);如果枚举成员全部是字面量枚举值,则所有枚举成员既是值又是类型,如下代码所示:

  enum Day {
    SUNDAY,
    MONDAY,
  }

  enum MyDay {
    SUNDAY,
    MONDAY = Day.MONDAY
  }

  const mondayIsDay: Day.MONDAY = Day.MONDAY; // ok: 字面量枚举成员既是值,也是类型
  const mondayIsSunday = MyDay.SUNDAY; // ok: 类型是 MyDay,MyDay.SUNDAY 仅仅是值
  const mondayIsMyDay2: MyDay.MONDAY = MyDay.MONDAY; // ts(2535),MyDay 包含非字面量值成员,所以 MyDay.MONDAY 不能作为类型

字面量枚举成员可以同时作为值和类型使用,但是非字面量枚举成员(即使是常量枚举成员)仅能作为值使用。

如果枚举仅有一个成员且是字面量成员,那么这个成员的类型等于枚举类型,如下代码所示:

enum Day {
  MONDAY
}

export const mondayIsDay: Day = Day.MONDAY; // ok
export const mondayIsDay1: Day.MONDAY = mondayIsDay as Day; // ok

联合类型使得 TypeScript 可以更清楚地枚举集合里的确切值,从而检测出一些永远不会成立的条件判断(俗称 Dead Code),如下所示示例:

  enum Day {
    SUNDAY,
    MONDAY,
  }
  
  const work = (x: Day) => {
    if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ts(2367)

    }
  }

因为x 的值要么是 Day.SUNDAY,要么是 Day.MONDAY。因为 Day纯字面量枚举类型,可以等价地看作联合类型 Day.SUNDAY | Day.MONDAY,所以我们判断出第 7 行的条件语句恒为真,于是提示了一个 ts(2367) 错误。不过,如果枚举包含需要计算(值)的成员情况就不一样了。如下示例中,TypeScript 不能区分枚举 Day 中的每个成员。因为每个成员类型都是 Day,所以无法判断出第 7 行的条件语句恒为真,也就不会提示一个 ts(2367) 错误。

  enum Day {
    SUNDAY = +'1',
    MONDAY = 'aa'.length,
  } 

  const work = (x: Day) => {
    if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ok

    }
  }

字面量类型所具有的类型推断类型缩小的特性,也同样适用于字面量枚举类型,如下代码所示:

  enum Day {
    SUNDAY,
    MONDAY,
  }

  let SUNDAY = Day.SUNDAY; // 类型是 Day
  const SUNDAY2 = Day.SUNDAY; // 类型 Day.SUNDAY
  const work = (x: Day) => {
    if (x === Day.SUNDAY) {
      x; // 类型缩小为 Day.SUNDAY
    }
  }

常量枚举(const enums)

枚举的作用在于定义被命名的常量集合,而 TypeScript 提供了一些途径让枚举更加易用,比如常量枚举。常量枚举的成员都必须是常量成员(字面量 + 转译阶段可计算值的表达式),如下代码所示:

  const enum Day {
    SUNDAY,
    MONDAY
  }

  const work = (d: Day) => {
    switch (d) {
      case Day.SUNDAY:
        return 'take a rest';
      case Day.MONDAY:
        return 'work hard';
    }
  }
}

通过添加 const 修饰符定义常量枚举,常量枚举定义转译为 JavaScript 之后会被移除,并在使用常量枚举成员的地方被替换为相应的内联值。使用常量枚举不仅能减少转译后的 JavaScript 代码量(因为抹除了枚举定义),还不需要到上级作用域里查找枚举定义(因为直接内联了枚举值字面量)。

外部枚举(Ambient enums)

TypeScript 中,我们可以通过 declare 描述一个在其他地方已经定义过的变量,如下代码所示:

declare let $: any; // declare 描述类型是 any 的外部变量 $

$('#id').addClass('show'); // ok

使用 declare 描述一个在其他地方已经定义过的枚举类型,通过这种方式定义出来的枚举类型,被称之为外部枚举

转译为 JavaScript 之后,外部枚举的定义也会像常量枚举一样被抹除,但是对枚举成员的引用会被保留。

外部枚举的作用:主要是用在 *.d.ts 类型声明文件里(在 *.d.ts 里也只能使用外部枚举),在不显式引入定义枚举的模块情况下,就可以直接使用该枚举类型。外部枚举的用处

10、泛型

什么是泛型?

借用 Java 中泛型的释义来回答这个问题:泛型指的是类型参数化,即将原来某种具体的类型进行参数化。和定义函数参数一样,我们可以给泛型定义若干个类型参数,并在调用时给泛型传入明确的类型参数。设计泛型的目的在于有效约束类型成员之间的关系,比如函数参数和返回值、类或者接口成员和方法之间的关系。

泛型类型参数

泛型最常用的场景是用来约束函数参数的类型,我们可以给函数定义若干个被调用时才会传入明确类型的参数。

比如以下定义的一个 reflect 函数 ,它可以接收一个任意类型的参数,并原封不动地返回参数的值和类型:

function reflect(param: unknown) {
  return param;
}

const str = reflect('string'); // str 类型是 unknown
const num = reflect(1); // num 类型 unknown

reflect 函数虽然可以接收一个任意类型的参数并原封不动地返回参数的值,不过返回值类型不符合我们的预期(始终是unknown)。

使用泛型可以很好地解决这个问题:

function reflect<P>(param: P) {
  return param;
}

const reflectStr = reflect<string>('string'); // str 类型是 string
const reflectNum = reflect<number>(1); // num 类型 number
// 泛型参数的入参可以从参数的类型中进行推断,所以可缺省
const reflectStr2 = reflect('string'); // str 类型是 string
const reflectNum2 = reflect(1); // num 类型 number

也可以给函数定义任何个数的泛型入参,如下代码所示:

function reflectExtraParams<P, Q>(p1: P, p2: Q): [P, Q] {
  return [p1, p2];
}

泛型类

在类的定义中,我们还可以使用泛型用来约束构造函数、属性、方法的类型,如下代码所示:

class Memory<S> {
  store: S;
  constructor(store: S) {
    this.store = store;
  }

  set(store: S) {
    this.store = store;
  }

  get() {
    return this.store;
  }
}

const numMemory = new Memory<number>(1); // <number> 可缺省

const getNumMemory = numMemory.get(); // 类型是 number

numMemory.set(2); // 只能写入 number 类型

const strMemory = new Memory(''); // 缺省 <string>

const getStrMemory = strMemory.get(); // 类型是 string

strMemory.set('string'); // 只能写入 string 类型

泛型类和泛型函数类似的地方在于,在创建类实例时,如果受泛型约束的参数传入了明确值,则泛型入参(确切地说是传入的类型)可缺省,比如上面,泛型入参就是可以缺省的。

泛型类型

将类型入参的定义移动到类型别名接口名称后,此时定义的一个接收具体类型入参后返回一个新类型的类型就是泛型类型。

下面示例定义了两个可以接收入参 P 的泛型类型:

type GenericReflectFunction<P> = (param: P) => P; // 类型别名

interface IGenericReflectFunction<P> { // 接口
  (param: P): P;
}

const reflectFn4: GenericReflectFunction<string> = reflect; // 具象化泛型

const reflectFn5: IGenericReflectFunction<number> = reflect; // 具象化泛型

const reflectFn3Return = reflectFn4('string'); // 入参和返回值都必须是 string 类型

const reflectFn4Return = reflectFn5(1); //  入参和返回值都必须是 number 类型

在泛型定义中,我们甚至可以使用一些类型操作符进行运算表达,使得泛型可以根据入参的类型衍生出各异的类型,如下代码所示:

type StringOrNumberArray<E> = E extends string | number ? E[] : E;

type StringArray = StringOrNumberArray<string>; // 类型是 string[]

type NumberArray = StringOrNumberArray<number>; // 类型是 number[]

type NeverGot = StringOrNumberArray<boolean>; // 类型是 boolean

如果传入的是string | boolean,则:

type BooleanOrString = string | boolean;

type WhatIsThis = StringOrNumberArray<BooleanOrString>; // 好像应该是 string | boolean ?

type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString; //  string | boolean

这是所谓的分配条件类型(Distributive Conditional Types):在条件类型判断的情况下(比如上边示例中出现的 extends),如果入参是联合类型,则会被拆解成一个个独立的(原子)类型(成员)进行类型运算。

只有泛型 + extends 三元,才会触发分配条件类型。

注意:枚举类型不支持泛型。

泛型约束

我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束。

比如最前边提到的原封不动返回参数的 reflect 函数,我们希望把接收参数的类型限定在几种原始类型的集合中,此时就可以使用“泛型入参名 extends 类型”语法达到这个目的,如下代码所示:

function reflectSpecified<P extends number | string | boolean>(param: P):P {
  return param;
}

reflectSpecified('string'); // ok
reflectSpecified(1); // ok
reflectSpecified(true); // ok
reflectSpecified(null); // ts(2345) 'null' 不能赋予类型 'number | string | boolean'

同样,也可以把接口泛型入参约束在特定的范围内,如下代码所示:

// ReduxModelSpecified 泛型仅接收 { id: number; name: string } 接口类型的子类型作为入参
interface ReduxModelSpecified<State extends { id: number; name: string }> {
  state: State
}

type ComputedReduxModel1 = ReduxModelSpecified<{ id: number; name: string; }>; // ok
type ComputedReduxModel2 = ReduxModelSpecified<{ id: number; name: string; age: number; }>; // ok
type ComputedReduxModel3 = ReduxModelSpecified<{ id: string; name: number; }>; // ts(2344)
type ComputedReduxModel4 = ReduxModelSpecified<{ id: number;}>; // ts(2344)

我们还可以在多个不同的泛型入参之间设置约束关系,如下代码所示:

interface ObjSetter {
  <O extends {}, K extends keyof O, V extends O[K]>(obj: O, key: K, value: V): V; 
}

const setValueOfObj: ObjSetter = (obj, key, value) => (obj[key] = value);

setValueOfObj({ id: 1, name: 'name' }, 'id', 2); // ok
setValueOfObj({ id: 1, name: 'name' }, 'name', 'new name'); // ok
setValueOfObj({ id: 1, name: 'name' }, 'age', 2); // ts(2345)
setValueOfObj({ id: 1, name: 'name' }, 'id', '2'); // ts(2345)

它拥有 3 个泛型入参:第 1 个是对象,第 2 个是第 1 个入参属性名集合的子集,第 3 个是指定属性类型的子类型(这里使用了 keyof 操作符)。

泛型入参与函数入参还有一个相似的地方在于,它也可以给泛型入参指定默认值(默认类型),且语法和指定函数默认参数完全一致,如下代码所示:

interface ReduxModelSpecified2<State = { id: number; name: string }> {
  state: State
}

type ComputedReduxModel5 = ReduxModelSpecified2; // ok
type ComputedReduxModel6 = ReduxModelSpecified2<{ id: number; name: string; }>; // ok
type ComputedReduxModel7 = ReduxModelSpecified; // ts(2314) 缺少一个类型参数

泛型入参的约束与默认值还可以组合使用,如下代码所示:

interface ReduxModelMixed<State extends {} = { id: number; name: string }> {
  state: State
}

这里我们限定了泛型 ReduxModelMixed 入参 State 必须是 {} 类型的子类型,同时也指定了入参缺省时的默认类型是接口类型 { id: number; name: string; }

注意{}和object的区别:{} 表示所有原始类型和非原始类型的集合,object 表示所有非原始类型的集合。