TypeScript 高阶教程(第一篇)– 把 TypeScript 当作静态类型语言来使用

前言

我用 TypeScript 很多年了,从 Angular 2 发布就开始用,那时候是 v1.8 左右。

之后的好几年里,我只写了一两篇学习笔记,直到 2022 年底,才做了一次知识整理,写了三篇 TypeScript 教程。

今年(2025 年),我计划把 Angular 高阶教程写完。

鉴于 TypeScript 是 Angular 必备知识,我也顺便对这三篇 TypeScript 教程做了修订。

 

语言基础概念 の 值、类型、强、弱、静态、动态

在讲解 TypeScript 之前,我们先了解一些非常基础、简单的语言概念,这有助于我们从源头认识 TypeScript。

值(Value)

'hello world' // 字符串
100           // 整数
100n          // 大整数
100.5         // 浮点数
true          // 真
false         // 假

上面这些是 JavaScript 里头的 "值"。

类型

这些值会被分类成 JavaScript 八大类型。(不熟悉 JavaScript 类型的读者可以看这篇

透过 typeof 可以获取值的类型信息。

typeof 'hello world' // 'string'
typeof 100           // 'number'
typeof 100n          // 'bigint'
typeof 100.5         // 'number'
typeof true          // 'boolean'
typeof false         // 'boolean'

除了 stringnumberbigintboolean 之外,还有 undefinedsymbolobjectnull

typeof undefined // 'undefined'
typeof Symbol()  // 'symbol'
typeof {}        // 'object'
typeof null      // 'object'

注:typeof null 返回 'object' 是历史遗留错误,实际上 null 值被归类为 null 类型。

知识点:

  1. stringnumberbooleansymbolbigintnullundefined

    这 7 个是原始类型(primitive type),同时也是值类型(value type)。

  2. object 则是非原始类型(non-primitive type),同时也是引用类型(reference type)。

  3. 一个值只会被归类为其中一种类型。

  4. 这 8 个类型,互不兼容,也没有 "类型继承" 或 "类型层次" 的概念。

    不像 C#,object 是所有类型的基类;在 JavaScript,object 与其它类型平级,也没有继承关系。

为什么要给 "值" 分 "类"?

因为特定的类型往往有着特定的程序逻辑。

比如说 string(字符串),它有大小写的概念,我们会用 toUpperCasetoLowerCase 来转换大小写。

number 则没有大小写之分,我们不可能对一个数字说要 toUpperCase 变大写,这显然说不通。

另一方面,number 可以拿来做计算,比如:100 * 100 = 10000

而布尔值则不可能拿来做计算,比如:100 * true = ?,这显然不合理。

因此,对值进行分类有助于我们更好地组织和管理程序代码。

强类型 与 弱类型

既然对值进行了分类,就应该好好利用类型对程序代码加以管理。

怎么个管理法呢?—— 约束。

有些语言是强类型(如 C# / Java),有些语言是弱类型(如 JavaScript)。

强与弱的区别在于类型约束的严格程度。

还是以上面的例子:100 * true = ?

在 JavaScript,100 * true = 100。why 🤔 ?

因为 JavaScript 是弱类型语言,它会隐式类型转换(implicit type conversion)。

console.log(100 * true); // 100

boolean true 在运行时会被隐式类型转换为 number 1。(false 转 0,true 转 1)

就像这样

console.log(100 * Number(true)); // 100

所以,结果是 100 * 1 = 100

反观 C# 就不同了。

C# 是强类型语言,它对类型约束是严格的,不会隐式类型转换。

Console.WriteLine(100 * true); // IDE Error: Operator '*' cannot be applied to operands of type 'int' and 'bool'

int * bool 会在我们编程时直接被 IDE 报错提示。

如果想要得和 JavaScript 相同的结果,我们需要手写类型转换(a.k.a 显式类型转换 explicit type conversion):

Console.WriteLine(100 * Convert.ToInt16(true)); // 100

隐式类型转换是一把双刃剑,它可以让代码更简洁(因为无需手写类型转换相关代码),但也容易引起 bug 和 runtime error。

因为像 number * boolean 这样的代码通常不是有意为之,更可能是写错了。

隐式类型转换会使这些错误被忽略,从而在运行时导致 bug 和 error。 

因此,在复杂项目中,人们通常更倾向于使用 "强类型语言" 来加强代码约束、降低出错概率;而在较为简单的项目中,"弱类型语言" 的灵活与简便则更受青睐。

补充说明:C# int 可以隐式转换成 double,因为 int 兼容 doubledouble 的精度范围足以精确表示所有 int 32 位整数),这类 "不会丢失信息" 的情况才允许隐式类型转换。

静态类型 与 动态类型

语言类型除了分 "强" 和 "弱",也分 "静态" 和 "动态"。 

同样是利用类型特点对程序代码进行约束和管理。

静态类型与强类型一样,走的是严格路线,程序代码比较严谨,不容易出 bug;

动态类型与弱类型一样,走的是宽松路线,程序代码比较简便,但容易出 bug。

我们来看一段静态类型的 C# 代码

var value = "";        // ✅ assign string
value = "hello world"; // ✅ assign string again
value = 0;             // ❌ assign int IDE Error: Cannot implicitly convert type 'int' to 'string'

再看一段动态类型的 JavaScript 代码

var value = "";        // ✅ assign string
value = "hello world"; // ✅ assign string again
value = 0;             // ✅ assign number

静态类型的 variable 与类型是绑定的,一旦确定为 string,就不能再 assign 其它类型的值给它。

动态类型的 variable 与类型无关,它只是一个单纯的值容器,要 assign 什么类型的值总是可以的。

还有一点,静态类型允许在变量、参数、函数返回值、属性等位置上显式声明类型(有些甚至是强制要求声明类型):

string value; // 这个变量是 string 类型

// 函数返回值是 string 类型
// 参数 param 是 string 类型
string GetValue(string param)
{
  return "";
}

class Person
{
  // Person.Name 是 string 类型
  public required string Name { get; set; }
}

有了明确稳定的类型,如果不小心 assign 错误类型的值,IDE 会立刻报错:

value = 0;   // IDE Error: Cannot implicitly convert type 'int' to 'string'
GetValue(0); // IDE Error: Argument 1: cannot convert from 'int' to 'string'

静态类型就是透过这种约束方式,使程序代码更严谨,从而能够更早、更容易地发现 bug。

强、弱、静、动

通常,"强类型" 和 "静态类型" 是一对,像 C#、Java、Go 就属于这类,非常严格。

"弱类型" 和 "动态类型" 是另一对,像 JavaScript、PHP(7.0 之前)就属于这类,非常宽松。

当然,也有一半一半的,比如 C++ 属于 "静态类型 + 弱类型":

int value = 0;
value = "";   // IDE Error: invalid conversion from ‘const char*’ to ‘int’
value = true; // ok

variable 声明为 int 类型,因此 assign string 会报错,这是静态类型的约束。

但 assign true 却可以通过,这是因为弱类型会进行隐式类型转换,true 自动转换成了 int 1

总结

语言中有 "值",值会被分 "类"。

类型与程序逻辑密切相关,透过类型可以很好地约束程序代码。

弱类型:会隐式类型转换,编程简便(代码量少、约束少),但容易出 bug。

强类型:不会被随意隐式类型转换,通常需要显式类型转换,编程严格(代码量多、约束多),不容易出 bug(或者说在编程阶段就可以尽早发现 bug)。

静态类型:先把类型固定好,大家依类型办事,编程严格,不容易出 bug。

动态类型:不固定类型,大家灵灵活活办事,编程简便,但容易出 bug。

如果一门语言的任务简单,它多半会是弱类型 + 动态类型。

弱类型 + 动态类型的优点是代码简便(代码量少、约束少),缺点是容易出 bug。

但因为任务简单,出 bug 的几率本就不高,这个缺点也就被抵消了。

一个典型的代表就是 2008 年以前的 JavaScript。

注:2008 年 v8 引擎诞生、2009 年 Node.js 诞生,往后 JavaScript 就不再只用于简单任务了,也因此 2012 年才有了 TypeScript。

相反,如果一门语言的任务复杂,它多半会是强类型 + 静态类型。

强类型 + 静态类型在编程时会麻烦一些(需要声明类型、代码量多、约束多),但代码更严谨,运行时 bug 自然就更少。

典型的代表有 C#、Java。

好,以上就是语言与类型的基础概念。

 

语言基础概念 の 编译、解析

除了强、弱、静态、动态类型以外,编译与解析也是非常重要的语言基础概念。

同样地,理解它们有助于我们从源头认识 TypeScript。

初代汇编语言

我们先来考古一下。

1949 年,第一台基于冯·诺伊曼架构的计算机诞生。

注:冯·诺伊曼架构一直沿用至今哦。

虽然历史上第一台计算机诞生得要更早几年,但那不是冯·诺伊曼架构,所以我们就不谈那个了。

当时的计算机主要由这几个硬件组成:

  • CPU(型号 EDSAC,由英国剑桥大学研发)

  • 寄存器(register)

  • 内存(delay-line memory)

  • 打孔机(punched tape punch)

  • 打孔纸带 (punched paper tape)

  • 纸带阅读器 (paper tape reader)

  • 电传打字机(teleprinter)

 

 

 

 

当时的计算机主要由这几个硬件组成:

  1. CPU(e.g. EDSAC 英国剑桥大学研发的)

    用来做运算

  2. 寄存器(Register)、内存(RAM)

    寄存器 > 缓存 > 内存 > 磁盘,这 4 个都是用来存储数据的。

    寄存器读写最快,磁盘读写最慢。

    1949 年缓存和磁盘都还没有诞生。

  3. 打孔纸带 (Punched Tape)

    image

    它是一卷长长的纸带,纸带上一行可以打 5 个孔,有孔代表 1,无孔代表 0。

    当年就是靠这个来表示二进制的,相等于一行 5 个 bit。
  4. 读卡机

    类似现代键盘的作用,用于输入。

  5. 打印机、打孔机

    类似现代屏幕的作用,用于输出。

 

 

 

 

 

EDVAC

最早(1940 年代)的计算机只有 CPU、内存、读卡机和灯泡指示器。

如果想用 CPU 做运算,就需要把 CPU 指令(二进制机器码)写在打孔卡上,

交给读卡机读取,接着 CPU 由从 CPU 运算后会把结果通过灯泡显示出来(也是一种二进制的表示)。

汇编语言 & 汇编器

直接写二进制机器码实在太强人所难了。

所以没过几年,汇编语言就诞生了。

汇编不是二进制,而是由字母、数字和符号组成(简称助记符)语言。

这是一条 CPU 指令机器码:10110001 00000001

它的意思是 "把数字 1 放入寄存器 AX"

题外话:寄存器 → 缓存 → 内存 → 磁盘,这些都是计算机用来储存数据的硬件,寄存器读写最快,磁盘最慢。

这条机器码对应的汇编是 MOV AX, 1

从写机器码 10110001 00000001 变成写汇编 MOV AX, 1,是不是轻松多了呢?

打孔机

打孔卡其实比 CPU 还早诞生。它不仅可以用来表示二进制,也可以表示汇编助记符。

虽然都是打孔(物理上依旧是有孔和无孔,零或一),但每个孔打在不同的 column 和 row 上都有特定含义,它有一套规则。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 机器码10110001 00000001

  • 汇编MOV AX, 1

📌 作用:把数字 1 放入寄存器 AX(加法可以类似写 ADD AX, 2)。

 

人可以容易的阅读和编写。

 

 

 

 

 

 

 

 

 

 

 

 

硬件、OS、可执行文件、机器码、汇编语言

先讲一个基础的计算机概念:一台电脑之所以能运行,靠的是硬件(主要有 CPU、RAM 和磁盘)和软件(Operating System,简称 OS)。

注:这里从 1960 年代说起,再早一点的打孔机时代就不讲了。

OS 负责管理硬件资源,并为程序提供操作硬件的接口。

如果我们想用 CPU 做运算,就需要先创建一个可执行文件 —— 俗称程序。

可执行文件是二进制文件,里面的内容主要是 CPU 指令 —— 俗称机器码。

程序员不可能直接用二进制写机器码(虽然在 1940 年代确实都是这样做),于是出现了低级语言 —— 汇编。

它就像机器码的 "翻译版",用助记符表示 CPU 指令,让程序员能够阅读和编写。

然后,通过汇编器(另一个程序)将汇编翻译成机器码,并生成可执行文件,由 OS 运行这个可执行文件,CPU 就会按照指令完成运算。

跨平台 の 汇编

所谓的跨平台,是指我们编写的程序代码能否同时支持不同的设备。

汇编是面向 CPU 指令编程,这也意味着不同的 CPU 架构,其汇编写法会有所不同。

市场上比较流行的 CPU 架构有 x86-64(主要用于桌面和笔记本)和 ARM64(主要用于手机和平板)。

如果我们的程序想要支持不同的 CPU 架构,就必须编写多套汇编代码。

除了 CPU 架构,不同的 OS 也可能导致汇编差异。

例如,我们不仅要操作 CPU,还想操作磁盘。

操作磁盘需要在汇编中加入 OS API 调用(也是 CPU 指令)。

而不同 OS(Windows、Linux、macOS)的 API 指令写法不尽相同。

因此,即便 CPU 架构相同,但 OS 不同,也可能因为要调用 OS API 而导致汇编代码不一样。

结论:汇编基本上完全不具备跨平台能力,因为它就是直接面向 CPU 指令和 OS API 编程的低级语言,一旦 CPU 架构或 OS 不同,代码也就不同。

高级语言、C++、编译

现在很少有人直接写汇编了。

程序员一般会选择使用高级语言,例如 C++。

C++ 不同于汇编,它并不是 CPU 指令的翻译,而是以更接近人类思维的方式来编写程序。

掌握 C++ 并不要求了解 CPU 指令。

从 C++(源代码)到最终的 CPU 指令(机器码),中间需要一个编译过程。(注:不是翻译,是编译)

透过编译器(另一个程序)把 C++ 编译成机器码,就能得到可执行文件。

最后,由 OS 运行这个可执行文件,CPU 就能按照指令完成运算。

我们来试一遍,需求是:利用 CPU 运算,找出 1 到 100 之间所有质数。

创建一个 ConsoleApplication.cpp 文件,里面写上 C++ 代码:

#include <iostream>
#include <vector>
#include <cmath>

std::vector<int> findPrimes(int limit)
{
  std::vector<int> primes;

  for (int i = 2; i <= limit; i++)
  {
    bool isPrime = true;

    for (int j = 2; j <= std::sqrt(i); j++)
    {
      if (i % j == 0)
      {
        isPrime = false;
        break;
      }
    }

    if (isPrime)
    {
      primes.push_back(i);
    }
  }

  return primes;
}

int main()
{
  int limit = 100;

  std::vector<int> primesUpTo100 = findPrimes(limit);

  for (int prime : primesUpTo100)
  {
    std::cout << prime << " ";
  }
  std::cout << std::endl;

  return 0;
}

打开 C++ 编译器 x64 Native Tools Command Prompt for VS 2022。

image

注:不同的 OS 会有不同的编译器,我是 Windows 用的是 Visual Studio 自带的 C++ 编译器。

接着 cd 进入 folder,然后 cl ConsoleApplication.cpp

image

它会把 ConsoleApplication.cpp 里头的 C++ 代码编译成机器码,并生成一个可执行文件 ConsoleApplication.exe。

接着我们运行这个可执行文件,就可以看到 1 到 100 内所有的质数了。

image

ConsoleApplication.exe 是二进制机器码,需要透过 dumpbin(另一个程序)把它转换成汇编,我们才能查阅。

image

打开 code.txt

image

有三个内容块:

0000000140001020 是内存地址

48 83 EC 28 是十六进制的机器码(CPU 指令)。

sub rsp,28h 是机器码的汇编翻译。

跨平台 の c++

todo 

以浏览器作为例子

 

 

 

 

 

C#、字节码、虚拟机、编译 AOT & JIT

 

 

 

 

 

 

 

 

 

总结

语言中有 "值",值会被分 "类"。

类型与程序逻辑密切相关,透过类型可以很好地约束程序代码。

弱类型:会隐式类型转换,编程简便(代码量少、约束少),但容易出 bug。

强类型:不会被随意隐式类型转换,通常需要显式类型转换,编程严格(代码量多、约束多),不容易出 bug(或者说在编程阶段就可以尽早发现 bug)。

静态类型:先把类型声明好,大家依类型办事,编程严格,不容易出 bug。

动态类型:不固定类型,大家灵灵活活办事,编程简便,但容易出 bug。

编译型:代码需要先经过编译才能拿去运行,通常编程时慢,运行时快。

解析型:代码无需编译,可以直接拿去运行,通常编程时快,运行时慢。

如果一门语言的任务简单,它多半会是解析型、弱类型、动态类型。

解析型语言可以加快编程和调试速度(因为无需编译),弱类型和动态类型则使编程更简便(代码量少、约束少)。

优点得到了充分发挥;至于缺点,由于任务简单,出 bug 的几率本身较低,性能稍慢也无关紧要,扬长避短 👍 。

一个典型的代表就是 2008 年以前的 JavaScript。

注:2008 年 v8 引擎诞生、2009 年 Node.js 诞生,往后 JavaScript 就不再只用于简单任务了,也因此 2012 年才有了 TypeScript。

相反,编译型、强类型、静态类型语言(如 C#、Java)通常用于更复杂的任务。

编译型 + 静态类型可以在运行时达到极致的性能。

静态 + 强类型在编程时稍麻烦(要写类型、代码量多、约束多),但代码严谨,运行时 bug 更少。

典型的代表有 C#、Java。

好,以上就是语言与类型的基础概念。理解它们,对于深入掌握 TypeScript 至关重要。

 

 

编译型 与 解析型

一门语言,在编写代码后,如果可以直接拿去运行(如 JavaScript 丢给浏览器),就可以被认为是解析型语言。

反之,如果一门语言在编写代码后,需要先经过编译(build),然后才能拿去运行,那它被称为编译型语言,如 C#、Java。

两者最大的区别在于速度。

解析型语言在最终运行时通常会比编译型语言慢。

不过,编译型语言多了一个编译过程,这个过程需要额外耗时。

例如早期使用 C# 开发时,每修改一次代码都需要一次完整编译,然后才能运行;而 PHP 则不需要编译,直接就可以运行。

虽然 PHP 运行时会比 C# 慢一些,但节省下来的编译时间往往更多(尤其在编程调试阶段)。

当然,近年来已有许多改进。C# 引入了 Hot Reload 技术,每次修改代码只需重新编译变更的部分,从而节省了大量编译时间。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

   

TypeScript 简单介绍

JavaScript 是动态类型语言,我们在书写的时候不需要声明类型。

index.js

function getValue(param) {
  return 'return' + param;
}

没有任何类型信息。

TypeScript 的作用是在 JavaScript 语言基础上,混入类型声明。

index.ts(.ts 代表是 TypeScript 文件)

function getValue(param: string): string {
  return 'return' + param;
}

明确定义了参数类型 string 和函数返回类型 string

特别说明一下:JavaScript 不是没有类型,它只是动态而已,像 string 就是其中一种 JavaScript 类型。(不熟悉 JavaScript 类型的读者可以参数这篇 —— 《JavaScript – 数据类型》)

除此之外,TypeScript 本身还是一门编程语言,但与其它编程语言不同,它的编程对象不是 "功能",而是 JavaScript 的 "类型"。

function getValue(param: string): string {
  return 'return' + param;
}

// 这是一段 TypeScript 编程语句
// GetValueParam 代表的是 getValue 函数的第一个参数类型 —— 也就是 string。
type GetValueParam = typeof getValue extends (...params: infer TParams) => unknown ? TParams[0] : never;

// 我们故意 assign 错误的 value 类型试试...
const getValueParam: GetValueParam = 0; // 结果 IDE 报错了: Type 'number' is not assignable to type 'string'
// 因为 GetValueParam 要求 value 类型必须是 string
// 可是我们 assign 的 value 是 0,零是 number 不是 string,所以 IDE 报错了。

我们暂且不探究具体语法,只要知道 TypeScript 最主要的两个特性就可以了:

  1. 在 JavaScript 里混入类型声明

  2. 作为一门编程语言,专门用来编写 JavaScript 的 "类型"(尤其是那种复杂的动态类型)。

 

教程介绍

《TypeScript 高阶教程》总共有三篇。

第一篇

第一篇教的是基础 —— 把 TypeScript 当作静态类型语言来使用。

TypeScript 比一般的静态类型语言(C# / Java)来的复杂,但绝大多数项目用不到那么深。

因此,我们只需要掌握到能把 TypeScript 当作静态类型语言(如 C# / Java)来使用就已经很足够了。

比如说:掌握好 class、interface、enum、generic、overloading 这些 C# / Java 也有的特性。

第二篇

第二篇教的是进阶 —— 把 TypeScript 当作编程语言来使用。

C# / Java 面对动态类型时,主要靠三招:Generic(泛型)、Overloading(重载)、Casting(强转)。

这三招对 JavaScript 这种超级动态类型语言来说,根本不够用。

因此,TypeScript 引入了一些编程能力(如 variable、function、if else、recursive 等)。

我们可以用 TypeScript 透过编程,给 JavaScript 描述动态类型。

简单说就是:用一个语言(TypeScript)编写另一个语言(JavaScript)的类型。

这种手段可以大幅度较少 overloading 代码,对于 Library 项目非常有用。

第三篇

第三篇教的是 —— 类型体操。

虽然 TypeScipt 可以算是一门编程语言,但它的语法是烂到....我都不知道该怎么形容。

因此,在用 TypeScript 描述 JavaScript 动态类型时,语句会非常的......难以形容,就像做体操那样,非常复杂、需要反复练习,才有可能真正熟悉和掌握。

第三篇就是要和大家一起,把第二篇的知识学以致用,拿来操练操练。

好,我们开始吧 🚀。

 

参考

TypeScript 高级类型及用法

你不知道的 TypeScript 高级技巧

TypeScript学习笔记——TS类型/高级用法及实战优缺点

你不知道的 TypeScript 高级类型

Typescript高级用法

YouTube – Advanced TypeScript Tutorials

YouTube – TypeScript Template Literal Types // So much power

TypeScript 类型体操姿势合集<通关总结>--刷完

TS挑战通关技巧总结,助你打通TS奇经八脉

Ts高手篇:22个示例深入讲解Ts最晦涩难懂的高级类型工具

以前写过的 TypeScript 笔记:

angular2 学习笔记 (Typescript)

Angular 学习笔记 (Typescript 高级篇)

 

Why TypeScript?

为什么要使用 TypeScript?

动态类型语言 の 优缺点

动态类型语言(如 JavaScript)在书写时不需要声明类型,这样的好处是代码量少,写起来轻便。

但也因为少了类型,IDE 无法借助类型分析提供辅助;当遇到新手程序员时,程序就容易出现大量的 runtime error。

比如

function removePrefix(value, prefix) {

  // runtime error : value.startWith is not a function
  if (value.startWith(prefix)) { 

    return value.substring(prefix.length);
  }
  return value
}

const withoutPrefix = removePrefix('stg-my-component', 'stg-');

不小心把 startsWith 写成 startWith ,结果就 runtime error 了🙄。

静态类型语言 の 优缺点

静态类型语言(如 TypeScript)则相反,它在书写时虽然比较麻烦,代码量比较多。

但也因为有了类型声明,IDE 可以借助它做分析,并提供良好的辅助。

同样的例子(by TypeScript):

IDE 知道参数 valuestring,而 string 只能调用 String.prototypeObject.prototype 里的方法。

这两个 prototype 里都没有一个叫 startWith 的方法;倒是有一个叫 startsWith 的方法,所以多半是拼写错了。

因此,不需要等到 runtime error,在拼写错 startWith 的当下,IDE 就立刻会提示错误。

JSDoc vs TypeScript

除了 TypeScript,JSDoc 也能给 JavaScript 添加类型声明。

IDE 同样可以分析类型并提供辅助。

不过,JSDoc 和 TypeScript 并不是一个量级的东西。

JSDoc 只能提供基础的类型声明,而 TypeScript 则更强大,它可以借助编程能力描述复杂的动态类型。

当然,使用 TypeScript 所需付出的代价也相对更高(唉......又是那句老话:everything has a price)。

Svelte 早年选用 TypeScript 开发,但后来发现代价太大,最终改成(重写)使用 JSDoc。

因此,我们在选用 TypeScript 之前,也可以先了解一下 JSDoc,权衡其利弊和代价。

Compile & Transpile

TypeScript 有两个沉重的代价。

第一个是 —— 它需要学,而且越进阶越难学,尤其是作为编程语言的部分。

第二个是 —— 它需要编译(compile or transpile)。

JSDoc 是透过注释的方式,为 JavaScript 声明类型。

它没有破坏 JavaScript 语法。

TypeScript 则不同,它将类型声明直接混入 JavaScript 代码之中。

这样一来,它就不是标准的 JavaScript 了。

而浏览器只能解析 JavaScript,不能解析 TypeScript。

因此,我们写的 TypeScript 必须先编译成 JavaScript(去除混入的类型声明),浏览器才能正确的解析执行。

注:TypeScript 声明的类型在编译后就被移除了,那些类型仅仅用于开发阶段而已(only for IDE, not browser)。

而编译是需要花费时间的。

一般开发会分为两个阶段:

  1. 一边写代码,一边测试结果

    每次修改、保存代码后,都需要经过一轮 TypeScript → JavaScript 编译。

    所幸有 hot reload 技术,只有被修改的部分代码需要重新编译,通常耗时非常短(毫秒级),完全可以接受。

  2. 程序最后的打包发布

    这个阶段通常会非常耗时(分钟级),因为它需要把整个项目的 TypeScript 编译成 JavaScript。

    代码量越多,编译的时间就越长,也越不能被接受。

    号外:最近 TypeScript 团队正在使用 Go 语言重写编译器,目的就是想彻底解决这个编译耗时的问题。相关项目 —— typescript-go

有代价不是不行,只要有相应的回报就可以了。

编译的代价那么大,仅仅用于类型检测和辅助,实在有点得不偿失。

因此,TypeScript 还有另一个好处 —— 语法降级(syntax downleveling)。

比如说,ECMAScript(简称 ES)推出一个新的语法,浏览器不见得会立刻支持,即便 modern 浏览器支持,用户也未必会使用最新版本的浏览器。

所以我们常常需要等好多年才能用上新语法。

有了 TypeScript 就不同了,我们编写的是新语法,但在 TypeScript → JavaScript 编译过程中,新语法会自动 fallback to 旧语法。这样就能兼容旧版本的浏览器了。

注:当然,不是所有的新语法都能用旧语法实现。

总结:

  1. 对比 JSDoc 使用注释声明类型,TypeScript 选择把类型混写入 JavaScript 中,主要是因为这样更直观(其它静态类型语言也都是这样)。

  2. 这种做法破坏了 JavaScript,因此需要额外做 TypeScript → JavaScript 编译。

  3. 编译的代价很大,但收益也很大。除了可以做类型检测和辅助,还可以做 syntax downleveling、性能优化等等。

总之,TypeScript 在绝大部分情况下都是利大于弊的,只有在两种极端情况下,可以考量不使用:1. 项目太小,2. 项目太大。

 

表达与理解

TypeScript 是一门语言。

语言嘛,就两个点:表达与理解。

我们主要是学习表达的部分,理解则交由 IDE。

表达

let value: string;

作为表达方,上面这一句想表达的是,有一个 variable,它的类型是 string

理解

作为理解方(如 IDE),当 assign 的 value 不是 string 时,就要发出警示 —— 这叫类型检测。

此外,IDE 还能透过类型分析,提供 intellisense(help tips)—— 这叫辅助。

整个过程就是:我们表达类型 → IDE 理解类型 → 并依据类型做出相应的检测和辅助。

TypeScript 的发展与局限

在书写 TypeScript 时,我们常会有以下两种感受:

  1. 表达不出来

    就像我们日常说话一样,词汇量如果不够,能表达的准确度就会受限。

    感受:类型逻辑编不出来......只能写 hardcode 😞。
  2. IDE 不理解

    有时候 IDE 会 get 不到我们想表达的意思。

    感受:我表达的不清楚吗,怎么它没有后续的辅助🤔?

会出现这类状况,其实都是因为 TypeScript 还不够完善。

我们可以去 Github Issue 区看看高赞(👍)的 feature requests,说不定已经在 roadmap 上,或者有人提供了 workaround。

好,前情提要就到这,接下来正式开课 🚀。

 

TypeScript の 类型声明 Annotations

再提醒一次,JavaScript 是动态类型,并不是没有类型。

JavaScript 著名的 8 大类型:stringnumberbooleansymbolbigintnullundefinedobject。(不熟悉的话,可以看这篇 —— 《JavaScript – 数据类型》)

string 表示字符串(e.g. '''hello world'),number 表示整数或浮点数(e.g. 00.5),这些在 TypeScript 中同样适用。

给 variable 声明类型

从最简单的开始,给 variable 声明类型

const value: string = ''; // 声明 variable 是一个 string 类型

// 等价于 C# 的
// string value = "";

给 variable 声明类型以后,会发生两件事:

  1. 类型检测 assigned value

    variable 类型是 string,意味着 assign 给它的 value 必须是 string(即字符串)。

    const value: string = 0; // IDE Error: Type 'number' is not assignable to type 'string'

    如果 assign 的 value 不是 string(上面 0 是整数,类型为 number),那 IDE 就会报错。

    像这样就是正确的:

    const value: string = 'hello world'; // assign 字符串(即 string 类型)就不会报错了。
  2. 型辅助 variable 使用

    IDE 会在我们使用 variable 时,提示 string 相关的方法(也就是 String.prototypeObject.prototype 里的方法)

    image

    如果我们拼写错方法名字,IDE 也会报错

    const value: string = ''
    value.startWith(''); // IDE Error: Property 'startWith' does not exist on type 'string'. Did you mean 'startsWith'?

给 function 声明类型

声明函数参数和返回值的类型

function getValue(
  param1: string, // 参数一是 string 类型
  param2: number  // 参数二是 number 类型
): string {       // 返回值是 string 类型
  return param1 + param2;
}

跟给 variable 声明类型大同小异。

同样,如果 assign 错类型或 return 错类型,IDE 就会报错。

function getValue(param1: string,  param2: number): string {   
  return 0; // 返回值 IDE Error: Type 'number' is not assignable to type 'string'
}

getValue(0, '');
// 参数一 IDE Error: Argument of type 'number' is not assignable to parameter of type 'string'
// 参数二 IDE Error: Argument of type 'string' is not assignable to parameter of type 'number'

给 class 声明类型

class 的属性、方法、构造函数声明类型。

class Person {
  name: string = 'default name';
  age: number;

  constructor(age: number) {
    this.age = age;
  }

  getValue(param: string): string {
    return this.name + this.age + param;
  }
}

跟给 variable、function 声明类型大同小异。

 

TypeScript 类型 1.0

TypeScript 有许多类型,除了原有的 JavaScript 8 大类型之外,还新增了许多 "更具体" 或 "更抽象" 的类型。

这些类型有助于我们在实战中更灵活地游走于静态与动态之间。

我会分两个阶段,由浅入深,先教一些简单的,复杂的则会放到下一 part 《TypeScript 类型 & 特性 2.0

原始类型 primitive types

JavaScript 有 8 大类型:stringnumberbooleanobjectnullundefinedbigintsymbol。(参考:JavaScript – 数据类型

其中 7 个是原始类型:stringnumberbooleannullundefinedbigintsymbol;剩下一个是非原始类型 —— object

object 我们先跳过,其余 7 个原始类型 TypeScript 都有。

const value1: string = '';
const value2: number = 0;
const value3: bigint = 123n;
const value4: boolean = false;
const value5: null = null;
const value6: undefined = undefined;
const value7: symbol = Symbol();

string 要求 assign 的 value 是字符串(e.g. '''hello world')。

number 要求 assign 的 value 是整数、浮点数(e.g. 00.5)。

bigint 要求 assign 的 value 是整数+n(e.g. 0n123n)。

boolean 要求 assign 的 value 是 truefalse

null 要求 assign 的 value 是 null

undefined 要求 assign 的 value 是 undefined

symbol 要求 assign 的 value 是 Symbol() 创建的。

class 实例

class Person {
  name: string = 'Derrick'
}

const person: Person = new Person();

person: Person 要求 assign 的 value 是 Person 实例。

specified-type array

JavaScript 没有 array 类型,array 被归类为 object 或者 Array 实例。

但这两个类型都不够精准,而 TypeScript 的 array 类型则可以精准地表示 string array、number array,或任何特定类型的 array。

const stringList: string[] = [''];  
stringList.push(0); // IDE Error: Argument of type 'number' is not assignable to parameter of type 'string'

const numberList: number[] = ['']; // IDE Error: Type 'string' is not assignable to type 'number'

string[] 要求 assign 的 value 是 array,并且里面的 item 只能是 string 类型(即字符串)。

而且哦,当我们调用 push 方法传入 item 时,它也会做类型检测。

number[] 要求 assign 的 value 是 array,并且里面的 item 只能是 number 类型(即整数或浮点数)。

array 类型还有另一种写法是这样

const stringList: Array<string> = [''];  

Array<string> 中的 Array 指的是 class Array

const stringList: Array 只能表示 stringListArray 实例,但无法表示 array 中的 item 是 string 类型。

Array<string> 则表示了 item 是 string 类型。

这种语法叫 "泛型",许多静态类型语言(C# / Java)也有。

下面还会详细教,这里点一下就好。

variable function

let getValueFn: (param: string) => string;

(param: string) => string 要求 assign 的 value 是一个函数(箭头函数或 function 都可以),函数的第一个参数要是 string,返回值要是 string

getValueFn = function (param: string): string {
  return param;
};

// 箭头函数也是可以
getValueFn = (param: string): string => {
  return param;
};

void

许多静态类型语言(C# / Java)都有 void

TypeScript 也有,但它的 void 和其它语言略有不同。

我们先看看 C# 中的 void

string value = ""; // 声明 variable 是 string 

// 声明函数返回值是 string
string GetValue()
{
  return "";
}

上面是声明 string 类型。

我们把 string 换成 void 类型看看。

void value; // IDE Error: Keyword 'void' cannot be used in this context

直接报错了,因为在 C#,void 不是一个类型,它只是一个 keyword,用来表示函数没有返回值。

void DoSomething1() { }         // void 的意思是函数没有 return
void DoSomething2() { return; } // 或者是有 return 但是没有 value

void DoSomething3()
{
  // 有 return value 就不符合 void 的要求了,直接报错。  
  return ""; // IDE Error: Since 'DoSomething3()' returns void, a return keyword must not be followed by an object
}

TypeScript 其实是想借鉴 C# 的概念,同样用 void 来表示函数没有返回值。

但是,它遇到了两个问题:

第一个问题是 JavaScript 的特性 —— 函数默认返回 undefined

function doSomething() {
  // 没有 return
}

const value = doSomething(); // 把 "没有 return" 装进 variable
console.log(value);          // 结果是 undefined

JavaScript 函数至少都会返回 undefined,它不像 C# / Java 有正真意义上的 "没有返回值"。

下面这三句都会返回 undefined

function doSomething1() { }                   // 完全没有 return,但会返回 undefined。
function doSomething2() { return; }           // 有 return 但是没有 value,也是返回 undefined。
function doSomething3() { return undefined; } // 有 return 但 value 是 undefined,更是返回 undefined。

如果 TypeScript 完全按照 JavaScript 的行为去设计,那所谓的 "没有返回值" 应该是用返回 undefined 类型来表示。

function doSomething1(): undefined { }
function doSomething2(): undefined { return; }
function doSomething3(): undefined { return undefined; }

不过这样一来,就和其他静态类型语言很不一样,而且语义上也无法凸显出 "没有返回值" 这个概念。

因此,TypeScript 做了一些特别设计

function doSomething1(): void { }                   // 完全没有 return 符合 void 的要求
function doSomething2(): void { return; }           // 有 return 但是没有 value 也符合 void 的要求
function doSomething3(): void { return undefined; } // return undefined 依然符合 void 的要求

// return 其它类型的 value 就不行
function doSomething4(): void { return null; }      // IDE Error: Type 'null' is not assignable to type 'void'

void 在语义上和 C# / Java 一样,用来表示函数没有返回值。

但在行为上,由于 JavaScript 无法真正做到 "没有返回值",所以 "返回 undefined" 就成了 "没有返回值"。

第二个问题是,在 C#,void 是 keyword,而不是类型。

string GetString() { return ""; } // 返回 string
int GetInt() { return 0; }        // 返回 int

void DoSomething() {  }           // 没有返回

表面上看,void 的位置和 stringint 一样,会让人误以为它是一种类型。

但实际上不是,void 不能作为 variable 的类型,因为它是 keyword,不是类型。

void value; // IDE Error: Keyword 'void' cannot be used in this context

TypeScript 没有跟随 C# 这个概念 —— 在 TypeScript,void 就是一种类型。

我们可以给 variable 声明 void 类型

let value: void;

虽然可以,但不应该这样做,因为 void 的本意是用来表示函数没有返回值。把它用在 variable 是想表达什么呢?这不合理。

void vs undefined

从上面的几个例子来看,voidundefined 视乎是一样的。

但实际上,void !== undefined

function doSomething1(): void { }
function doSomething2(): void { return; }
function doSomething3(): void { return undefined; }

一个函数,声明其返回值类型必须是 void

那么,这个函数具体应该返回什么?

答案是 "不要返回"。

但在 JavaScript,没有 "不返回" 的概念,即便没写 return,它也会默认返回 undefined

因此,void 一定要可以接受 undefined 作为 "没有返回值"。

把这个概念放到 variable 也是如此。

const value: void = undefined; // ok

assign undefinedvoid 是可以的。

但是!反过来就不行。

function doSomething(): void { }

const value: undefined = doSomething(); // IDE Error: Type 'void' is not assignable to type 'undefined'

value: undefined 要求 assign 的 value 必须是 undefined

虽然我们知道 doSomething 最终返回的值是 undefined,但从语义上来讲,doSomething 其实是 "没有返回",而不是 "返回 undefined"。

这也是为什么 void 不能 assign to undefined

voidundefined 本来应该是没有关联的,只是因为 JavaScript 函数有默认返回 undefined 特性,void 为了兼容这种状况,才允许 undefined 可以 assign to void

总结

虽然讲了很多缘由,但回到实战上,只要遵循好 best practice 就可以了。

  1. void 只用在函数返回类型就好,不要用在 variable。

    function doSomething(): void { }   // good
    const value: void = doSomething(); // bad
  2. void 函数,不要 return undefined

    function doSomething1(): void { }                   // good
    function doSomething2(): void { return; }           // good
    function doSomething3(): void { return undefined; } // bad

    虽然它可以,但我觉得 follow 其它语言的使用方式会更顺风水。

any

JavaScript 是动态类型语言

let value = '';
console.log(typeof value); // 'string'
value.substring(0);        // call String.prototype method

value = 0;
console.log(typeof value); // 'number'
value.toFixed();           // call Number.prototype method

variable 的类型可以换来换去(e.g. from string to number)。

如果因为使用 TypeScript 而完全丧失原有的动态能力,那未必是一件好事。

因此 TypeScript 新增了一个 any 类型。

any 代表任何类型

const v1: any = '';    // assign string ok
const v2: any = 0;     // assign number ok
const v3: any = true;  // assign boolean ok

任何种类的 value 都可以 assign to any 类型。

如果不使用 any 类型,上面 JavaScript 代码会无法通过类型检测。

let value: string = '';
value.substring(0);

value = 0;       // IDE Error: Type 'number' is not assignable to type 'string'
value.toFixed(); // IDE Error: Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?

换成 any 就可以。 

let value: any = '';
value.substring(0);

value = 0;       // ok
value.toFixed(); // ok

当然,这是一种取舍。any 等同于 bypass 了所有 TypeScript 的类型检测。

同时也失去了类型辅助,因为它可能是任意类型,任何提示都有可能是错的,所以就不能提示了。

any 是非常手段,非到迫不得已,不建议使用。 

unknown

unknown 经常用来替代 any,它用来表示 "暂时还不知道" 类型。

首先,它和 any 一样,可以接受任何种类的 value。

const v1: unknown = '';    // assign string ok
const v2: unknown = 0;     // assign number ok
const v3: unknown = true;  // assign boolean ok

但在使用时,它和 any 不同。

let value: unknown = '';
value.substring(0); // IDE Error: 'value' is of type 'unknown'

value = 0;          // ok
value.toFixed();    // IDE Error: 'value' is of type 'unknown'

any 不会有类型检测,但 unknown 会检测。

那要如何使用 unknown 呢?

unknown 类型需要搭配类型判断才能使用,我们看一个例子:

function doSomething(param: unknown): void {
  param.substring(0); // IDE Error: 'param' is of type 'unknown'
  param.toFixed();    // IDE Error: 'param' is of type 'unknown'
}

doSomething('');
doSomething(0);

如果传入的参数是 string,调用 .toFixed() 就会 runtime error;如果传入的参数是 number,调用 .substring(0) 就会 runtime error。

因此,在没有搞清楚 param 是什么类型前,IDE 不允许使用 param

function doSomething(param: unknown): void {
  if (typeof param === 'string') { // 判断类型为 string
    param.substring(0); // ok 
  }

  if(typeof param === 'number') {  // 判断类型为 number
    param.toFixed();    // ok 
  }
}

经过类型判断(术语叫 Narrowing,下面会详细讲解)之后,param 的类型就不再是 unknown 了,IDE 也就能根据判断出的类型提供相应的检查和辅助。

any vs unknown

// assign 任何种类的 value 给 any 都行
let anyValue: any = 'whatever type value';
anyValue = 0;
// 要如何操作 any 都行 
anyValue.substring(0); // 会 runtime error


// assign 任何种类的 value 给 unknown 都行
let unknownValue: unknown = 'whatever type value';
anyValue = 0;
// 在明确类型以前,不得操作 unknown
unknownValue.substring(0); // IDE Error: 'unknownValue' is of type 'unknown'

if (typeof unknownValue === 'string') {
  // 确认 unknown 是 string 之后,就可以把它当作 string 来使用了
  unknownValue.substring(0); // ok
}

any 就是完全 bypass TypeScript,不管是 assign 还是使用,IDE 都不会进行检测,也不会提供辅助,这可能会导致 runtime error。

unknown 相对安全,它可以任意 assign,但在使用时需要先判断清楚类型(Narrowing),否则无法通过类型检测。因此,它不会导致 runtime error。

never

never 是 "绝不" 类型,或者说 "不存在的类型"、"没有这种类型"。

没有任何一种 value 可以 assign to never 类型。

const v1 : never = 0;         // Type 'number' is not assignable to type 'never'
const v2 : never = '';        // Type 'string' is not assignable to type 'never'
const v3 : never = true;      // Type 'boolean' is not assignable to type 'never'
const v4 : never = 0n;        // Type 'bigint' is not assignable to type 'never'
const v5 : never = Symbol();  // Type 'symbol' is not assignable to type 'never'
const v6 : never = null;      // Type 'null' is not assignable to type 'never'
const v7 : never = undefined; // Type 'undefined' is not assignable to type 'never'

// void 也不能 assign to never
const voidFn : () => void = () => {};
const v8: never = voidFn();   // Type 'void' is not assignable to type 'never'

那...它有啥用呢?

它常用来表示 "永不 return" 的函数。

function fn1(): never {
  return ''; // IDE Error: Type 'string' is not assignable to type 'never'
} 

function fn2(): never {
  // IDE Error: A function returning 'never' cannot have a reachable end point
} 

一个要求返回 never 类型的函数,无论 return 什么 value 都不行;什么都不 return 也不行。

唯一能做的是让它:

function infinityLoop(): never {
  while (true) {} 
}

function throwError(): never {
  throw new Error('');
}

const v1: never = infinityLoop();
const v2: never = throwError();

white (true) 就 infinity loop 了,函数永远执行不完,这就符合了函数返回类型要求的 never

throw error 函数就中断跳出了,所以它也代表没用

 

 

 

其实也蛮好理解的,white (true) 函数就执行不完了,v2 自然啥也不是,用 never 来表示 "绝不"、"不存在的类型"、"没有这种类型" 都挺贴切的。

throw 也是同样道理,程序中断了,v3 自然啥也不是。

我们不可能会去使用一个 never 类型的 variable,因为程序根本执行不到那一行。

const v3: never = throwError();
// 上面 throw error 了,下面代码在 runtime 时,根本不会执行


// 尝试判断 v3 的类型
if (typeof v3 === 'string') {
  // 尝试使用 string 的方法
  v3.substring(0); // IDE Error: Property 'substring' does not exist on type 'never'
}

即便我们 "硬写",给它加上类型判断也没用。总之,never 就是 "不会发生",所以无论怎么写都行不通。

void vs never

function throwError1 (): void { throw new Error(''); }
function throwError2 (): never { throw new Error(''); }

请问函数返回是 void 还是 never

函数没用 return,符合 void 的条件,所以可以说它是 void

函数有 throw error,符合 never 的条件,所以也可以说它是 never

上面两行代码都正确,都不会有 IDE Error。

 

 

 

 

 

 

 

 

 

 

这里再给一个真实场景用例:

function getValue(param: string): string {
  if (param === '') {
    return '';
  }
  throw new Error('error');
}

param 不等于 empty string '',程序就 throw error。

现在我们要封装一个函数,里面负责 log + throw。

image

logAndThrow 返回 void,但 getValue 要求返回 string,因此不符合要求。 

我们需要把 logAndThrow 的返回类型改成 never 才可以。

function getValue(param: string): string {
  if (param === '') {
    return '';
  }
  logAndThrow('error');
}

// 把返回改成 never
function logAndThrow(errorMessage: string): never { 
  window.localStorage.setItem('errorMessage', errorMessage);
  throw new Error(errorMessage);
}

你体会到 never 的实际作用了吗?

除此之外,never 还有一些奇妙的用法:

const values: never[] = [];
values.push(0); // Argument of type '0' is not assignable to parameter of type 'never'

never[] 可以用来表示 empty array。

因为无论往 array 里放什么 value,都无法通过 never 的类型检测,这就变相使它成了 empty array。

至于,什么情况下会可能需要一个 empty array 我就不知道了 🤷 。

 

静态类型语言的特性 の 管理与约束

除了类型声明以外,TypeScript 还支持许多静态类型语言(如 C# / Java)的特性。

比如:abstract class、access modifiers、interface、enum 等等。

这些特性对代码管理、约束都很有帮助。

注:我不会特意讲解这些特性本身(好处、用法等等),只会讲解 TypeScript 如何支持这些特性(语法层面)。

类型推断 Type Inference

let value = '';
value.toFixed();    // IDE Error: Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?
value.substring(0); // ok

value 没有声明类型,但 IDE 却能从 assigned value '' 推断出它的类型是 string,并据此做出后续的类型检测,这就是所谓的 "类型推断"。

类型推断只发生在第一次 assign value 的时候

let value = ''; // 类型推断为 string
value = 0;      // IDE Error: Type 'number' is not assignable to type 'string'

第二次就不会类型推断了,会变成类型检测。

其它例子:

let v1 = '';        // 类型推断为 string
let v2 = 0;         // 类型推断为 number
let v3 = true;      // 类型推断为 boolean
let v4 = Symbol();  // 类型推断为 symbol
let v5 = 0n;        // 类型推断为 bigint
let v6 = null;      // 类型推断为 null
let v7 = undefined; // 类型推断为 undefined
let v8 = [''];      // 类型推断为 string[]
let v9 = () => '';  // 类型推断为 () => string

BTW,类型推断不是 TypeScript 独有的,其它静态类型语言(C# / Java)也有。

// C#
string v1 = ""; // 声明类型 string 
var v2 = "";    // 类型推断为 string

类型推断和类型声明的区别

let v1: string = '';
let v2 = ''; 

虽然 v1v2 最终的类型都是 string,但在类型声明上,v1v2 是完全不同的概念。

v1: string 明确声明了类型,在 assign value 时,IDE 会做类型检测,如果 value 不是 string ,那 IDE 就会报错。

v2 没有明确声明类型,assign value 时,IDE 不会做任何检测,v2 最终的类型是透过 "推断 value 的类型(type infer)" 得出来的。

为什么需要类型推断?

类型推断最大的好处就是减少类型声明代码。

let v1: string = ''; // 类型声明 —— 代码多
let v2 = '';         // 类型推断 —— 代码少

第二行看上去就像是纯 JavaScript,但它却享有 TypeScript 的类型检测和辅助功能,这不是很好吗?

什么情况下,"类型声明" 会优于 "类型推断" ?

类型推断的特色是代码量少,优点是书写时比较轻松,阅读时比较简洁;缺点是没有检测,阅读时不能一目了然看见类型。

用在 variable 上,缺点不明显

let value = ''; // value 是 string

因为从声明 variable 到 assign value 的时间间隔很短,程序员不太容易出错,所以无需检测。

而在阅读时,虽然不是一目了然看见类型 string,但透过旁边的 '',程序员一般也能快速反应出它是 string,所以不会有太大的影响。

反观,在函数返回类型上,使用类型推断的缺点就明显了。

function getValue() {
  return '';
}
const value = getValue(); // 经过类型推断 (from return ''),value 的类型是 string

从 "声明函数返回类型" 到写完函数内容,这断时间可能是漫长的,程序员有机率弄错返回值的类型。

因此,采用 "类型声明" 让 IDE 帮助检测会更理想。

另外,在阅读代码时,程序员必须找到 return '' 才能获知函数返回类型,这不仅不是一目了然,更是严重增加了阅读成本。

因此,采用 "类型声明",让代码一目了然地显示函数返回类型,才是正确的做法。

Abstract class and method(抽象类和方法)

abstract class Animal {
  name = '';
  abstract run(param: string): void; // 抽象方法只定义类型,不含具体实现
}

class Dog extends Animal {
  // 派生类必须实现基类的抽象方法  
  run(param: string): void {}
}

const person = new Animal(); // IDE Error: Cannot create an instance of an abstract class (抽象类不能 new)
const derrick = new Dog();   // ok

抽象类(abstract class)表示这个类不能直接被 new,只能被其它类继承(extends)。

抽象方法(abstract method)表示基类(based class)只声明类型,具体的实现必须由派生类(derived class)来完成。

Access Modifiers(public, protected, private)

class 的属性和方法可以用 private, protected, public 来限制访问。

private 表示只有当前 class 内的方法可以访问。

protected 表示只有当前 class 和这个 class 的派生类可以访问。

public 表示没有限制,哪里都可以访问(属性和方法,若无声明,默认就是 public

class Parent {
  private _onlyThis = '';              // only this class can access
  protected onlyThisAndDescendant = 0; // only this and derived class can access

  // wherever can access
  public method() {
    console.log(this._onlyThis); // can access private
  }
}

class Child extends Parent {
  method() {
    console.log(this.onlyThisAndDescendant); // can access protected
    console.log(this._onlyThis);             // IDE Error: Property '_onlyThis' is private and only accessible within class 'Parent'.

  }
}

const child = new Child();
child.method(); // can access public

// IDE Error: Property 'onlyThisAndDescendant' is protected and only accessible within class 'Parent' and its subclasses
console.log(child.onlyThisAndDescendant);

console.log(child._onlyThis); // IDE Error: Property '_onlyThis' is private and only accessible within class 'Parent'

TS private vs JS #private

JavaScript 也有 private key 概念(es2022 推出的),但 TypeScript 的 private  和 JavaScript 的 #private  并不是同一个概念,千万不要混淆。

TypeScript 的 private 在 runtime 是无效的,它仅用于 IDE 检测。

在 TypeScript compile to JavaScript 的过程中,private 会被移除,而不是转换成 JavaScript 的 #private。 

而 JavaScript 的 #private 在 runtime 是有效的,访问 #private key 会 runtime error ,而且 #private key 是 not enumerable(无法被遍历出来)。

best practice:习惯使用 TypeScript 的人,一般上都会选择使用 TS 的 private 而不是 JS 的 #private

Function / Method overloading(函数 / 方法重载)

函数重载是指:一个同名函数有着不同数量或不同类型的参数,或者有着不同类型的返回值。

function getValue(param1: string): string; 
function getValue(param1: number): number;
function getValue(param1: string | number): string | number {
  return param1;
}

有三个 doSomething 函数,前两个用来声明不同类型的调用,最后一个用来定义函数具体实现:

第一个声明:输入 string 返回 string

第二个声明:输入 number 返回 number

第三个声明:输入 string | number union tpyes,返回 string | number。(就是把第一和第二声明合起来)

函数重载对调用友好

const valueString: string = getValue(''); // 传入 string 返回值就是 string
const valueNumber: number = getValue(0);  // 传入 number 返回值就是 number

但对开发维护就比较累,重载就好像写一堆的 if / else if 那样,非常丑。(下面会教如何用泛型来优化重载)

小心坑

getValue 函数不接受输入 string | number

查看 getValue 函数的类型是这样:

要嘛输入 string,要嘛输入 number,没有输入 string | number 的。

也就是说,虽然我们定义了三个 function,但只有前两个是类型声明,最后一个不算类型声明。相关 Github Issue

解决方法是添加多一个类型声明

function getValue(param1: string): string; 
function getValue(param1: number): number;
function getValue(param1: string | number): string | number; // 添加多一个类型声明
function getValue(param1: string | number): string | number {
  return param1;
}

function doSomething (param1: string | number) {
  const value = getValue(param1); // 没有 Error 了
}

class 的方法也支持重载

class Person {
  getValue(param1: string): string; 
  getValue(param1: number): number;
  getValue(param1: string | number): string | number {
    return param1;
  }
}

const person = new Person();
const value: string = person.getValue('');

特别提一点:TypeScript 的方法重载和 C# 的方法重载,写法是不一样的。

C# 的方法重载长这样

public class Person
{
    public void Method()
    {
        // logic A...
        Method("default"); // call another overload method
    }
    
    public void Method(string name)
    {
        // logic B...
    }
}

不同重载方法有不同的执行逻辑,而且可以相互调用。

TypeScript 只能有一个执行逻辑,它只是在类型声明上有 multiple。

因此,若想做到类似 C# 那样,需要这样写:

class Person {
  method(): void;
  method(name: string): void;
  method(name?: string): void {
    if (name === undefined) {
      internalMethodA();
    } else {
      internalMethodB(name);
    }

    function internalMethodA() {
      // logic A
      internalMethodB("default");
    }
    function internalMethodB(name: string) {
      // logic B
    }
  }
}

个人比较习惯 C# 的方法重载模式。

你可能不需要 Overloading

函数在被调用时,如果有重载,那它可以选择不同的版本

在 IDE 的辅助下,调用体验非常好。

可是,写重载是有代价的:代码量多 if / else if 不好维护。

如果不使用重载,参数就会变成一个 union types

参数是 union types 或许还能接受,但返回值也是 union types 就比较麻烦了。

因为返回值通常还有后续的操作,如果没有准确的类型,IDE 就无法提供准确的辅助,整体体验就会差很多。

目前无解,但未来(TypeScript v5.9)或许可以靠 conditional return type 来解决。

到时候,说不定我们就能减少对重载的使用了。

Overloading 对 v8 引擎优化有伤害?

我不熟悉 v8,所以这一段只是道听途说,大家自行验证。

据《V8引擎是如何工作的?》说,v8 执行一个函数调用以后,会缓存编译好的代码,当函数再次被调用时,它就不需要再编译多一次,这样就变快了。

但是,使用缓存的前提是函数的参数类型必须和上一次一样,如果参数类型不一样了,那缓存就失效了。

函数重载的特色就是同一个函数有不同类型的参数调用,显然这违背了 v8 使用缓存的前提,所以个人认为确实有可能影响优化。

为了安全起见,我们可以考虑在重载函数外,写多几个具体实现,这样至少外面的函数可以被优化。


// 可以优化
function _getStringValue(param1: string): string {
  return param1;
} 
// 可以优化
function _getNumberValue(param1: number): number {
  return param1;
}

// 不可以优化
export function getValue(param1: string): string; 
export function getValue(param1: number): number;
export function getValue(param1: string | number): string | number; 
export function getValue(param1: string | number): string | number {
  return typeof param1 === 'string' ? _getStringValue(param1) : _getNumberValue(param1);
}

const valueString: string = getValue('');
const valueNumber: number = getValue(0);

这个写法也非常接近我们上面提到的 TS 模拟 C# overloading。

Interface

TypeScript 也支持 interface(接口),就如同 C# / Java 一样。

// 接口只负责定义类型,不含具体实现
interface CanJump {
  jump(param1: string): void;
}

interface CanFly {
  fly(param1: string): void;
}

class Animal implements CanJump, CanFly {
  // 必须实现接口的定义
  jump(param1: string): void {}
  fly(param1: string): void {}
}

interface extends interface

接口可以继承接口,这点也和 C# / Java 一样。

interface CanJump {
  jump(param1: string): void;
}

interface CanFly {
  fly(param1: string): void;
}

// 接口多继承接口
interface Animal extends CanFly, CanJump {  
  name: string;
}

class Bird implements Animal {
  // 所有接口都要实现
  name: string = 'Angry';
  fly(param1: string): void {}
  jump(param1: string): void {}
}

multiple interface

TypeScript 可以定义多个同名的 interface,它们的属性和方法会被合并起来。(注:C# / Java 没有这项机制)

interface Animal {
  jump(param1: string): void;
}

interface Animal {
  fly(param1: string): void;
}

class Bird implements Animal {
  // fly 和 jump 都要实现
  fly(param1: string): void {}
  jump(param1: string): void {}
}

这个特性可以用来扩展原生接口,比如 Window

interface Window {
  stringValue: string;
  numberValue: number;
}

// window 多了两个属性:stringValue 和 numberValue
const stringValue: string = window.stringValue; 
const numberValue: number = window.numberValue;

interface for function

interface 除了可以用来声明对象的属性和方法,还可以用来声明 variable function。

const getValueFn1: (param1: string) => string = param1 => param1;
const getValueFn2: (param1: string) => string = param1 => param1;

// 等价于
interface GetValueFn {
  (param1: string): string;
}
const getValueFn3: GetValueFn = param1 => param1;
const getValueFn4: GetValueFn = param1 => param1;

这个特性也常用来声明 "既是函数又是对象" 的结构,比如 class + 静态属性。(不熟悉的可以看这篇:JavaScript – 理解 Function, Object, Class, Prototype, This, Mixins) 

interface Person {
  name: string;
}

interface PersonClass {
  new (...args: unknown[]): Person; // 必须可以 new 然后返回 person 实例
  age: number;                      // 有静态属性 age
}

class Derrick implements Person {
  name = '';
  static age = 0;
}

function doSomething(PersonClass: PersonClass) {
  const personInstance = new PersonClass(); // able to new
  console.log(personInstance.name);         // able access instance property
  console.log(PersonClass.age);             // able access class static property
}

function normalFunction() {}
doSomething(Derrick); // ok
doSomething(normalFunction); // Error: Argument of type '() => void' is not assignable to parameter of type 'IClassFunction'

interface for function overloading

interface 可以声明函数,也可以声明函数重载。

但它有一些些不完善的地方,使用时要小心。

interface DoSomething {
  (param1: string): string;
  (param1: number): number;
  (param1: string | number): string | number;
}

function doSomething(param1: string): string;
function doSomething(param1: number): number;
function doSomething(param1: string | number): string | number;
function doSomething(param1: string | number): string | number {
  return param1;
}

const doSomething1: DoSomething = doSomething;

上面这样是 ok 的,但如果我换成箭头函数就不行了。

const doSomething1: DoSomething = param1 => param1; // error

function doSomething2(param1: string | number): string | number {
  return param1;
}

const doSomething3: DoSomething = doSomething2;     // error

这两个都不行,原因是需要提供一个满足所有重载的函数。 

param1 => param1 相等于 (string | number) => string | number 而已,没有满足 string => stringnumber => number

同理 doSomething2 也是一样。

虽然直觉上我们会认为,我们提供一个具体兼容不同类型的函数,应该算是满足了重载,但对 TypeScript 来说并不是这样。

有兴趣想搞明白其中原理的朋友可以看下面这两个 Github Issues,我个人的建议是少用 interface for function overloading。

Github – Allow overloads to be specified in interface

Github – Support overload resolution with type union arguments

Enum

TypeScript 也支持 enum,就如同 C# / Java 一样。

enum Status {
  Processing,
  Completed,
  Canceled,
}

const status: Status = Status.Processing;
console.log('status', status); // 0,输出的是 number

如果想输出 string 也可以

enum Status {
  Processing = 'Processing',
  Completed = 'Completed',
  Canceled = 'Canceled',
}

const status: Status = Status.Processing;
console.log('status', status); // 'Processing', 输出的是 string

Enums as flags

TypeScript 的 enum 也支持 flags,就如同 C# / Java 一样。

不熟悉 flags 概念的可以看这篇《C# and TypeScript – Enum Flags》。

enum Status {
  Processing = 1 << 0,
  Completed = 1 << 1,
  Canceled = 1 << 2,
}

function process(status: Status): void {
  const availableProcessStatus = Status.Processing | Status.Completed;

  if ((availableProcessStatus & status) > 0) {
    // 类似于 if (availableProcessStatus.includes(status))
    console.log('can process');
  } else {
    console.log(`can't process`);
  }
}
process(Status.Processing); // can process
process(Status.Completed);  // can process
process(Status.Canceled);   // can't process

get enum values

TypeScript 其实是把 enum compile to JavaScript 的对象。

特别要注意的是 stringnumber compile 的结果是不一样的:

  1. string

  2. number

最终出来的对象长这样

number 版本可以双向获取,用 string key 获取 numbervalue(通常都是用这个);也可以反过来用 number key 获取 string value(很少会用这个)。

string 版本比较简单,key 就是 key,value 就是 value,两边都是 string

另外,若想获取所有的 key / value,可以使用 Object.keys(Status) 或者 Object.values(Status)

如果是 number 版本的话,需要额外 filter 掉 number key 或者 number value。

console.log(Object.keys(Status).filter(key => /^[^\d]+$/.test(key))); // ['Processing', 'Completed', 'Canceled']

Generic 泛型

TypeScript 也支持泛型,就如同 C# / Java 一样。

静态类型语言的缺点就是不够动态(废话),而泛型的目的就是让它稍微 "动态一点"。

函数 with 泛型

function getValue<T>(param: T): T {
  return param;
}

const value1: number = getValue<number>(0);  // value1 是 number
const value2: string = getValue<string>(''); // value2 是 string

function getValue<T>(param: T): TgetValue 函数的类型声明,这样解读:

<T> 是 Type(类型)的缩写,它是一个类型代号(注:我们要放其它字母也行)。

它代表着一个动态类型,所谓 "动态" 是指:在声明函数类型的当下,我们先不定义它具体是什么类型;

我们只定义这个动态类型用在什么地方,比如:参数 param 和返回值。

等到调用 getValue<number>(0) 函数的时候,我们才传入 <number> 给动态类型 <T>

此时函数的具体类型变成了

function getValue(param: number): number {
  return param;
}

参数 paramnumber,返回值也是 number

那如果调用时传入的是 string(getValue<string>('')),函数类型则变成

function getValue(param: string): string {
  return param;
}

参数 paramstring,返回值也是 string

如果不使用泛型,而是使用函数重载,那代码量可就多了

泛型可以大大减少 overloading 的使用,比如说

function getValue(param: string): string;
function getValue(param: number): number;
function getValue(param: boolean): boolean;
function getValue(param: null): null; 
// ...以及所有其它类型
function getValue(param: any): any {
  return param;
}

const value1 = getValue(0);  // value1 是 number
const value2 = getValue(''); // value2 是 string

用 overloading 我们需要把每一个可能的类型都写进去。

用泛型的话,只需要一行就够了。

function getValue<T>(param: T): T {
  return param;
}

另外,泛型也可以用在箭头函数哦

const getValue = <T>(param: T): T => {
  return param;
};

const numberValue: number = getValue<number>(1);

class / interface with generic

classinterface 同样可以搭配泛型

// 可以有多个动态类型
class Person<TValue, TParam> {
  constructor(value: TValue) {
    this.value = value;
  }
  value: TValue;

  getValue(param: TParam): TValue {
    return this.value;
  }
}

const person1 = new Person<number, string>(0);
const value1: number = person1.getValue(''); // value1 是 number
const person2 = new Person<string, number>('');
const value2: string = person2.getValue(0);  // value2 是 string

// 接口用法和 Class 一样
interface Animal<TValue> {
  value: TValue;
}

const person3: Animal<string> = {
  value: '',
};

泛型约束

// 约束 T 类型必须至少是一个 array
function getValue<T extends unknown[]>(values: T): T {
  // 虽然不确定 T 具体是什么类型,但可以确定它至少是个 array,所以 Array.prototype 方法都可以使用
  values.forEach(value => console.log(value));
  return values;
}
const value1 = getValue([1, 2, 3]); // value1 是 number[]

const value2 = getValue(['a', 'b', 'c']); // value2 是 string[]

const value3 = getValue(123);
// IDE Error: Argument of type 'number' is not assignable to parameter of type 'unknown[]'
// 123 是 number 不是 array,所以报错了。

T extends unknown[] 表示传入的 T 类型至少要是一个 array,如:string[]number[]boolean[],这些都可以接受。

因为有约束(T 至少是个 array),所以在函数内,IDE 会把 T 当作 array 来检测和辅助。

注:约束用的是 extends 而不是 equals,所以不需要完全相等,只要是 "一种" 关系就可以了。

另外,TypeScript 的 extends 博大精深,会涉及到鸭子类型、协变、逆变等概念,这些下面会详细讲解。

默认泛型

TypeScript 的泛型可以设置默认类型,这点比 C# 还厉害。

先看一个没有设置默认泛型,同时没有传入泛型的情况

function getArray<T>(): T[] {
  return [];
}

const values = getArray(); // values 是 unknown[]

IDE 不会报错,只是类型是 unknown 而已。

有声明默认泛型的话

function getArray<T = string>(): T[] {
  return [];
}

const values = getArray(); // values 是 string[]

它就不是 unknown 了。

泛型 の 类型推断

泛型也有类型推断机制

function getValue<T>(param: T): T {
  return param;
}

getValue<string>(123); // IDE Error: Argument of type 'number' is not assignable to parameter of type 'string'.

参数 param 是泛型。

当调用 getValue 时,如果有指定泛型 <string>,那参数值就会被类型检测。

这就类似于 variable 的类型检测

const param: string = 123; // IDE Error: Type 'number' is not assignable to type 'string'

反之,调用 getValue 时,如果没有指定泛型,那参数值就会变成一种类型推断。

function getValue<T>(param: T): T {
  return param;
}

const value = getValue(123); // value 的类型是 number literal 123

泛型来自于参数值 123 的类型推断。

这就类似于 variable 的类型推断机制

const param = 123; // value 的类型是 number literal 123

如果泛型被用于多个参数,那情况会有点微妙

function getValue<T>(param1: T, param2: T): T {
  return param1;
}

const value1 = getValue('abc', 'xyz'); // value 的类型是 'abc' | 'xyz'

const value2 = getValue('efg', 123);   // IDE Error: Argument of type '123' is not assignable to parameter of type '"abc"'

两个 string literal('abc''xyz')会变成 union types 'abc' | 'xyz'

一个 string literal 'efg' 和一个 number literal 123,会类型检测报错。

它的规则是:泛型只能是一个类型。如果是一个 number 一个 string ,那不行;但两个不同的 string literal 则是可以的,因为它们抽象还是 string

提醒:这个规则只限于类型推断,如果是传入指定泛型,像 getValue<number | string>('efg', 123); 这是可以的。

泛型 の 类型推断 vs 默认泛型

"泛型类型推断" 优先于 "默认泛型"

function getValue<T = string>(param: T): T {
  return param;
}

const v1 = getValue(123); // v1 的类型是 number literal 123。它不会类型检测,而是采用类型推断

const v2 = getValue<string>(123); // 有传入指定泛型才会类型检测
// IDE Error: Argument of type 'number' is not assignable toparamter of type 'string'

虽然设置了默认泛型,但仍然不会检测类型,而是启动了类型推断。

NoInfer<T> 阻止类型推断给泛型

默认情况下,参数会被类型推断给泛型,使用 NoInfer 可以阻止这个行为。

function getValue<T>(param: NoInfer<T>): T {
  return param;
}

const value = getValue(123); // value 的类型是 unknown

NoInfer 加到参数 param 的类型上(从 param: T 变成 param: NoInfer<T>)。

这样 "类型推断给泛型" 就被阻止了。

<T> 没有默认泛型 + 调用时没有传入指定泛型 + NoInfer 阻止了类型推断给泛型 = <T> 最终是 unknown

因此,最后 value 的类型也是 unknown

注:NoInfer 是 TypeScript 的特殊语法,专门用来阻止泛型的类型推断。

NoInfer<T> 的使用场景

参数没了 "类型推断给泛型",反过来就会被泛型约束。

function getValue<T = string>(param: NoInfer<T>): T {
  return param;
}

getValue(123);
// IDE Error: Argument of type 'number' is not assignable to parameter of type 'string'

因为泛型默认是 string,因此 param 也必须是 string

如果没有 NoInfer 的话,由于 "泛型类型推断" 优先于 "默认泛型",因此参数不会有类型约束检测,反而参数类型(number literal 123)会被用作泛型。

再一个例子

function getValue<const T>(actions: T[], defaultAction: T): T {
  return defaultAction;
}

const value = getValue(['add', 'remove'], 'delete'); // value 的类型是 'add' | 'remove' | 'delete'

参数一 actions: T[] 被类型推断给泛型是 'add' | 'remove'

参数二 defaultActions: T 被类型推断给泛型是 'delete'

最终,泛型是两个参数的总和 union types 'add' | 'remove' | 'delete'

defaultAction 改成 NoInfer

function getValues<const T>(actions: T[], defaultAction: NoInfer<T>): T {
  return defaultAction;
}

const value = getValues(['add', 'remove'], 'delete');
// IDE Error: Argument of type '"delete"' is not assignable to parameter of type '"add" | "remove"'

没了类型推断,变成了类型检测,actions 成了 defaultAction 的约束。

总结

这一 part 主要介绍了 TypeScript 作为静态类型语言的一些特性。

这些特性都可以在其他静态类型语言(Java、C#)中看见。

从这里我们也能感受到,TypeScript 不仅仅是给 JavaScript 加入了类型,它还融入了许多静态类型语言所具备的约束与管理。

对比起 JSDoc,两者的区别就明显看出来了,JSDoc 仅仅只是增加了类型而已,而像 generic、overloading,这些特性通通都是没有的。

 

TypeScript 类型兼容性(Type Compatibility)

TypeScript 的类型兼容性和一般的静态类型语言(C# / Java)有相似之处,同时也存在一些区别。

这边我们就好好地理一理 🚀 。

所谓类型兼容性指的是

const value: string = 0; // IDE Error: Type 'number' is not assignable to type 'string'

这段代码里出现了两个类型:

  1. variable 要求的类型(target type) —— string

  2. value 的类型(source type) —— number

如果 0(source type number) 可以 assign to variable value(target type string),那就代表 number 兼容 string

换个角度理解:number 能否被当作 string 来使用?如果可以,那就是兼容。

那可以吗?当然不行!

numberstring 是完全不同的类型,number 不具备 string 的特征(比如 stringsubstring 方法,number 就没有),自然无法当作 string 来使用(即不兼容),所以 IDE 就报错了。

这个例子比较简单,"兼容与否" 一眼就看出来了,但并不是所有类型关系都能这样轻易判断 "兼容" 还是 "不兼容",比如:

const v1 : { name: string; age: number } = { name: '', age: 0 };
const v2 : { name: string } = v1; // ok

v1 是 object literal with 两个 key,v2 则要求 object literal with 一个 key。(v1v2 丰富)

v1 assign to v2 是 ok 的。

因为 v2 仅会使用到 name,所以只要给它 name 就满足了;而给多一个 age 也不要紧,因为在类型限制下,v2 访问不到 age。(给少不行,给多可以)

反观

const v1 : [string, number] = ['', 0];
const v2 : [string] = v1; // IDE Error: Type '[string, number]' is not assignable to type '[string]'

v3 是 tuple with 两个 item,v4 则要求 tuple with 一个 item。(v3v4 丰富)

v3 assign to v4 是不行的。(给少不行,给多也不行)

因为 tuple 经常会拿来 for loop,额外的 item 会直接影响程序结果;

而 object literal 很少拿来 Object.keys,所以额外的 key 通常不会影响程序结果。

仔细看 Object.keys 的返回类型

const person = { name: '', age: 0 }
const keys = Object.keys(person); // keys 的类型是 string[]

不是 string literal union type array Array<'name' | 'age'>,而是 Array<string>,正是因为它无法保证没有额外的 key。

从上述两个例子可以看出,object literal 和 tuple 在 "兼容与否" 规则的判断上是不同的。

object literal 允许额外的 key,而 tuple 却不允许额外的 item。

类似的兼容规则还有许多,我们接下来好好了解一番 🚀。

鸭子类型(structural typing)

TypeScript 的类型系统是 Structural Typing(俗称:鸭子类型),而 C# / Java 则是 Nominal Typing(名义类型)。

所谓 "鸭子类型" 指的是:一个东西长得像鸭子,叫声像鸭子,走路也像鸭子,那它就可以被当作鸭子来看待。(重点:只要像...就可以被当作)

对应到类型系统:只要一个对象具备某个 class 的属性和方法,即使它不是由这个 class 实例化出来的,我们仍然可以把它当作是这个 class 的实例来看待。(注重 "结构",而非 "名义")

例子说明:

有两个类:PersonProduct

class Person {
  name: string = '';
  age: number = 0;
}

class Product {
  name: string = '';
}

它俩的共同点是:都有属性 name

但是它俩没有继承关系,也没有共同的 interface

因此,如果站 C# / Java 角度看,这两个 class 八竿子打不着,一点关系也没有。

接着,有个 getName 函数

function getName(product: Product): string {
  return product.name;
}

参数 product 要求是一个 Product 类的实例。

问:把 Person 实例传进去会报错吗? Person 兼容 Product 吗?

const name = getName(new Person());

如果是 C#,那肯定会报错。

class Person
{
  public string Name { get; set; } = "";
  public int Age { get; set; } = 0;
}

class Product
{
  public string Name { get; set; } = "";
}

public class Program
{
  public static void Main()
  {
    static string GetName(Product product)
    {
      return product.Name;
    }

    var name = GetName(new Person());
    // IDE Error: Argument 1: cannot convert from 'CSharp13.Person' to 'CSharp13.Product'
  }
}

因为 C# 是 Nominal Typing(名义类型),它认的不是结构(structure)而是名义(nominal)。

C# 需要靠 interface 或继承来解决。

而 TypeScript 就不同,它完全不会报错。

因为 TypeScript 是 Structural Typing(鸭子类型),它认的是结构(structure)而非名义(nominal)。

product: Product 要求的不是一个 Product 实例,而是一个拥有 class Product 结构的对象。

class Person {
  name = '';
  age = 0;
}

class Product {
  name = '';
}

class Product 结构指的是:一个拥有 name: string 属性的对象。

Person 实例就是一个拥有 name: string 属性的对象,所以它可以满足 getName 参数的类型要求。

结论:因为是鸭子类型,所以 Person 兼容 Product

类型变异(Type Variance)

参考:协变与逆变 

提醒:以下 TypeScript 代码必须在严格模式下运行(tsconfig.json 配置 "strict": true 或者单独配置 "strictFunctionTypes": true)才有效。另外,也强烈建议大家 always 开启严格模式。

类型变异(Type Variance)也是 "类型兼容性" 中一个重要的概念。(注:其它静态类型语言也是有这个概念)

它是针对函数的兼容性规则。

我们透过一个例子来理解它 🚀 。

首先,有三个 class

class GrandParent {
  gValue = '';
}
class Parent extends GrandParent {
  pValue = '';
}
class Child extends Parent {
  cValue = '';
}

它们是继承关系 —— ChildParentGrandParent

接着有一个 doSomething 函数

function doSomething(fn: (param: Parent) => Parent): void {}

doSomething 有一个参数 fnfn要求是一个函数,这个函数接收一个 Parent(作为参数) 并返回一个 Parent(作为返回值)。

问题一:

function fn1(param: Parent): Parent {
  return new Parent();
}

doSomething(fn1);

fn1 传入 doSomething 会报错吗?(fn1 兼容 fn 吗?)

答:不会报错,因为 fn1 的参数是 Parent,返回值是 Parent,这和 doSomething 的参数 fn 所要求的类型 fn: (param: Parent) => Parent 完全一致。

问题二:

function fn2(param: GrandParent): Child {
  return new Child();
}

doSomething(fn2);

fn2 传入 doSomething 会报错吗?(fn2 兼容 fn 吗?)

fn2 的参数是 GrandParent,返回值是 Child。(GrandParent => Child

doSomething 参数 fn 的要求是:参数要是 Parent,返回值要是 Parent。(Parent => Parent

表面上看,fn2 没有对上类型,应该要报错。

但实际上,不会报错,也不会有 runtime error。

为什么🤔?

我们一步一步拆解分析,看看这其中的来龙去脉:

这是 doSomething 参数 fn 要求的类型:(param: Parent) => Parent

这是 fn2 的类型:(param: GrandParent) => Child

问:fn2 兼容 fn?(GrandParent => Child 兼容 Parent => Parent?)

兼容的另一种理解是:fn2 能否被当作 fn 来使用?

我们想一想 doSomething 内部会如何使用这个参数 fn

function doSomething(fn: (param: Parent) => Parent): void {
  const parent = fn(new Parent()); // 传入 Parent
  console.log(parent.pValue);      // 使用 Parent
}

它会传入 Parent,接着使用返回值 Parent

我们再回看 fn2 的类型 (param: GrandParent) => Child

如果内部调用 fn2时,传入 Parent ok 吗?

换句话说,fn2GrandParent,内部给 Parent,这 ok 吗?

答案是 OK。

因为 GrandParentParent 是继承关系。

在继承关系里,派生类(子类)拥有所有基类(父类)的属性和方法。

因此派生类(子类)可以被当作基类(父类)使用。

也就是说,fn2 需要 GrandParent(基类),内部给予 Parent(派生类),这完全可以满足。

好,fn2 的参数类型没问题了,我们继续看它的返回类型。

内部需要使用返回值 Parent,而 fn2 返回的是 Child,这 ok 吗?

答案还是 OK。

因为同样的道理:内部需要 Parent(基类),fn2 返回 Child(派生类);派生类可以用作基类,因此完全可以满足。

结论:fn2 可以被当作 fn 来使用(fn2 兼容 fn),因此 IDE 不会报错,也不会有 runtime error。

问题三:

function fn3(param: Child): GrandParent {
  return new GrandParent();
}

doSomething(fn3);

fn3 传入 doSomething 会报错吗?(fn3 兼容 fn 吗?)

我们套用回问题二的思路,再推演一遍问题三。

参数的部分:fn3 要 Child,内部传入 Parent,这 ok 吗?

答案是 No。

派生类(子类)可以替代基类(父类),是因为派生类(子类)具备所有基类(父类)的属性和方法。

反过来,基类 Parent 想替代派生类 Child 是不行的,因为基类 Parent 不具备所有派生类 Child 的属性和方法。

继续看返回值的部分:内部需要使用返回值 Parent,而 fn3 返回的是 GrandParent,这 ok 吗?

答案还是 No。

因为同样的道理:内部需要 Parent(派生类),fn3 返回 GrandParent(基类);派生类无法替代基类,因此完全不 ok。

结论:fn3 无法满足 fn 的使用(fn3 不兼容 fn),因此 IDE 类型检测会直接报错。

协变(Covariant)& 逆变(Contravariant)

上面我们是从原理上一步一步拆解分析,这里我们讲一讲术语。

当我们在判断一个函数(如上述的 fn1, fn2, fn3)是否 "兼容" 或 "满足" 另一个函数(doSomething 的参数 fn)时,主要看两个点:

  1. 参数类型

    如果 target 函数(doSomething 的参数 fn)要求的参数类型是 Parent,而 source 函数(fn2, fn3)的参数类型是 Parent 或它的基类(GrandParent),那就 ok,反之就不行。

    这种情况被称为 Contravariant(逆变),表示可以接受更抽象(或者说更宽泛 broader)的类型。

  2. 返回类型

    如果 target 函数(doSomething 的参数 fn)的返回类型是 Parent,而 source 函数(fn2, fn3)的返回类型是 Parent 或它的派生类(Child),那就 ok,反之就不行。

    这种情况被称为 Covariant(协变),表示可以接受更具体(或者说更狭窄 narrower)的类型。

结论:在判断 "source 函数" 能否兼容 "target 函数" 时,参数是逆变(往上去基类),返回则是协变(往下去派生类)。

doSomething 的参数 fn 要求的是 Parent => Parent

fn1 的类似是 Parent => Parent,完全一致,因此 ok。

fn2 的类型是 GrandParent => Child

参数是往上(逆变):GrandParentParent 之上,因此 ok;

返回是往下(协变):ChildParent 之下,因此也 ok。

反观 fn3 的类型是 Child => GrandParent

参数是往上(逆变):ChildParent 之下,因此不 ok;

返回是往下(协变):GrandParentParent 之上,因此也不 ok。

小总结

先小总结一下上面提过的兼容规则:

  1. 鸭子类型

    类型兼容看重的是 "结构" 而非 "名义"。

    即便出自不同的 class,只要对象结构满足,就能兼容。

  2. 给少不行,给多可以

    target 是抽象,source 是具体,这样通常是兼容的。

    因为抽象需要的东西,具体一定会有。

    而具体额外的东西,由于抽象类型限制,它也访问不到。

    注:也有例外,比如 tuple 的数量必须刚刚好,不能多。

  3. 函数兼容性

    函数是否兼容要看它的参数和返回类型。

    参数是逆变(可以更抽象),返回是协变(可以更具体)。

Type Hierarchy(a.k.a type level)

参考:

Dev – Type system hierarchy in TypeScript: from Top Type to Bottom Type

官网 – Type Compatibility

我们上面讲了三个兼容规则:鸭子类型、给少不行给多可以、函数兼容性。

也举例三个类型例子:对象兼容、tuple 兼容、函数兼容。

TypeScript 还有好多类型,这里我们逐一看看它们的兼容性,以及兼容规则。

首先,引入一个新概念 —— Type Hierarchy(a.k.a type level)

每个类型都有一个 level(层级)。

level 越高,表示类型越抽象、越宽泛(broader);

level 越低,表示类型越具体、越狭窄(narrower)。

依照兼容性规则:具体(低 level)可以满足抽象(高 level),target type 是抽象,source type 是具体,通常就代表兼容。

注:也不是 100%,总会有一起例外或奇葩的状况,这些下面会讲到。

string literal vs string

问:string literal 和 string,哪一个 level 比较高?

答案是 string 比较高,因为 string 更抽象,更宽泛。

一个 string literal(e.g. 'hello world')肯定是一个 string

一个 string(e.g. 'whatever')不一定是 string literal(e.g. 'hello world')。

拿 string literal assign to string 一定兼容。

const stringLiteral = 'hello world';
const string: string = stringLiteral; // ok 兼容

反过来,拿 string assign to string literal 一定不兼容。

const string: string = 'whatever'; 
const stringLiteral : 'hello world' = string; // IDE Error: Type 'string' is not assignable to type '"hello world"'

number vs string

问:numberstring,哪一个 level 比较高?

答案是一样高。

因为 numberstring 是完全不同的类型,不存在继承概念,它们就是平级的。

想当然的,它们互不兼容

const string: string = 0;  // IDE Error: Type 'number' is not assignable to type 'string'
const number: number = ''; // IDE Error: Type 'string' is not assignable to type 'number'

string vs {}

问:string 和 {},哪一个 level 比较高?

答案是 {} 比较高。

{} 是除 nullundefined 以外的所有类型,当然也涵盖了 string

因此 {} 的 level 比 string 高。

string assign to {} 一定兼容。

const string = ''; 
const emptyObject: {} = string;     // ok 兼容

反过来,拿 {} assign to string 一定不兼容。

const emptyObject: {} = 0; 
const string: string = emptyObject; // IDE Error:Type '{}' is not assignable to type 'string'

tuple vs tuple with rest or optional

我们上面有提到一个兼容规则:给少不行、给多可以

const v1 : { name: string; age: number } = { name: '', age: 0 };
const v2 : { name: string } = v1; // ok

多 key 可以兼容少 key,这符合继承概念 —— 具体可以兼容抽象。

但 tuple 却不是这样

const v1 : [string, number] = ['', 0];
const v2 : [string] = v1; // IDE Error: Type '[string, number]' is not assignable to type '[string]'

tuple 不允许额外的 item,因为 tuple 经常会拿来 for loop,额外的 item 会直接影响程序结果。

不过,tuple with rest or optional 可以突破这个规则。

const v1 : [string, number] = ['', 0];
const v2 : [string, number?] = v1;      // ok 兼容 tuple with optional
const v3 :  [string, ...number[]] = v1; // ok 兼容 tuple with rest

因为 rest 或 optional 表示 item 数量是动态的,自然就没有数量上的兼容问题了。

Auto-boxing

let v1: { length: number };

v1 要求一个有 length 属性的对象。

问:assign 一个 string 给它,兼容吗?

v1 = '';

答案是兼容。

第一个原因:TypeScript 是鸭子类型,它只注重结构。

因此,只要 string 能满足 object literal { length: number } 结构就可以了。

第二个原因:TypeScript 兼顾了 JavaScript 的 auto-boxing 概念。

const value = '';
console.log(typeof value); // 'string'
console.log(value.length); // 0

value 是 string,但是 .length 可以访问到它的属性。

这背后就是 auto-boxing 机制在起作用

// 这句
console.log(value.length); 

// auto-boxing 变成这句来执行
console.log(new String(value).length); 

string 变成了 String 对象。

String 对象具有 length 属性,因此能够满足 { length: number } 类型的要求。

同样的 auto-boxing 机制也适用在其它类型,比如

const v1: String = '';         // ok
const v2: Number = 0;          // ok 
const v3: Boolean = true;      // ok
const v4: Array<unknown> = []; // ok 
const v5: Object = '';         // ok
const v6: Object = 0;          // ok
const v7: Function = class {}; // ok
// ... 等等

特殊类型 の any、unknown、object、Object、{}、void、undefined、null、never

TypeScript 里有一些比较特殊的类型,我们可能没法单凭直觉判断它们的兼容性。

但,不要担心,它们还是有一定规则的,我们来理一理:

  any unknown object Object {} void undefined null string string literal never
any
unknown
object
Object
{}
void
undefined
null
string
string literal
never

这个表是这样看:左边(source type)assign to 上面(target type),✅ 就是兼容,❌ 就是不兼容。

以下是这些特殊类型的种种规则:

1. never

注:我们先撇开 any 类型不看,因为它有点破坏规则。

never 是最具体、最狭窄、最低 level 的类型。

因此,它可以 assign to 任何类型

  unknown object Object {} void undefined null string string literal never
never

相反,没有任何类型可以 assign to 它。

  never
unknown
object
Object
{}
void
undefined
null
string
string literal
never

2. unknown

注:我们先撇开 any 类型不看,因为它有点破坏规则。

unknown 是最抽象、最宽泛、最高 level 的类型。

因此,它无法 assign to 任何类型

  unknown object Object {} void undefined null string string literal never
unknown

相反,任何类型都可以 assign to 它。

  unknown
unknown
object
Object
{}
void
undefined
null
string
string literal
never

3. any

any 比较特殊,当它作为 source type 时,它是仅次于 never 最具体、最狭窄、最低 level 的类型。

因此,它可以 assign to 任何类型,除了 never

  any unknown object Object {} void undefined null string string literal never
any

当它作为 target type 时,它是最抽象、最宽泛、最高 level 的类型。

因此,任何类型都可以 assign to 它,包括 unknown

  any
any
unknown
object
Object
{}
void
undefined
null
string
string literal
never

4. void and undefined

在 TypeScript,没有 return 的函数,它的返回类型是 void。(C# / Java 也是用 void 表示)

function doSomething() {}
const v1 = doSomething(); // v1 的类型推断是 void

在 JavaScript, 没有 return 的函数,它的返回值是 undefined

function doSomething() {}
const v1 = doSomething();
console.log(v1 === undefined); // true

TypeScript 也有 undefined 类型,因此 voidundefined 就必然会有关系。

在类型兼容性规则上,voidundefined 的抽象、void 比 undefined 更高 level。

因此

function doSomething() : void {
  return undefined;
}

函数声明返回类型 void,函数 return undefined,这样是 ok 的,undefined 可以 assign to void(兼容)。

相反

function doSomething() : void {}
const v1: undefined = doSomething(); // IDE Error: Type 'void' is not assignable to type 'undefined'

v1 要求 undefined 类型,doSomething 返回 void 是不 ok 的,void 无法 assign to undefined(不兼容)。 

  void undefined
void
undefined

对于其它类型,voidundefined 与它们是互相排斥的(就好比 stringnumber,完全就不同类)。

憋开 unknownneverany(这三个就按照上面说过的规则),voidundefined 无法 assign to 任何类型。

  object Object {} void undefined null string string literal
void
undefined

同样的,任何类型也都无法 assign to voidundefined

  void undefined
object
Object
{}
void
undefined
null
string
string literal

5. null 

null 的兼容规则和 void 大同小异:都是与其它类型互斥。

憋开 unknownneverany(这三个就按照上面说过的规则),null 无法 assign to 任何类型。

  object Object {} void undefined null string string literal
null

同样的,任何类型也都无法 assign to null

  null
object
Object
{}
void
undefined
null
string
string literal

6. {}

上面我们有讲解过,empty object type {} 代表的是 "除了 null 和 undefined 以外的所有类型"。(这里可以追加多一个:void 以外)

这样死背类型不好,我们换一个思路。

顾名思义,empty object type {} 指的就是 "空对象"。 

何为空对象?

问1:object literal { name: string } 可以 assign to {} 空对象吗?

答案是可以,因为依照类型兼容规则 —— 给少不行,给多可以,对象有额外的 key 是兼容的。

这也意味着,只要是 key-value pair 对象,一律都能 assign to {} 空对象。

object 非原始类型(e.g. functionclass[])都是 key-value pair 对象。

问2:string literal 'hello world' 可以 assign to {} 空对象吗?

答案是可以,因为依照 auto-boxing 规则,string literal 会被转换成 String 实例,而只要是 key-value pair 对象(String 实例当然也是),一律都可以 assign to {} 空对象。

结论:

因为有 auto-boxing 机制,所以除了 nullundefinedvoid 以外,所有类型其实都是 key-value pair 对象。

只要是 key-value pair 就一定能 assign to {} 空对象。(因为 —— 给少不行,给多可以)

所以结论就是 {} 代表 "除了 nullundefinedvoid 以外所有的类型"。

 

 

 

 

 

 

{} 就是 "空对象",不要死背非 null and undefined void 了

它的核心是 可以给多,然后 auto boxing

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

object vs {} vs Object 

unknown, never, any

第二篇 search "看几个例子" 搬过来

 

class / interface / object literal 的类型检测规则

有一个 object literal with dynamic key

type Animal = { [prop: string | symbol]: unknown }

// 或者用 interface or class 也可以
// interface Animal { [prop: string | symbol]: unknown }
// class Animal { [prop: string | symbol]: unknown }

接着有 classinterface、object literal

class Cat { name = '' }
const cat = new Cat();

interface Dog { name: string }
const dog: Dog = { name: '' }

type Bird = { name: string }
const bird : Bird = { name: '' }

请问,catdogbird 满足类型 Animal 吗?

const v1 : Animal = dog;  // Type 'Dog' is not assignable to type 'Animal'
const v2 : Animal = cat;  // Type 'Cat' is not assignable to type 'Animal'
const v3 : Animal = bird; // ok

dogcat 不满足 Animal 是因为:Animal 要求 dynamic key,而 DogCat 没有明确声明支持 dynamic key。

interface Dog { name: string; [prop: string | symbol] : unknown }
class Cat { name = ''; [prop: string | symbol] : unknown }

DogCat 声明 dynamic key 就可以了。

另外,Bird 也没有 dynamic key 啊,为什么它却可以满足 Animal

原理我也不晓得,好像是因为 Bird 是 object literal,类型检测比较宽松。

type Animal = { [prop: string | symbol]: unknown };
type Animal = { [prop: string | symbol]: any };

请问,这两个 object literal 有什么区别?

第一个区别是 key value 的类型,一个是 unknown,一个是 any

unknown 需要 narrowing 后才能使用,any 算是 bypass,想怎么用都行。

第二个区别是

type Animal = { [prop: string | symbol]: any };

interface Dog { name: string; }
const dog: Dog = { name: '' }

class Cat { name = '' }
const cat = new Cat();

type Bird = { name: string }
const bird : Bird = { name: '' }

const v1 : Animal = dog;  // ok
const v2 : Animal = cat;  // ok
const v3 : Animal = bird; // ok

any 可以 bypass dynamic key 的检测。

DogCat 没有 dyanmic key,但是 v1v2 没有报错了。

原理我也不晓得,我猜可能是 any 大显神威,bypass 了类型检测。

Excess Property Checking
class Animal {
  name = 'Lucky';
}

class Dog extends Animal {
  age = 5;
}

const animal: Animal = new Dog();

类型检测的规则通常是:"具体" 可以满足 "抽象"。

比如说,variable 要求抽象的 Animal,但赋值具体的 Dog,这是可以的。(原则:给多可以,给少才不行)

不过,下面这种情况会有所不同:

interface SquareConfig {
  color?: string;
  width?: number;
}
 
const config: SquareConfig = {
  colour: 'red', // IDE Error: Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'
}

{ colour: 'red' } 有满足 SquareConfig 吗?

其实是有的,因为 SquareConfig 的属性都是 optional,也就是说,最低要求是一个 "空对象"。

{ colour: 'red' } 满足 "空对象" 条件;而多出来的 colour 属性,依照 "给多可以,给少才不行" 原则,给多是可以的。

但,IDE 却报错了。

原因是,TypeScript 对 "object literal 赋值" 会有 excess property checking(超额属性检测)机制 —— 意思是 "给多也不行"。

注:这个机制只有在 "以 object literal 赋值" 时才会启动。

若想规避这个检测,我们可以声明允许 dynamic key

interface SquareConfig {
  color?: string;
  width?: number;
  [prop: string | symbol] : unknown
}

或者用 as bypass 类型检测。

const config = { colour: 'red' } as SquareConfig;

 

 

 

 

 

TypeScript 有很多类型,我们需要知道它们各自的 level。

 

 

 

 

 

 

 

 

 

 

constructable(class)

在 JavaScript,class {} 不是一种类型,typeof 它会得到 'function',但 function 也不是类型,它实际上被归类为 object

TypeScript 虽然也没有 class 类型,但可以用 constructable 来代表 —— 意思是一个可以被 new 的东西。

// 参数 Class 是一个 class,可以被实例化
function doSomething(Class: new () => object) : void {
  const instance: object = new Class(); 
}

class Person {}
class Animal {}
doSomething(Person);
doSomething(Animal);
doSomething(''); // IDE Error: Argument of type 'string' is not assignable to parameter of type 'new () => object'

new () => object 这就是 constructable 类型。

它代表一个可以被 new 的东西,比如 class 就可以被 new

注:object 是 JavaScript 8 大类型之一,上面我刻意跳过是因为 TypeScript 有许多和 "对象" 相关的类型,object 是其中一个,在 todo

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

boolean literal

literal 翻译成中文叫 "字面"。

当我们说 "字面意思" 就表示:你看到的表面是什么,那它就是什么,没其它意思了。

这个概念用在类型上,是这样:

const booleanValue1: boolean = true;  // ok 
const booleanValue2: boolean = false; // ok 

boolean 是一个类型,它可以接受 truefalse

const trueValue1: true = true;  // ok
const trueValue2: true = false; // IDE Error: Type 'false' is not assignable to type 'true'

true 也是一个类型,但它只接受 true,不接受 false

const falseValue1: false = false; // ok
const falseValue2: false = true;  // IDE Error: Type 'true' is not assignable to type 'false'

false 也是一个类型,它只接受 false,不接受 true

类型 truefalse 就是所谓的 boolean literal。

true 只接受 truefalse 只接受 false,就如它 "字面" 上的意思。

为什么 TypeScript 要新增这么一个 boolean literal 类型呢?

boolean 还不够吗?

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

浅谈类型兼容性

类型兼容性是 TypeScript 一个重要的概念,下面会独立开一个主题详细讲解。

这里我们先谈一点点,方便接下来学习类型。

class Animal {
  name = ''
}

class Dog extends Animal {
  age = 0;
}

有两个类,它们是继承关系 —— Dog ≼ Animal

const animal: Animal = new Dog();

有一个 variable animal,它的类型声明是 Animal(表示 variable 是 Animal 实例)。

我们 assign 一个 Dog 实例给它,请问 ok 吗?

答案是 ok。

因为,一个 value 可以 assign to 一个 variable 并不是说它俩的类型必须完全一致。

value 只要可以 "兼容" 或 "满足" variable 就可以了。

怎么样才叫 "兼容" 或 "满足"?

我们在使用 variable animal 时,会将它视为 Animal 实例,并访问 Animal 的属性或调用其方法。

因此,assigned value 必须拥有 Animal 的属性和方法,否则调用不到就会 runtime error。

DogAnimal 的派生类,它拥有所有 Animal(基类)的属性和方法,因此 assign Dog 实例给一个要求 Animalvariable 是完全能满足它的使用需求的。

使用者(variable)只会用到 Animal 范围内的属性和方法,

提供者(assigned value Dog)提供了超出 Animal 范围的属性和方法。

这就叫 "兼容" 和 "满足":可以给刚刚好(要 AnimalAnimal),也可以给多(要 AnimalDog),但不能给少(要 DogAnimal)。

我们继续看

const animal: Animal = new Dog();
const dog: Dog = animal; // IDE Error: Type 'Animal' is not assignable to type 'Dog'

刚我们把 Dog assign to animal,接着再把 animal assign to 一个要求 Dog 的 variable。

请问 ok 吗?

首先,animal 的类型声明是 Animal,虽然 assigned value 是 Dog,但在使用 animal 时,它依然被认定为 Animal

那问题就变成是 assign Animal to Dog ok 吗?

答案是 no。

assign 具体 to 抽象,这是 "给多",ok。

反过来,assign 抽象 to 具体,这是 "给少",不 ok。

所以最后 IDE 报错了。

注:这个机制其它静态类型语言(C# / Java)也都有。

好,点到为止,类型兼容性还有许多复杂的概念,下面会详细教,这里我们只要抓住一个最基本的概念先就好:

一个 value 可以 assign to 一个 variable 并不是说它俩的类型必须完全一致。

value 只要可以 "兼容" 或 "满足" variable 就可以了。

 

 

JavaScript 类型

比较特别的是 object:它是非原始类型(non-primitive types),也是引用类型(reference type)。

它能接受的 value 种类就比较宽泛

const value1: object = new Object();
const value2: object = new Date();
const value3: object = new RegExp('[a-z]');
const value4: object = new Map();

任何 class 实例都可以 assign to object。(实例就是对象,也就是 object)

const value1: object = Object.create(null); // 这不算 class Object 实例哦
const value2: object = {};

key-value pair 对象也可以 assign to object。(JavaScript 的特色:对象不一定来自 new Class(),但它还是对象)

const value1: object = [];            // 算是 class Array 实例
const value2: object = function() {}; // 算是 class Function 实例 
const value3: object = class {}       // 算是 class Function 实例

甚至 array、functionclass 也都可以 assign to object。(这些也都算是 class 实例)

依照上一 part 《类型兼容性》里的概念,object 显然是一个相当 "抽象" 的类型。

variable 类型越抽象,assign value 就越灵活,因为它可以接受不同种类的 value。

不过,在使用 variable 时,由于它抽象,所以无法使用到具体特性。

举两个例子:

第一个是具体 Date

let v1: Date = new Date();
v1 = new RegExp('[a-z]'); // IDE Error: Type 'RegExp' is not assignable to type 'Date'
v1.getDate();             // ok

assign value 时只接受 Date,使用 variable 时可以调用 Date.prototype 的方法。

第二个是抽象 object

let v1: object = new RegExp('[a-z]');
v1 = new Date(); // ok
v1.getDate();    // IDE Error: Property 'getDate' does not exist on type 'object'

可以 assign Date,也可以 assign RegExp,但使用 variable 时无法调用 Date.prototype 的方法。

结论:在使用 variable 的时候,越具体就越好用;在 assign 的时候,越抽象能接受的就越宽泛。

如何?你对类型兼容性和类型层次(抽象、具体)有点概念了吗?

好,我们点到为止,暂且不深入探究 object 的类型兼容性,等了解完所有 TypeScript 类型后,再一起探究各类型之间的兼容关系。

 

TypeScript 类型

除了 JavaScript 的 8 种类型之外,TypeScript 还额外新增了一些类型。

void 

熟悉 JavaScript 的你可能会有一个疑惑:在 JS,即便函数没有返回值,它其实依然会返回 undefined,那 TypeScript 呢?

对,也是这样。

在 TypeScript,voidundefined 虽然是不同类型,但在类型兼容性上,它们有继续关系,undefined 是 "一种" void

function doSomething(): void {
  return undefined; // ok
}

因此,return undefined 是可以的。 

这些细节我们留到下面《类型兼容性》主题再来讲解。

 

boolean literal

站在类型兼容性的角度上看,我们可以说 boolean literal 是 "一种" boolean,它俩是继承关系。

boolean 比较抽象,boolean literal 则更具体。

把 boolean literal assign to boolean 一定是 ok 的,反之则不行。

除了 boolean literal 以外,TypeScript 还有很多其它的 literal 类型,比如:string literal、object literal、number literal 等。

概念都大同小异,下面会逐一介绍。

empty object type {}

empty object type {} 是一个比 object 还抽象的类型。

const value1: {} = '';                  // ok string
const value2: {} = 123;                 // ok number
const value3: {} = true;                // ok boolean
const value4: {} = Symbol();            // ok symbol
const value5: {} = 123n;                // ok bigint
const value6: {} = Object.create(null); // ok object
const value7: {} = {};                  // ok object
const value8: {} = [];                  // ok object
const value9: {} = class { };           // ok object
const value10: {} = function() {};      // ok object

const value11: {} = null;               // Error: Type 'null' is not assignable to type '{}'
const value12: {} = undefined;          // Error: Type 'undefined' is not assignable to type '{}'

只要不是 nullundefinedvoid,一律都可以 assign to empty object type {}

"空对象" 是什么含义?

stringnumberbooleanbigintsymbol 又不是对象,为什么可以 assign 给 "空对象" 呢?

这些疑问我们留到下面《类型兼容性》主题再来讲解,这里先死背吧。

object literal

上面我们提到过,object 是非原始类型。

它非常地抽象,可以接受的 value 类型非常寛泛,比如说

const value1: object = { name: 'Derrick' };
const value2: object = { age: 11 };

不管对象有什么 key value,都可以 assign to object

const value1: object = new Error();
const value2: object = new Date();

不管是哪个类的实例,都可以 assign to object

const value1: object = [];
const value2: object = function() {};
const value3: object = class {};

甚至 functionclass、array 也都可以 assign to object

empty object type {} 就更抽象、更宽泛了,除了 nullundefinedvoid 以外,一律都可以 assign to {}

太抽象不好(IDE 无法提供精准的辅助),我们需要更具体、更狭窄的对象类型。

参考其它语言(如 C#),具体的对象结构通常是用 class 来表示。

// 3. options 对象来自 class Options
public class Options
{
  public bool Enabled { get; init; } = false;
}

public class Program
{
  public static void Main()
  {
    // 1. 一个函数 with 一个参数,参数的类型是 options 对象
    static void DoSomething(Options options)
    {
      Console.WriteLine(options.Enabled);
    }

    // 2. 调用函数,传入 options 对象
    DoSomething(new() { Enabled = true });
  }
}

class 很具体、很狭窄,有哪些 key,每个 value 是什么类型,都定义得清清楚楚。

TypeScript 也可以用 class 来表示具体对象

// 3. options 对象来自 class Options
class Options {
  enabled: boolean;

  constructor(enabled: boolean) {
    this.enabled = enabled;
  }
}

// 1. 一个函数 with 一个参数,参数的类型是 options 对象
function doSomething(options: Options): void {
  console.log(options.enabled);
}

// 2. 调用函数,传入 options 对象
doSomething(new Options(true));

但是!这不是我们写 JavaScript 的习惯。

在 JavaScript,创建对象并不需要像 C# 那样强制先定义一个 class

因此,很多时候为了方便,我们会直接开一个对象,像这样

function doSomething(options) {
  console.log(options.enabled);
}

doSomething({ enabled: true }); // 直接开一个对象,而不是 new Class

为了支持这种写法,TypeScript 提供了一种叫 object literal 的类型。

顾名思义,object literal 就是比 object 更具体、更狭窄的对象类型(上面我们说了,object{} 实在太抽象了)。

function doSomething(options: { enabled: boolean }): void {
  console.log(options.enabled);
}

doSomething({ enabled: true }); // ok

doSomething({ name: 'Derrick' }); 
// IDE Error: Object literal may only specify known properties, and 'name' does not exist in type '{ enabled: boolean; }'

{ enabled: boolean } 就是 object literal,它代表一个 key value pair 对象,里面有 enabled 属性,value 类型是 boolean

object vs {} vs object literal

参考:Stack Overflow – Difference between 'object' ,{} and Object in TypeScript

这三个类型经常被混淆,这里特别说明一下:

object 是非原始类型(non-primitive types)。—— 抽象

{}empty object type 是 "非 nullundefined"。—— 极度抽象

object literal 是 {} 的狭窄版,它可以明确定义对象结构:有哪些 key,每个 value 是什么类型。—— 具体

点到为止,它们之间的关系,我们留到下面《类型兼容性》主题再来讲解。

 

string literal

string literal 是 string 的更狭窄类型。

let value: 'derrick' = 'alex';  // IDE Error: Type '"alex"' is not assignable to type '"derrick"'.

value = 'derrick';              // ok

value 只能 assign 'derrick',其它字符串都不接受。

string literal 经常搭配 union types 一起使用。

function doSomething(action: 'create' | 'edit' ) : void {}

doSomething('create'); // ok
doSomething('edit');   // ok 
doSomething('delete'); // IDE Error: Argument of type '"batch delete"' is not assignable to parameter of type '"create" | "edit" | "delete"'

template literal

template literal 是建于 string 和 string literal 之间的类型。

string 的约束很小,什么字符都可以。

string literal 的约束很大,只有指定的字符才可以。

template literal 的约束在它俩之间,看例子:

我想约束一个 value,开头必须是 'Version_' 接着后面是一个 number,无论什么数字都可以。

使用 string literal union types 无法实现这个约束

const version1: 'Version_1' | 'Version_2' | 'Version_3' = 'Version_1'; // ok
const version2: 'Version_1' | 'Version_2' | 'Version_3' = 'Version_2'; // ok
const version3: 'Version_1' | 'Version_2' | 'Version_3' = 'Version_3'; // ok

const version4: 'Version_1' | 'Version_2' | 'Version_3' = 'Version_4'; 
// IDE Error: Type '"Version_4"' is not assignable to type '"Version_1" | "Version_2" | "Version_3"'

除非我们把所有可能的数字都加进 union types 里。这显然不切实际了。

改用 template literal 则可以轻松实现

const version1: `Version_${number}` = 'Version_1'; // ok
const version2: `Version_${number}` = 'Version_2'; // ok
const version4: `Version_${number}` = 'Version_3'; // ok
const version5: `Version_${number}` = 'Version_4'; // ok

`Version_${number}` template literal 语法就如同 JavaScript 的 Template literals(模板字符串)。

当 template literal 遇上 union types

另外,当 template literal 配上 union types 时,会形成笛卡尔积

let fullName1: `${'Derrick' | 'David' } ${'Yam' | 'Tan'}` = 'Derrick Yam'; // ok
let fullName2: `${'Derrick' | 'David' } ${'Yam' | 'Tan'}` = 'Derrick Tan'; // ok
let fullName3: `${'Derrick' | 'David' } ${'Yam' | 'Tan'}` = 'David Yam';   // ok
let fullName4: `${'Derrick' | 'David' } ${'Yam' | 'Tan'}` = 'David Tan';   // ok

// IDE Error: Type '"Hello World"' is not assignable to type '"Derrick Yam" | "Derrick Tan" | "David Yam" | "David Tan"'
let fullName5: `${'Derrick' | 'David' } ${'Yam' | 'Tan'}` = 'Hello World';
当 template literal 遇上 never
const value: `version_${never}` = 'version_1'; // IDE Error: Type '"version_1"' is not assignable to type 'never'

当 template literal 中出现 never,那它整句都无效,value 最终的类型会是 never

number literal

number literal 和 string literal 大同小异,只是一个是 for string 一个是 for number

let numberValue: 123 = 321;  // IDE Error: Type '321' is not assignable to type '123'

numberValue = 123;           // ok

tuple

tuple 是 array 的更狭窄类型。

为什么它不叫 array literal 呢?

因为 tuple 这个类型其它语言也有,比如 C# 就叫 Tuple

function doSomething(values: [string, number]) {}

doSomething(['', 0]); // ok

doSomething([0, '']);
// IDE Error:
// for 1st slot: Type 'number' is not assignable to type 'string'
// for 2nd slot: Type 'string' is not assignable to type 'number'

doSomething 函数的参数 values 是一个 tuple。

它不仅声明 values 是一个 array,还明确这个 array 只有两个值:第一个的类型是 string,第二个的类型是 number

数量、类型要完全正确才能通过 IDE 的类型检测。

除了类型检查,把 array 定义的那么具体,也能让 IDE 提供更精准的辅助。

function doSomething(values: [string, number]) {
  const [stringValue, numberValue] = values;
  
  stringValue.substring(0); // 第一个 value 类型是 string,因此 IDE 可以提示 String.prototype 方法
  numberValue.toFixed();    // 第而个 value 类型是 number,因此 IDE 可以提示 Number.prototype 方法
}

如果没有 tuple,那我们只能用 union types array,这样 IDE 的检测和辅助就无法那么精准了。

// 把 tuple [string, number] 或者 union types (string | number)[]
function doSomething(values: (string | number)[]) {
  const [stringValue, numberValue] = values;

  // 本来下面两行是 ok 的,现在变 error 了
  stringValue.substring(0); // IDE Error: Property 'substring' does not exist on type 'string | number'
  numberValue.toFixed();    // IDE Error: Property 'toFixed' does not exist on type 'string | number'
}

doSomething(['', 0]);  

doSomething([0, '']); // 本来应该要 error 的,现在变 ok 了。

本来 ok 的变成 error,本来该 error 的变成 ok,全乱了。

tuple with name

const range: [start: number, end: number] = [0, 100];

我们可以给 tuple 中的每个 value 放一个名字,这样有助于 code study。(注:这个特性 C# Tuple 也有)

tuple with rest and optional

tuple 还可以搭配 rest 和 optional

const values1: [string, ...number[]] = ['', 0, 0]; // 第一个是 string,其余(没限数量)的都是 number

const values2: [...number[], string] = [0, 0, '']; // 最后一个是 string,前面(没限数量)的都是 number

const values3: [string, ...number[], string] = ['', 0, 0, '']; // 第一个和最后一个是 string,中间(没限数量)的都是 number

const values4: [string, number?] = [''];           // 第一个是 string,第二个不一定会有 value,有的话是 number

搭配 JavaScript 解构会很好用

const values: [string, ...number[]] = ['', 0, 0]; // 第一个是 string,其余的是 number
const [stringValue, ...numberList] = values;

stringValue.substring(0);   // stringValue 是 string
numberList.forEach(v => v); // numberList 是 number[]

总结

到这里,我们介绍了基本的类型声明方式,以及最常使用的 JS / TS 类型。(注:类型还没有介绍完哦,这里只是一部分,下面还会继续。)

我尽量先介绍简单的,或者是 C# / Java 中也有的特性;而那些比较难,或是 TypeScript 独有的特性,则会慢慢、一点一点地带出来。

这样我们可以先把容易的部分吃掉;等消化之后,有了能量再继续吃。

 

 

 

union types(联合类型)

一般来说,我们给 variable 声明类型时,只会指定一个类型。

而 union types 则允许同时指定多个类型。什么意思呢?

let value: string | number;

它的意思是:variable 的类型是 string "or" number

从某些角度看,它和 unknown 有几分相似之处。

unknown 是 "完全不知道",类型有可能是 string or number or 任何一种类型。

union types 则是 "知道" 类型在一个范围内(如:string or number),但 "不知道" 是哪一个。

在 assign value 时,可以 assign string,也可以 assign number,其它则不行。(unknown 是可以 assign 任何类型,union types 则是有一个指定范围)

value = ''; // assign string 可以
value = 0;  // 换成 number 也可以

在使用 variable 时

function doSomething(param: string | number) : void {
  param.toString();   // ok

  param.toFixed();    // IDE Error: Property 'toFixed' does not exist on type 'string | number'
  param.substring(0); // IDE Error: Property 'substring' does not exist on type 'string | number'
}

param 有可能是 string 也有可能是 number

如果我们调用 toString 方法,那 ok。

因为不管是 string 还是 number 都有 toString 方法。

但如果我们调用 toFixedsubstring 则不行。

因为如果 paramstring,那它就没有 toFixed 方法可调用,如果 IDE 不警示,那就可能出现 runtime error。

同样地,如果 paramnumber,那它就没有 substring 方法可调用。

因此,正确的做法是像使用 unknown 那样,先判断 param 的类型,再调用相应的方法,这样就能确保调用是安全的。

function doSomething(param: string | number) : void {
  param.toString(); // string 和 number 都有 toString 方法,所以可以直接调用

  if (typeof param === 'number') { // 先判断类型是 number
    param.toFixed(); // 可以调用 number 相关方法
  }
  else { 
    // union types 是有范围的(string or number)
    // 既然 if 是 number 那 else 就一定是 string 了
    param.substring(0); // 可以调用 string 相关方法
  }
}

自动简化机制

image

当我们 hover variable 时,IDE 会显示它的类型。

我们声明 string | number,它显示 string | number,完全合理,没有问题。

但如果加上一个 unknown,就不同了。

image

声明 unknown | string | number,最终却只会剩下 unknown —— 这就是 union types 的自动简化机制。

我们可以这样简单理解:unknown 有点像是所有类型合在一起的 union types(string | number | boolean | ...等等所有类型),其中也包含 stringnumber

因此,在 unknown | string | number 中,string | number 实际上是多余的,所以会被简化掉。

再一个 never 的例子

image

never 是 "不存在的类型",它放在 union types 里没有任何意义(因为它不代表任何类型),所以会被简化掉。

我们试试推理看看

string | number 在 assign value 时:

string 表示可以 assign 字符串。

number 表示可以 assign 整数和浮点数。

最终两个 "联合" 就是可以 assign 字符串、整数和浮点数。

同样的流程,我们走一遍 never

string | never 在 assign value 时:

string 表示可以 assign 字符串。

never 表示可以 assign 没有。

最终两个 "联合" 就是可以 assign 字符串和没有。

 

 

 

 

 

 

 

 

 

 

其实想来也合理,any 已经代表任何类型了,其中自然也包括 stringnumber,那再把 any | string | number 放一起,string | number 就显得多余了。

1. variable 有可能是任何类型

2. variable 有可能是 string

第二句显然是多余的,因为第一句的 "任何类型" 就已经包括 string 了。

其它例子:

const v1: unknown | string  = ''; // 简化成 unknown
const v2: any | unknown  = '';    // 简化成 any
const v3: string | never = '';    // 简化成 string

any 和 unknown 雷同,

 

string | number

assign 的时候选一个寛的

use 的时候,你要全部人都可以的

boolean | true

 

 

 

 

 

 

 
 
当 union types 中有更宽泛的类型

 

 

 

 

 

 

当 union types 遇上 never

image

never 会自动被移除 union types。

当 union types 遇上更抽象的类型

image

boolean 比 boolean literal true 更抽象,因此 true 会被移除 union types。

因为既然可以接受 boolean,那自然也可以接受 true,所以 true 就不需要了。

image

any 更抽象,因此 boolean 也被移除了。

另外,TypeScript 还有一个类型叫 intersection types(交叉类型),它是 "and" 的概念。

这个比较复杂,我们下面再教,先继续学些简单的。

 

 

 

类型推断未必精准

类型推断是透过 IDE 分析 TypeScript 代码得出来的。

它未必能做到 100% 精准。

function getValue(condition: boolean) {
  if (condition) return [1, 2, 3];
  return ['a', 'b', 'c'];
}

const value = getValue(true); // 推断为 number[] | string[]

value 的类型不是 number[],而是 union types number[] | string[]

显然,IDE 的分析并没有很聪明(无法处理复杂 condition)。

没关系,下面我会教,如何用 Overloading 特性来做到我们想要的结果。

 

 

 

TypeScript 类型 & 特性 2.0

上面我们已经介绍了许多 TypeScript 类型与特性,但还没完。

这里继续补上更多的 TypeScript 类型,以及一些细小的特性。

this 类型

参考:Docs – this Types

这是一个专门用在 class 方法的类型。

return this

class Parent {
  returnSelf(): Parent {
    return this;
  }
}

class Child extends Parent {}

const child = new Child();
const self = child.returnSelf(); // self 的类型是 Parent,但实际上它是 Child
console.log(self instanceof Child); // true

class Parent 有个 returnSelf 方法,它返回 this

如果没有 this 类型,我们就只能声明返回 Parent,但这不够精准。

因为一旦有 Child 继承它,returnSelf 实际返回的应该是 Child,而不是 Parent

而有了 this 类型,我们就能更精准地表达返回的是当前实例的类型。

class Parent {
  // 改成返回 this 类型
  returnSelf(): this {
    return this;
  }
}

class Child extends Parent {
  cValue = '';
}

const child = new Child();
const childSelf = child.returnSelf(); // childSelf 的类型是 Child

const parent = new Parent();
const parentSelf = parent.returnSelf(); // parentSelf 的类型是 Parent

parameter : this

class Parent {
  // 参数要去是 this 类型
  doSomething(param: this): void {}
}
class Child extends Parent {
  cValue = '';
}

const parent = new Parent();
const child1 = new Child();
const child2 = new Child();

parent.doSomething(parent); // ok
child1.doSomething(child1); // ok

child1.doSomething(child2); // ok: child1 传入 child2 也行, 因为 TypeScript 是鸭子类型

child1.doSomething(parent); // IDE Error: Argument of type 'Parent' is not assignable to parameter of type 'Child'

Parent.doSomething 要求参数是 this 类型。

有 4 个测试:

  1. parentparent 没问题

  2. childchild 也没有问题

  3. child1child2 也行,因为 TypeScript 是鸭子类型,只要结构对就可以了。

  4. 只有 childparent 不行,因为要求是 this 类型,必须要传当前的实例,当前实例是 Child 类型,传 Parent 无法满足。

给函数 this 声明类型

JavaScript 除了 class method 内会出现 this 以外,function 内也可以有 this(虽然不太鼓励)。

这个 this 也可以声明类型。

function doSomething(this: string) {
  this.substring(0); // this 是 string 类型
}

doSomething.call(0); 
// IDE Error: Argument of type 'number' is not assignable to parameter of type 'string'
// 零是 number, 但 this 的要求是 string,所以报错了

Optional Parameter

by default,参数是 required

function doSomething(param: string) {}

doSomething(); // IDE Error: Expected 1 arguments, but got 0

没有传入参数,IDE 会报错。

跟 C# 一样,我们可以透过给参数设置 default value,让它变成 optional。

function doSomething(param: string = '') {}

doSomething(); // ok

比 C# 厉害,TypeScript 还可以这个设置 optional parameter。

function doSomething(param?: string) {}

doSomething(); // ok

param?: string "?" 问号用于表示 optional。

与此同时,param 的类型会变成 union types string | undefined

自动增加 undefined 类型是合理的,因为 JavaScript 读取没有传入的参数,确实会得到 undefined

function doSomething(param) {
  console.log(param); // undefined
}

doSomething();

Optional Property

提醒:请确保 tsconfig.json 配置了 "useDefineForClassFields": true"exactOptionalPropertyTypes": true

by default,class 的 property 是 required。

class Person {
  name: string; // IDE Error: Property 'name' has no initializer and is not definitely assigned in the constructor.
}

没有赋值,IDE 会报错。

有两个时机可以赋值:

  1. 直接写在旁边(俗称:field initializer)

    class Person {
      name: string = 'default value'; 
    }
  2. 写在 constructor 里(俗称:constructor assignment)

    class Person {
      name: string;
      
      constructor() {
        this.name = 'default value';
      }
    }

如果想设置 optional,那就在 property 后面加上 ? 问号。

class Person {
  declare name?: string;
}

注:declare 有啥用,我们下面再讲。

与此同时,name 的类型会自动变成 union types string | undefined

增加 undefined 类型是合理的,因为 JavaScript 读取 optional property,确实会得到 undefined

class Person {}

const person = new Person();
console.log(person.name); // undefined

declare property

下面这个 TypeScript

class Person {
  declare name?: string;
}

等于 JavaScript

class Person {}
const person = new Person();
console.log(Object.keys(person)); // []

重点是它没有 define property name(没有 key)。

而下面这个 TypeScript(少了 declare

class Person {
  name?: string;
}

等于 JavaScript

class Person {
  name;
}
const person = new Person();
console.log(Object.keys(person)); // ['name']
console.log(person.name); // undefined

区别在于:它有 define property(有 key),只不过 value 是 undefined

no property !== property undefined

没有 property 和 property 的值是 undefined,是两种不同的概念。

class Person {
  name;
}

const person = new Person();
console.log(person.name); // undefined
console.log(Object.keys(person)); // ['name']

上面这个是有 property,但它的 value 是 undefined

下面这个是完全没有 property

class Person {}

const person = new Person();
console.log(person.name); // undefined
console.log(Object.keys(person)); // []

TypeScript 也有区分这两种概念:

class Person {
  declare name?: string;
}

这表示 property name 是 optional,而且不可以 assign undefined 作为 value。

const person = new Person();
console.log(person.name); // undefined
console.log(Object.keys(person)); // []

person.name = undefined; // IDE Error: Type 'undefined' is not assignable to type 'string'

assign undefined ,IDE 会报错。

要允许 assign undefined 需要明确声明 | undefined

class Person {
  declare name?: string | undefined;
}

const person = new Person();
person.name = undefined; // ok

Best practice:

class Person {
  declare name1?: string; // 不允许 undefined 值, 初始是 no property

  name2?: string | undefined; // 允许 undefined 值,初始是 has property + undefined

  declare name3?: string | undefined; // 允许 undefined 值,初始是 no property

  name4?: string; // 不允许 undefined 值, 初始是 has property + undefined
}

name1 最严谨,不允许 undefined 来乱。

name2name3 比较随性,undefined 等同于 no property。

name4 最乱,不允许 undefined 但又没有采用 declare,这导致了初始是 undefined,自相矛盾。

除了 name4,其它三个都可以用,我个人偏爱 name1

小总结

上面我们讲了三个概念,它们各自有各自的逻辑,不要把它们混淆哦。

? 表示 property 是 optional,可以 delete 可以 add。

declare 表示 compile to JavaScript 它不会 define property(没有 key),没有 declare 的话,compile to JavaScript 它会 define property(有 key)并 assign undefined 作为初始值。

| undefined 表示允许 assign undefined 给 property。

dynamic property

JavaScript 可以任意的给一个对象添加 property

class Person {}
const person = new Person();

const randomVersion = Math.floor(Math.random() * 10) + 1;
person[`version_${ randomVersion }`] = 'value';

绝对的任意。

TypeScript 面对这种极度动态的状况

class Person {}
const person = new Person();

const randomVersion = Math.floor(Math.random() * 10) + 1;
person[`version_${randomVersion}`] = 'value';
// IDE Error: Element implicitly has an 'any' type because expression of type '`version_${number}`' can't be used to index type 'Person'

如果不做类型声明,IDE 会报错。

声明 dynamic property 的方式是这样

class Person {
  [propertyName: `version_${number}`]: string;

  // 或者更宽松,只要 property 是 string 就可以了
  // [propertyName: string]: string;
}

propertyName 是一个代号(放 propp 也都可以),后面跟着的是 property 的类型 `version_${number}`,最后是这个 property value 的类型 string

property / key 的类型

JavaScript 对象的 key 支持两种类型:1. string,2. symbol

const person = {
  'a': 'v',

  0: 'v',
  // 等价于 '0' : 'v', 因为 number key 会自动转换成 string key

  [Symbol('s1')]: 'v'
}
 
console.log([Object.keys(person)[0], typeof Object.keys(person)[0]]); // ['0', 'string'] 最后是 '0' key,它是 string,不是 number 哦

比较特别的一点是,放 number 也是可以的,因为它会自动转换成 string

TypeScript 有一点点特别。

class Person {
  [propertyName: string]: string; // 声明 key 的类型是 string
}

const person = new Person();
person['0'] = 'abc'; // string key 当然可以
person[0] = 'abc';   // number key 也可以哦

// symbol key 不行
person[Symbol('0')] = 'abc'; // IDE Error: Type 'symbol' cannot be used as an index type

声明 key 类型 string,传入 number 它也可以接受。这符合 JavaScript 的特性。

class Person {
  [propertyName: number]: string; // 声明类型是 number
}

const person = new Person();
person[0] = 'abc';   // number key 当然可以
person['0'] = 'abc'; // string numeric key 也可以哦

// string not numeric key 不行
person['xyz'] = 'abc'; // IDE Error: Element implicitly has an 'any' type because index expression is not of type 'number'

// symbol key 不行
person[Symbol('0')] = 'abc'; // IDE Error: Type 'symbol' cannot be used as an index type

如果要求 key 的类型是 number,那输入的 string 就一定要是 numeric 才可以。

如果想做一个完全 dynamic 的对象,需要声明 stringsymbol

class Person {
  [propertyName: string | symbol]: unknown; // 声明类型是 string 或 symbol
}

const person = new Person();
person[0] = 'abc';           // ok
person['0'] = 'abc';         // ok
person['xyz'] = 'abc';       // ok
person[Symbol('0')] = 'abc'; // ok

总结:

string —— 接受 stringnumber

number —— 接受 number 和 string numeric `${number}`

symbol —— 接受 symbol

string | symbol —— 接受 stringnumbersymbol

assign optional parameter to optional property(conditional add property)

interface Person {
  name?: string;
}

function doSomething(name?: string): void {
  const person: Person = {
    name,
  };
  // IDE Error: Type '{ name: string | undefined; }' is not assignable to type 'Person'
}

为什么会报错?

因为参数 name 是 optional,它有可能是 undefined,而 Person.name 虽然是 optional 但它不允许 assign undefined,于是就报错了。

我们需要采用 conditional add property 手法来化解

function doSomething(name?: string): void {
  const person: Person = {
    ...(name !== undefined && { name }),
  };
}

Narrowing & Type Guards

上面我们在介绍 unknown 类型时,提到过 narrowing(收窄)概念。

简单说,它就是把一个不明确的类型给弄清楚(收窄类型的意思)。

C# / Java 也有这概念。

public class Person
{
  public required string Name { get; set; }
}

public class Program
{
  public static void Main()
  {
    static void DoSomething(object instance)
    {
      Console.WriteLine(instance.Name); // IDE Error: 'object' does not contain a definition for 'Name'

      // 判断 instance 是不是 Person 实例
      if (instance is Person person)
      {
        // 确认是 Person 后,就可以访问 Name 属性了
        Console.WriteLine(person.Name);
      }
    }
  }
}

C# 最常见的 narrowing 手法就是关键字 is,它可以把一个抽象的 object "收窄" 成一个具体的 class 实例。

object 是最抽象的,IDE 无法提供任何辅助,收窄成具体 class 之后,IDE 就可以精准提示 class 相关属性和方法了。

TypeScript 也很多 narrowing 的手法,我们一个一个来看。

typeof

// 一开始 param 有可能是 string 或 number
function doSomething(param: string | number) {
  // 在没有明确类型之前,IDE 只能提示 string 和 number 共同拥有的属性和方法,比如 valueOf, toLocaleString, toString。
  param.toString();

  if (typeof param === 'string') {
    // 在确认类型是 string 后,IDE 就能提示 String.prototype 的方法。
    param.substring(0);
  } else {
    // TypeScript 很聪明, 它知道 param 不是 string, 那就只能是 number。
    // 因此能提供 Number.prototype 的方法。
    param.toFixed();
  }
}

typeof 可以判断出 8 中类型:string, number, bigint, boolean, symbol, undefined, object, function

需要特别注意的是:null 会被判断为 object,array 也是 object,这是 JavaScript 的特性。

因此要判断 array 和 null 不要用 typeof

function doSomething(param: string[] | string | null) {
  if (Array.isArray(param)) {
    // param 是 string array
  } else if (param === null) {
    // param 是 null
  } else {
    // param 是 string
  }
}

instanceof

instanceof 用于判断对象属于哪个 class 实例

class Person {
  name = '';
}
class Animal {
  fly(): void {}
}

function doSomething(param: Person | Animal) {
  if (param instanceof Person) {
    // param 是 Person 实例
    console.log(param.name);
  } else {
    // param 是 Animal 实例
    param.fly();
  }
}

in operator

in operator 可以判断对象是否包含某个 key

function doSomething(param: unknown) {
  // 先判断是对象
  if (typeof param === 'object' && param !== null) {
    // 再判断对象有没有 name 这个 key
    if ('name' in param) {
      console.log(param.name); // 注:只是确定有 name 这个 key,至于它是什么类型还不知道哦
    }
  }
}

提醒:JavaScript in 包含对象原型链(prototype)上的 key 哦。(不熟悉的读者可以看这篇

单纯判断对象是否包含某个 key 用处不大,但配合 TypeScript 的聪明才智,它可用来间接推断类型。

class Person {
  name = '';
}
class Animal {
  fly(): void {}
}

function doSomething(param: Person | Animal) {
  if ('fly' in param) {
    // 信息 1. param 是 Person 或 Animal 
    // 信息 2. param 有 'fly' 或者 key
    // 推断: Person 和 Animal 只有 Animal 有 'fly' 这个 key
    // 结论:param 一定是 Animal 
    param.fly();
  } else {
    // 信息 1. param 是 Person 或 Animal
    // 信息 2. if 能确认 param 是 Animal 
    // 推断 + 结论:不是 if 的 Animal 那 else 一定是 Person
    console.log(param.name);
  }
}
TypeScript 判断类型是可以叠加信息的,'fly' in param 虽然只能判断 param 是否有 fly key,但结合其它类型信息一样能完成精准的类型推断。

注:当然,这题刚好 PersonAnimal 中,只有 Animalfly key,如果碰巧两个都有 fly key,那仅用 in 来判断就不足够了。

type property

interface 没法用 instanceof 做 narrowing,但我们可以使用 type property 手法。

interface Person {
  type: 'person'; // 需要添加一个 type property 做区分
  name: string;
}
interface Animal {
  type: 'animal'; // 需要添加一个 type property 做区分
  fly(): void;
}

function doSomething(param: Person | Animal) {
  // 透过 type property 来判断是 Person 还是 Animal
  if (param.type === 'person') {
    console.log(param.name);
  } else {
    param.fly();
  }
}

跟上一个 in 判断手法类型,透过特有属性做类型推断。

narrowing function の is keyword

上面提的几个 narrowing 手法都是借助 JavaScript 原本的语法(typeofinstanceofin===Array.isArray 等等)。

这显然不足以应付所有场景,于是 TypeScript 引入了一个 is 关键字,作为一个万能的 narrowing 手法。

class Person {
  name = '';
}

// 只要 isPerson 返回 true,那 value 就是 Person 实例
function isPerson(value: unknown): value is Person {
  // 具体如何判断 value 是 Person 实例,完全由我们来决定
  return true;
}

function doSomething(param: unknown) {
  // 透过 isPerson 函数来判断 param 是不是 Person 实例
  if (isPerson(param)) {
    // 进入 true condition,那就表示 param 是 Person 实例。
    console.log(param.name);
  }
}

定义一个 narrowing 函数,专门用来做判断类型。

它的特色就是返回类型 value is Person,意思是参数 value 是一个 Person 实例。

至于如何做类型判断,完全由我们决定。

只要 if 判断 narrowing 函数返回值进入 true,那 value 就认定为 Person 实例。

is 也可以用在匿名函数或箭头函数

class Person {
  name = '';
}
const values: unknown[] = [];
const people = values.filter((value): value is Person => true); // people 的类型是 Person[]

Assertion 断言

Narrowing 是透过判断来明确类型,断言则是不经过判断,直接明确类型。

as keyword

下面这个是透过 narrowing 手法判断类型

class Person {
  name = '';
}

function doSomething(param: unknown) {
  if (param instanceof Person) {
    console.log(param.name);
  }
}

下面这个是透过 assertion 断言手法

function doSomething(param: unknown) {
  console.log((param as Person).name);
}

param as Person 就是直接认定(断言)paramPerson 实例,不需要经过判断,我说它是就是!

提醒:断言等同于 bypass TypeScript,可能会出现 runtime error,因此要谨慎使用。

再一个例子

function doSomething(param: string) {
  console.log((param as Person).name);
  // IDE Error: Conversion of type 'string' to type 'Person' may be a mistake because neither type sufficiently overlaps with the other.
}

之所以会 IDE error 是因为,param 已经声明是 string 了,硬说(断言)它是 Person 实例不合理啊。

如果我们确信,param 真的是 Person,那可以这样声明

console.log((param as unknown as Person).name);

as unknownas Person

提醒:会用到 as unknown as 通常意味着代码质量不够好,我们最好 code review 一下,看看是不是哪里写的不够优雅。

惊叹号 !

interface Person {
  getName?(): string;
}

const person: Person = {};
const name = person.getName?.(); // name 的类型是 string | undefined

Person 有一个  optional 方法 —— getName

因为它是 optional,所有调用时需要使用 Optional Chaining getName?.()

这会导致返回值可能是 undefined

如果我们可以断言,getName 一定有,那返回值就不会是 undefined,最终 name 的类型将会是 string

const name = person.getName!(); // name 的类型是 string

! 惊叹号用来表示断言 "optional key 存在"。

提醒:我们要有足够把握才能断言哦,要是 optional key 不存在,那就 runtime error 了。

! 惊叹号断言除了可以用在 optional chaining 以外,还能用在 class 的 field 旁边。

class Person {
  name: string; // IDE Error: Property 'name' has no initializer and is not definitely assigned in the constructor
}

const person = new Person();
person.name = 'Derrick';

之所以会报错,是因为 name 不是 optional,它必须在声明时直接赋值,或在 constructor 中赋值;而上面的写法是等实例化后才赋值。

如果我们可以确保在访问 name 之前会赋值,那可以使用 ! 惊叹号断言 bypass 这个 error。

class Person {
  name!: string; // no more error
}

Assertion function

判断不同的类型有不同的处理方式,这叫 narrowing。

判断类型不正确,然后 throw error,这叫断言。

function doSomething(param: unknown): void {
  // 如果 param 不是 string 就直接 throw error
  if (typeof param !== 'string') throw new Error('param should be a string');

  // 这里 param 肯定是 string 类型,因为不是 string 的话,早 throw error 出去了。
  param.substring(0);
}

我们可以把 "类型判断 + throw" 封装成 assertion function。

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') throw new Error('param should be a string');
}

function doSomething(param: unknown): void {
  // 如果 param 不是 string 就直接 throw error
  assertIsString(param);

  // 这里 param 就确定是 string 类型了
  param.substring(0);
}

关键字是函数的返回声明 : asserts value is string

和 narroing function 有点像,区别是多了一个 asserts keyword,还有内部不是 return boolean 而是 throw Error

还有一种更通用的 assert function 写法

function assert(condition: unknown): asserts condition {
  if (!condition) {
    throw new Error('assert failed');
  }
}

function doSomething(param: unknown): void {
  // 如果 param 不是 string 就直接 throw error
  assert(typeof param === 'string');

  // 这里 param 就确定是 string 类型了
  param.substring(0);
}

readonly & const

readonly 是 TypeScript 语法里的一个 keyword,它可以用在许多地方,我们一个一个来看:

readonly property

JavaScript 对象有 Property Descriptor 概念,它可以把对象的 key 设置成 not writable。

'use strict';

const person = {
  name : 'Derrick',
}

// 设置 person.name 的 descriptor
Object.defineProperty(person, 'name', {
  writable: false // 不允许 assign value
});

person.name = 'Alex'; // runtime error: Cannot assign to read only property 'name' of object

TypeScript 也可以做到,透过 readonly keyword。

interface Person {
  readonly name: string;
}

const person: Person = {
  name: 'Derrick',
};

person.name = 'Alex'; // IDE Error: Cannot assign to 'name' because it is a read-only property.

提醒:TypeScript 的 readonly 在 compile to JavaScript 之后,就被移除了,并不会变成 JS 的 writable: false

class 内也是相同的写法

class Person {
  readonly name: string;

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

const person = new Person('Derrick');

person.name = 'Alex'; // IDE Error: Cannot assign to 'name' because it is a read-only property.

name 需要在 class 内赋值,用 field initializer 或 constructor assignment 都行。

readonly array and object

JavaScript 可以透过 Object.freeze 把 array 和 object 变成 readonly。

'use strict';

const values = [1, 2, 3];
Object.freeze(values); 
values.push(4);       // runtime error: Cannot add property 3, object is not extensible

const person = {
  name: 'Derrick'
};
Object.freeze(person);
person.name = 'Alex'; // runtime error: Cannot assign to read only property 'name' of object
person.age = 11;      // runtime error: Cannot add property age, object is not extensible

TypeScript 也行

const values: readonly number[] = [1, 2, 3];
values.push(4); // IDE Error: Property 'push' does not exist on type 'readonly number[]'

const person: {
  readonly name: string;
} = {
  name: 'Derrick',
};

person.name = 'Alex'; // IDE Error: only permits reading
person.age = 11;      // IDE Error: Property 'age' does not exist

同样是使用 readonly keyword。

注:TypeScript 的 object by default 就不能随意 add / remove key,因此对于 person.age = 11 默认就是不允许的。

readonly by as const keyword

上面 array and object 的 readonly 是以类型声明的方式去描述。

还有一种是结合类型推断的方式去描述。

const values1 = [1, 2, 3]; // values 的类型是 number[]
values1.push(4);           // 可以 push

const values2 = [1, 2, 3] as const; // values2 的类型是 Tuple [1, 2, 3]
values2.push(4);                    // IDE Error: Property 'push' does not exist on type 'readonly [1, 2, 3]'

const person1 = { name: 'Derrick' }; // person1 的类型是 { name: string }
person1.name = 'Alex';               // 可以 assign value

const person2 = { name: 'Derrick' } as const; // person2 的类型是 { readonly name: 'Derrick' }
person2.name = 'Alex';                        // IDE Error: Cannot assign to 'name' because it is a read-only property

as const 是 TypeScript 的 keyword,它可以把类型推断的结果再加上 readonly 概念。

本来 [1, 2, 3] 类型推断的结果是 number[]

加了 as const 之后,就变成 tuple [1, 2, 3] 了(不能 add / remove item)。

本来 { name: 'Derrick' } 类型推断的结果是 { name: string }

加了 as const 之后,就变成 { readonly name: 'Derrick' } 了,不仅加了 readonly 还变成 string literal 了。(不能 assign value 给 person.name

提醒:as const 只能用在推断类型上,已有类型的 value 不能再 as const。 

// 这样 ok
const values = [1, 2, 3] as const; // values 类型是 readonly [1, 2, 3]

// 这样不 ok
const numbers = [1, 2, 3];
const newNumbers = numbers as const;
// IDE Error: A 'const' assertions can only be applied to references to enum members, or string, number, boolean, array, or object literals

as const 题外话

as const 不仅仅可用于 readonly array 和 object,它还可以用在 stringnumberboolean,虽然比较少见。

const value1 = 'abc';        // value1 的类型是 'abc' string literal

let value2 = 'abc';          // value2 的类型是 string,因为 let 是可变的,所以 TypeScript 类型推断选了比较抽象的 string

let value3 = 'abc' as const; // value3 的类型是 'abc' string literal,虽然是 let,但我们声明了 as const,所以 TypeScript 类型推断选了比 string 收窄的 string literal

泛型 + const keyword

function getValues<TValues>(values: TValues): TValues {
  return values;
}

const values1 = getValues([1, 2, 'a']);          // values1 的类型是 (string | number)[],这是 mutable array

const values2 = getValues([1, 2, 'a'] as const); // values2 的类型是 readonly [1, 2, "a"],这是 tuple

透过类型推断,我们传什么类型给 getValues 它就会返回什么类型。

泛型 + const keyword 长这样

// 在泛型前面加 const 关键字
function getValues<const TValues>(values: TValues): TValues {
  return values;
}

const values1 = getValues([1, 2, 'a']); // values1 的类型是 readonly [1, 2, "a"],这是 tuple
// 等价于
const values2 = getValues([1, 2, 'a'] as const);

在泛型旁边加 const keyword,它相等于再传入时自动加上 as const 效果。

提醒:这个泛型 + const 只适用于直接传入值的类型推断,像下面这样就没有 const 效果了


// const values1 = getValues([1, 2, 'a']);
// 等价于
// const values1 = getValues([1, 2, 'a'] as const);  
// 但不等于
const values = [1, 2, 'a'];        // values 的类型是 (string | number)[]
const values1 = getValues(values); // values1 的类型是 (string | number)[],这是 mutable array

readonly array !== array

function doSomething(values: string[]): void {}

const values = ['a', 'b', 'c'] as const;

doSomething(values); // IDE Error: Argument of type 'readonly ["a", "b", "c"]' is not assignable to parameter of type 'string[]'

一个 mutable array 可以被当作 readonly array 来使用,但反过来就不行。

因此,我们最好将它们分清楚,不要混淆,尤其是函数参数,如果内部只需要 readonly 那就清楚声明为 readonly,这样才能最大化函数复用率。

甚至更极端一点,使用 Iterable<string>

function doSomething(values: Iterable<string>): void {
  // 只要可以 for of 就行了
  for (const value of values) {
    console.log(value);
  }
}

const values = ['a', 'b', 'c'] as const;

doSomething(values); // ok

Satisfies Operator

我们有两个选择:

  1. 类型声明

    const value: string = 0; // IDE Error: Type 'number' is not assignable to type 'string'

    我们声明 variable 的类型,如果 assign 的 value 不符合就报错。

  2. 类型推断

    const value = '';   
    value.substring(0); // value 被推断为 '' string literal,IDE 提示 String.prototype 相关方法

    我们不声明 variable 类型,让 TypeScript 依据 assign 的 value 自行推断出类型。

这两个方式不足以应付所有场景,因为有一些场景,我们希望声明一部分的类型,另一部分则靠推断。

比如说:

interface Person {
  name: string;
  [prop: string | number | symbol]: unknown;
}

Person 有一个 name,还有一些 dynamic property。

如果我们单单使用类型声明的话

const person: Person = {
  name: 'Derrick',
  age: 11,
};

console.log(person['age'].toFixed()); // IDE Error: Object is of type 'unknown'

IDE 会把 person['age'] 依照我们的类型声明,理解为 unknown

我们换成类型推断看看

const person = {
  name: 0, // IDE 没有类型检测,name 应该要是 string,但这里却可以赋值零
  age: 11,
};

console.log(person['age'].toFixed()); // ok

age 的类型是 number,这个类型推断正确了,不过 name 却失去了类型检测。

satisfies keyword 就是用在这种一半一半场景的。

const person = {
  name: 0, // IDE Error: Type 'number' is not assignable to type 'string'
  age: 11,
} satisfies Person;

console.log(person['age'].toFixed()); // ok

satisfies 是满足的意思,我们 assign 的 object literal 要满足 Person interface(类型检测要过)。

然后,由于我们没有声明类型,所以它是采取类型推断的方式,age 的类型也就被推断出来了。

Type Aliases

function doSomething1(numericBoolean: 0 | 1): void {}

function doSomething2(numericBoolean: 0 | 1): void {}

有两个方法,它们都有一个参数,这个参数用于表达 boolean,只不过它是以数字来表示,0 代表 false1 代表 true

那问题来了,0 | 1 这个 union types 没有语义,一直重复这样写,看起来很怪。

这时,Type Aliases 就登场了。

type NumericBoolean = 0 | 1;

function doSomething1(numericBoolean: NumericBoolean): void {}

function doSomething2(numericBoolean: NumericBoolean): void {}

type 是 TypeScript 的一个 keyword。

类似于 JavaScript 的 const / let / var,用来创建一个变量。

只不过,type 创建的这个变量,value 是类型。

它被用于类型声明,如:numericBoolean: NumericBoolean

透过这个手法,我们就可以给类型命名,这不仅增加了语义,也起到了复用的效果。

其它例子:

type Version = `version_${number}`;
type Status = 'processing' | 'completed';

const version: Version = 'version_1';
const status: Status = 'processing';

本教程的第二篇《把 TypeScript 当作编程语言来使用》会有更多 Type Aliases 相关内容,因为它在 "TypeScript 作为编程语言" 中,扮演的就是 "定义 variable" 这个角色,敬请期待。

Intersection Types(交叉类型)

union types(联合类型)是 "or" 的概念。

type NumericBoolean = 0 | 1;

0 或者 1

Intersection Types(交叉类型)是 "and" 的概念。

object & object

有一个人 Human 和一个马 Horse

interface Human {
  height: number;
  job: string;
}

interface Horse {
  height: number;
  breed: string;
}

"or" 的概念就是:它是人或马。

"and" 的概念是:它是人,同时也是马。

人马兽 centaur 就符合 "and" 的概念,它既是人,也是马。

const centaur: Human & Horse = {
  height: 2.5,
  breed: 'thoroughbred',
  job: 'logging',
};

Human & Horse 就是人马兽,人马兽可以被当作人,也可以被当作马,因为它同时具备人和马的特性。

因此,object & object 就是把两个对象的 key 合并起来。

我们看一个具体的例子

const defaultConfig = { readonly: true };
const customConfig = { disabled: true };

const combinedConfig = Object.assign({}, defaultConfig, customConfig); // combinedConfig 的类型是 { readonly: boolean } & { disabled: boolean }

console.log(Object.keys(combinedConfig)); // ['readonly', 'disabled']
console.log(combinedConfig.readonly); // true
console.log(combinedConfig.disabled); // true

透过 Object.assign 我们可以把多个对象合并起来。

Object.assign 是原生方法,它的类型定义是这样

assign<T extends {}, U, V>(target: T, source1: U, source2: V): T & U & V;

关键就是 T & U & V,使用了 intersection types 把所有对象的类型都 "and" 起来。

string & number 不同类

object & object 就是合并对象,这很好理解。

string & number 怎样解读?

const value1: string & number = 'a'; // IDE Error: Type '"a"' is not assignable to type 'never'
const value2: string & number = 0;   // IDE Error: Type '0' is not assignable to type 'never'

string 同时也是 number

有这样的类型吗?没有!

所以它会变成 neverstring & number 等于 never

类似的例子还有

type T1 = null & undefined; // never
type T2 = object & symbol;  // never

通常不同类型的 primitive types 合并出来都会是 never

string & string literal 抽象 & 具体

再看一些特别的例子

const value1: string & 'abc' = 'abc';
const value2: string & 'abc' = 'xyz'; // IDE Error: Type '"xyz"' is not assignable to type '"abc"'

string & 'abc',一个是比较抽象的 string,一个是比较具体 string literal。

这样不算不同类,因此不会是 never

string & 'abc' 会是什么呢?

答案是 'abc' string literal。

因为 'abc'string 同时也是 'abc' string literal。

结论:当一个抽象遇上一个具体时,最终只会剩下具体。

string literal & string literal 具体 & 具体

再一个例子:

const value: 'abc' & 'xyz' = 'abc'; // IDE Error: Type '"abc"' is not assignable to type 'never'

上一题是:一个抽象 string,一个具体 string literal

这一题是:两个都是具体 string literal

两个具体时,就回到 string & number 的比法。

'abc' & 'xyz' 是不同类型,因此答案是 never

当 Intersection 遇上 Union = Distributive

union & union 会如何?

type A = 'a' | 'b' | 'c';
type B = 'b' | 'c' | 'd';
type AB = A & B;

答案是笛卡尔积

type AB = 'a' & 'b' | 'a' & 'c' | 'a' & 'd' | 
          'b' & 'b' | 'b' & 'c' | 'b' & 'd' | 
          'c' & 'b' | 'c' & 'c' | 'c' & 'd';

细看每一个的结果

// 'a' & 'b' = never
// 'a' & 'c' = never
// 'a' & 'd' = never
// 'b' & 'b' = 'b'
// 'b' & 'c' = never
// 'b' & 'd' = never
// 'c' & 'b' = never
// 'c' & 'c' = 'c'
// 'c' & 'd' = never

因此

type AB = never | never | never | 
          'b' | never | never | 
          never | 'c' | never;

union types 里的 never 会被自动移除,所以最终是

type AB = 'b' | 'c';

这其实也符合 intersection types 的规则 —— 'b' | 'c'(AB 共同拥有的)是一种 'a' | 'b' | 'c' 同时也是一种 'b' | 'c' | 'd'

总结

object & object 是合并

string & number 不同类是 never

string & 'abc' 抽象 string 对具体 string literal 'abc',最后剩下具体 'abc'

'abc' & 'xyz' 虽然都是 string,但具体对具体,算是不同类,所以是 never

union & union 会笛卡尔积

傻傻分不清楚 の object、{}、Object、class、interface、object literal、type aliases + object literal

TypeScript 中有 6 种与 "对象" 相关的类型。

我们需要把它们弄清楚,否则在真实项目中滥用这些类型,可能会引发一些意想不到的 bug。

{}

{}empty object type 是 "非 nullundefined" 类型。

由于它太抽象了,真实项目很少会用到它。

object

object 是非原始类型。

它同样很抽象,真实项目很少会用到它。

{}object 最大的区别是

const v1 : {} = '';     // ok
const v2 : object = ''; // IDE Error: Type 'string' is not assignable to type 'object'

object{} 狭窄,基础类型 stringnumberbooleanbigintsymbol 都不接受。

Objectclass,它也很抽象,真实项目很少会用到它。

Object

Object{} 蛮像的。

因为除了 nullundefined 以外,所有类型都继承自 Object

const v1 : {} = '';     // ok
const v2 : Object = ''; // ok,因为 '' auto-boxing 会变成 new String('') 对象,而 String 继承自 Object。

string auto-boxing 成 StringString 继承自 Object

它俩唯一的区别是 Object 是有方法定义的(如 toString),{} 则没有定义任何 key value。

const v1: {} = { toString() { return 1; } };      // ok
const v2 = Object = { toString() { return 1; } }; // IDE Error : Type '() => number' is not assignable to type '() => string'.

toString() : number 不符合 Object.toString 的类型 () => string,因此报错了。

{}objectObject 都很抽象,不适合用来表示具体的对象结构。

在真实项目里,除非我们很确信使用正确,否则就应该停下来想一想,是不是有其它类型可以替代它们。

class、interface、object literal、type aliases + object literal

classinterface、object literal 这三种类型都可以用来描述相对具体的对象结构。

class Person1 {
  name?: string;
}

interface Person2 {
  name?: string;
}
  
const person3: { name?: string } = {};

type Person4 = {
  name?: string;
};

那我们日常该如何选择呢?

首先,class 不仅仅是类型,JavaScript 就有 class 了。

因此,如果你是要 class 作为一个工厂、模板,透过 new Class 实例化对象,那就选择 class

第二 interface,它有三个面向:

  1. 作为传统静态类型语言(C# / Java)

    class implements

  2. multiple interface

    TypeScript 独有的特性,透过 multiple interface 扩展已存在的 interface(比如原生的 interface Window

  3. 作为对象的类型

第三 object literal:如果我们懒得开一个 interface 来描述对象,那就选 object literal。

另外,type aliases + object literal 和 interface 的第三个面向是最常傻傻分不清楚的,我们重点来看一看:

interface Person {
  name: string;
}

type Animal = {
  name: string;
};

interface 和 object literal 几乎是等价的。

它们都可以被 class implements,也可以被 interface extends

class Dog implements Animal {
  name = 'Lucky';
}

interface Cat extends Animal {
  age: number;
}

它们只有三个地方不一样:

  1. interface 可以 multiple,object literal 不行。

  2. interfaceextends,object literal 用 intersection types

    interface Person {
      name: string;
    }
    
    // interface 用 extends
    interface Derrick extends Person {
      age: number;
    }
    
    type Animal = {
      name: string;
    };
    
    // object literal type 用 intersection types
    type Dog = Animal & {
      age: number;
    };
  3. extends 或 & 遇到 same key

    interface Person {
      name: string;
    }
    
    interface Derrick extends Person {
      name: number; // Person 已经有 name 了,这里不能 override 类型
    }
    
    type Animal = {
      name: string;
    };
    
    type Dog = Animal & {
      name: number; // Person 已经有 name 了,这里类型会变成 never
    };

    interface 遇到 same key different type 会报错,object literal + intersection types 会把类型变成 never

{} vs object literal with dynamic key

最后再讲讲 {} 和 object literal with dynamic key 的区别。

const v1 : {} = { name: 'Derrick' };
const v2 : { [prop: string | symbol]: unknown } = { name: 'Derrick' };

赋值时的类型检测,它俩是一样的 —— 除了 nullundefined,其它类型都可以接受。

但是在属性访问上

v1['age'] = 11; // IDE Error: Property 'age' does not exist on type '{}'
v2['age'] = 11;

{} 不支持属性访问(read / write 都不行,需要先 norrowing 才能对其操作),而 object literal with dynamic key 则可以。

object literal の 小技巧 & 细节

这里补充一些 object literal 的使用小技巧,以及类型检查的细节。

声明 "空对象"

如何声明一个 empty object "空对象" 类型(一个没有任何 key value 的对象)?

1. 使用 empty object type {}

const value: {} = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测

这当然是错的!{} 是 "除了 nullundefined 以外的所有类型",和 "空对象" 八竿子打不着。

2. 使用 objectObject

const v1: object = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测
const v2: Object = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测

还是不行。

3. 使用 class 和 interface

interface Person {}
class Animal {}

const v1: Person = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测
const v2: Animal = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测

还是不行。

4. 使用 object literal

type Person = {
  [prop: string | symbol] : unknown;
};

const value: Person = { name: 'Derrick' }; // 赋值一个 non empty object,结果通过了类型检测

还是不行...难道真的没有办法吗?

有的,需要一点巧思

type Person = {
  [prop: string | symbol] : never;
};

const v1: Person = { name: 'Derrick' }; // Type 'string' is not assignable to type 'never'
const v2: Person = {  };                // ok

使用 never 作为 value 的类型。

这样一来,只要赋值的对象 key value 不是 never,它就会报错了。

这法子不是直接阻止对象有 key,而是透过要求 key value 必须是 never,间接的达到阻止有 key 的目的。

 

泛型约束 の extends

上面已经讲解过泛型约束了,只是当时还没有谈及鸭子类型、协变、逆变等概念,因此这里做一些小补充。

有三个 class

class GrandParent {
  g1 = '';  
}

class Parent extends GrandParent {
  p1 = ''
}

class Child extends Parent {
  c1 = ''
}

它们是继承关系 —— Child ≼ Parent ≼ GrandParent

有一个 doSomething 函数

function doSomething<T extends Parent>() {}

它有一个泛型 T,而且 T 类型至少要是 Parent

如果我们传入 Child 作为泛型

doSomething<Child>();

这是 ok 的,因为 Child extends Parent,符合泛型约束。

如果传入 GrandParent

doSomething<GrandParent>(); // IDE Error: Type 'GrandParent' does not satisfy the constraint 'Parent'

那是不 ok 的。不满足泛型要求。

再看一个例子

function doSomething<T extends `version_${number}`>() {}
doSomething<'version_1'>(); // ok
doSomething<string>();      // IDE Error: Type 'string' does not satisfy the constraint '`version_${number}`'

'version_1'`version_${number}` 更具体,或者换一个说法,'version_1' 是 version_${number} 的派生。

因此泛型约束通过 ✅ 。

反观,string`version_${number}` 更抽象,因此泛型约束不通过 ❌ 。

再看一个例子

function doSomething<T extends (param: Parent) => Parent>() {}
doSomething<(param: GrandParent) => Child>(); // ok
doSomething<(param: Child) => GrandParent>();
// IDE Error: Type '(param: Child) => GrandParent' does not satisfy the constraint '(param: Parent) => Parent'

函数 extends 函数?

规则是:参数是逆变(需要更抽象),返回值是协变(需要更具体)。

因此 (param: GrandParent) => Child ok,(param: Child) => GrandParent 不 ok。

 

Class 类型 & 特性

class 还有一些细节没有讲到,这里补上。

getter、setter の 类型

only get

class Person {
  // 可以声明类型
  get name(): string {
    return 'Derrick';
  }

  // 也可以类型推断
  get age() {
    return 11;
  }
}

和设定方法一样,只是前面多了一个 get

only set

class Person {
  set name(value: string) {
    console.log(value); // 'Derrick'
  }
}

const person = new Person();
person.name = 'Derrick';

only set 的话,参数一定要声明类型,如name: string

当 get set 都存在,同时 set 没有声明类型,那么 set 的类型会依据 get 

class Person {
  // 类型推断 name: string
  get name() {
    return '';
  }

  // 参数 value 不需要声明类型,自动依据 get name 的 string
  set name(value) {}
}

当 get set 都存在,同时 set 有声明类型,那么 get 的类型会依据 set 

class Person {
  // 声明 name: string
  set name(value: string) {}

  // name 的类型依据 set name 的声明,所以是 string
  get name() {
    return 5; // IDE Error:Type 'number' is not assignable to type 'string'
  }
}

简单说,get set 是有关联的,其中一个声明类型,另一个就自动跟上。

但,如果是 get set 都有声明类型呢?

class Person {
  private _name = '';

  get name(): string {
    return this._name;
  }

  set name(value: number | boolean) {
    this._name = value.toString();
  }
}

一切正常。因为 get set 本来就是独立的方法,只要我们自己控制好,不要出现 runtime error 就行了。

override property or method

JavaScript 派生类可以 override 基类 

class Parent {
  method() {
    console.log('parent method');
  }
  method2() {
    this.method();
  }
}
class Child extends Parent {
  method() {
    console.log('child method');
  }
}

const child = new Child();
child.method2(); // 'child method'

这种 override 是隐式的,只要名字一样就能 override。

隐式很危险,容易不小心搞错,像 C# 就需要声明 overridenew 关键字才能 override。

而 TypeScript 也借鉴了这一点

class Child extends Parent {
  // 加入关键字 override
  override method() { 
    console.log('child method');
  }
}

假如派生类声明了 override,但基类没有这个属性或方法,那 IDE 会报错。(注:需要配置 tsconfig.json "noImplicitOverride": true

Parameter Property

它是一种语法糖。

下面这个

class Person {
  name: string;

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

可以改写成

class Person {
  constructor(public name: string) {}
}

透过 public 表达这个不只是一个参数,它也是一个 property。

privatereadonly、optional 也都支持

class Person {
  constructor(private readonly name?: string) {}

  /* 等价于
  private readonly name?: string; // 提醒:这里没有 declare 哦

  constructor(name?: string) {
    this.name = name;
  }
  */
}

Overload constructor return generic type

这是一个比较复杂的需求,TypeScript 目前没有直接支持,但可以用一些 hacking way 实现。

我们一步一步看

class Person<TValue> {
  value: TValue;

  constructor(value: TValue) {
    this.value = value;
  }
}

const person1 = new Person('abc');
person1.value.substring(0); // person1.value 的类型是 string

const person2 = new Person(123);
person2.value.toFixed();    // person2.value 的类型是 number

person.value 是泛型,它的类型来自实例化时传入的参数。

传入 'abc' 就是 string,传 123 就是 number

so far 没有问题,我们加难度。

我希望传入 123,TValue 的类型是 number | null

用 overloading 来描述是这样

class Person<TValue> {
  value: TValue;

  // IDE Error: Type annotation cannot appear on a constructor declaration.
  constructor(value: number): Person<number | null>;
  constructor(value: TValue): Person<TValue>;
  constructor(value: TValue): Person<number | null> | Person<TValue> {
    this.value = value;
  }
}

但目前 TypeScript 不支持这个写法,相关 Github Issue – Allow overloading constructors with type parameters to instantiate the same class but with different generics

目前,如果想做到这一点,有一个 workaround 办法(我从 Angular FormControl 学来的)

首先做 interface

interface Person<TValue> {
  value: TValue;
}

interface PersonConstructor {
  new (value: number): Person<number | null>;
  new <TValue>(value: TValue): Person<TValue>;
}

一个代表 Person,另一个代表 Person 的构造函数,接着做 class

const Person: PersonConstructor = class Person<TValue> implements Person<TValue> {
  value: TValue;

  constructor(value: TValue) {
    this.value = value;
  }
};

虽然写法很丑,但确实是做到了

const person1 = new Person('abc');
person1.value.substring(0); // person1.value 的类型是 string

const person2 = new Person(123);
person2.value!.toFixed();    // person2.value 的类型是 number | null

Overload method when matched Generic

这招我也是从 Angular FormGroup 学来的。 

class Person<T> {
  method(this: Person<string>, value: string): T;
  method(this: Person<number>, value: number): void;
  method(value: string | number): void | T {
    console.log(value);
  }
}

const person = new Person<number>();
person.method(123);
person.method('abc'); // IDE Error: No overload matches this call

关键是 this: Person<string>,它的意思是,只有在 Person<string> 时,这个 overload method 才有效。

 

Native Extension の Global Variables、Window、CustomEvent、Prototype

创建一个新的类型很简单,扩展原有类型就需要一些小技巧。

Global Variables

自从 JavaScript 引入 modular 以后,就没有 global variables 了。

但有一些老旧的 library 可能还一直沿用到现在。

如果我们需要用到它们,就得做一些类型声明才能顺利使用。

举例

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>

    <!-- 假设这是一个老旧的 JavaScript Library -->
    <script>
      var library = { doSomething: function(param) { return param + 'abc'; } };
    </script>

    <script type="module" src="./index.js" defer></script> 
  </head>
  <body></body>
</html>

library 是一个全局变量,它是 JavaScript 写的没有任何类型。

const value = library.doSomething('value'); // IDE Error: Cannot find name 'library'
value.substring(0);

直接访问它,IDE 会报错。

我们需要做类型声明,创建一个 library.d.ts 文件(d 代表 declare)

declare global {
  const library: {
    doSomething(param: string) : string
  }
}

export {};

declare global 的意思是所有 .ts 文件都能使用这个类型。

里面的 const library 是定义全局变量,然后用 object literal 来描述它的类型,这样就可以了。

如果我们不想特地创建一个 library.d.ts 文件,也可以这样写

index.ts

declare const library: { doSomething(param: string) : string }

const value = library.doSomething('value'); // ok
value.substring(0); // ok

declare 的方式大同小异,只是少了 global

这代表只有 index.ts 这个文件能使用这个类型,index2.ts 访问 library,IDE 仍然会报错。

Window

除了全局变量,这些老旧的 Library 也经常把 library 对象挂到 window 对象上(也算是一种全局的方式)。

<!-- 假设这是一个老旧的 JavaScript Library -->
<script>
  window.library = { doSomething: function(param) { return param + 'abc'; } };
</script>

我们需要这样声明类型

library.d.ts

declare global {
  interface Window {
    library: {
      doSomething(param: string) : string
    }
  }
}

export {};

利用 interface 同名的特性,扩展 window 对象。

Custom Event

参考:Stack Overflow – How do you create custom Event in Typescript?

document.addEventListener('click', event => {
  // event 的类型是 MouseEvent
});

document.addEventListener('keydown', event => {
  // event 的类型是 KeyboardEvent
});

document.addEventListener('dosomething', event => {
  // event 的类型是 Event
});

监听原生事件(如 clickkeydown),我们会得到具体的 event(如 MouseEventKeyboardEvent)。

监听自定义事件(如 custom),我们会得到抽象的 event: Event

我们要如何去扩展它,让自定义的事件也能拿到准确具体的 CustomEvent 呢?

custom-event.ts


// 这是 custom event detail,用于在 dispatch 时传递信息
interface DoSomethingEventDetail {
  value: string;
}

// 这是 CustomEvent
export class DoSomethingEvent extends CustomEvent<DoSomethingEventDetail> {
  constructor(eventInitDict: EventInit & { detail: DoSomethingEventDetail }) {
    // event name 叫 'dosomething'
    super('dosomething', { bubbles: true, ...eventInitDict });  
  }
}

declare global {
  // 透过 multiple interface 特性,扩展原生 Document 对象的 addEventListener 方法
  interface Document {
    addEventListener(
      type: 'dosomething', 
      listener: (this: Document, ev: DoSomethingEvent) => any, 
      options?: boolean | AddEventListenerOptions
    ): void;
  }

  // 扩展 HTMLElement 也是同样手法
  interface HTMLElement {
    addEventListener(
      type: 'dosomething', 
      listener: (this: HTMLElement, ev: DoSomethingEvent) => any, 
      options?: boolean | AddEventListenerOptions
    ): void;
  }
}

主要是利用了 multiple interface 的特性。

index.ts

import { DoSomethingEvent } from "./custom-event";

document.addEventListener('dosomething', event => {
  console.log(event.detail.value); // event 的类型是 DoSomethingEvent
});

document.dispatchEvent(new DoSomethingEvent({ detail: { value: 'some value' } }));

 这样类型就出来了。 

Extension Methods

许多类型都有 build-in 的扩展方法,比如

const str = '';
str.substring(0);

const num = 0;
num.toFixed();

const arr = [];
arr.push();

如果我们想增加更多的方法,该如何声明呢?

比如,我想给 string 添加一个 toKebabCase 方法。。

const titleCase = 'Hello World';
const kebadCase = titleCase.toKebabCase(); // hello-world

首先使用 multiple interface 特性,扩展 String 的方法。

declare global {
  interface String {
    toKebabCase(): string;
  }
}

然后是具体实现

Object.defineProperty(String.prototype, 'toKebabCase', {
  enumerable: false,
  value(this: string): string {
    return this.toLowerCase().replace(' ', '-');
  },
});

这样就行了。

提醒:扩展方法不是 best practice,更理想的方案是使用 Pipeline Operator

 

结语

本篇介绍了 TypeScript 的一些基础类型和特性。

通篇围绕的是 "把 TypeScript 当作静态类型语言来使用"。

如果你熟悉 JavaScript 和 C# / Java,相信过一遍就掌握八九不离十了。

下一篇《把 TypeScript 当作编程语言来使用》可能会让你感到陌生许多,因为会涉及许多 TypeScript 独有的特性,而这些是传统静态类型语言(如 C#/Java)所没有的。

敬请期待 🚀。

 

 

 

todo 放在第三篇吧

JavaScript 解构的局限

const values: [...number[], string] = [0, 0, '']; // 最后一个是 string,前面的是 number[]

const [...numberList, stringValue] = values;
// IDE Error:
// A rest element must be last in a destructuring pattern
// 虽然 TypeScript 可以表达类型,但 JavaScript 不支持把 rest 写在前面

解决方案

const values: [...number[], string] = [0, 0, '']; // 最后一个是 string,前面的是 number[]

// 这里利用泛型特性,把类型搞出来
function getLastValue<TLastValue>(values: [...unknown[], TLastValue]): TLastValue {
  return values[values.length - 1] as TLastValue;
}

const stringValue = getLastValue(values); // stringValue 是 string

 

 

posted @ 2022-10-22 20:25  兴杰  阅读(1263)  评论(0)    收藏  举报