TypeScript 高级教程 – 把 TypeScript 当强类型语言使用 (第一篇)

前言

原本是想照着 TypeScript 官网 handbook 写个教程的. 但提不起那个劲...

所以呢, 还是用我自己的方式写个复习和进阶笔记就好了呗.

以前写过的 TypeScript 笔记:

angular2 学习笔记 (Typescript)

Angular 学习笔记 (Typescript 高级篇)

 

参考

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 的三个阶段

学 TypeScript 有三个阶段

第一个阶段是把 TypeScript 当 C# / Java 静态类型语言来写.

你会用到 Class, Interface, Generic, Enum 这些东西. 但其实这些只是 TypeScript 很小的部分而已.

第二阶段是把 TypeScript 当编程语言使用

C# 是没有办法表达出类型间的逻辑关系的. 你不能表达 "这个变量的类型是那个函数的第一个参数类型".

但 TypeScript 可以这样表达. 而为了实现这个表达手法, TypeScript 多了很多概念. 你甚至可以把 TypeScript 当成一门编程语言. TS 是一门用来表达 JS 类型的编程语言.

语言意味着它有自己的语法, 编程意味着它能实现基本的编程概念, 比如 variable, function, assign, call, if else, loop 等等.

如果 TypeScript 把格局做大点, 以后任何动态类型语言想静态化, 都可以使用它. (更新:后来我才意识到并不会,因为其它动态类型语言需要静态化时,开发人员会选择直接换静态语言去实现,只有 JS 没办法换...)

第三个阶段就是类型体操

如果你只是写业务代码, 你不会上升到第三阶段. 因为没有需求. 但如果你开发维护 library 就会.

许多语言 (C#) 的 library 都挺可怜的. 一堆的方法重载. 一堆的强转类型. 这是因为语言表达力不够. 

如果有关注 C# 就会发现, 自从 Blozar 想吃前端蛋糕后, C# 加入了许多特性. 就是因为 C# 没办法直接替代 JS, 

在第二阶段我们会用到许多 TypeScript Utility 来表达类型间的逻辑关系, 这些 build-in 的 Utility 是 TypeScript 封装给我们的.

它底层其实是 TypeScript 编程 e.g. variable, function, assign, call, if else, loop

到了第三阶段, build-in 的 Utility 就不够用了. 我们需要自己编写 Utility (类型体操) 和使用 Utility Library e.g. type-festts-toolbelt.

这阶段你必须对 TS 编程非常了解, 不然是写不出 Custom Utility 的. 

 

Why TypeScript?

1. IDE 检测和补助

动态类型语言的特色是需要表达的少, 写起来方便. 但缺点也是因为表达的太少, IDE 想帮忙你也难, 因为它不知道你的准确意图.

当项目简单, 程序员专业时, 其实也没有什么问题, 但是一旦项目复杂, 或者是新手阶段, 就会出现大量 runtime error.

而静态化以后, IDE 就可以提供更全面的检测和补助, 这样就可以大大减少 runtime error 和 debug time.

JS 也是有类型的

JS 并不是完全没有类型概念的, 比如下面这个例子, IDE 可以通过值的类型来提供对应的方法集.

但是面对参数就不能推断出类型了.

这时可以通过注释来声明类型

可以看出来, 即使没有 TypeScript, 为了解决动态类型语言的问题, 大家都在想办法让 JS 静态化, 只是 TypeScript 的实现手法比较优雅, 所以更受青睐.

2. 预编辑

TypeScript 的另一个好处就是提供了一种上层语法. 类似 Sass > CSS 那样.

这种方式可以加速语言新特性的普及, 尤其是语法糖. 完全不用等游览器兼容. 程序员就可以吃到甜甜的糖了.

 

表达与理解

语言嘛, 就是表达和理解, 2 个点.

我们主要都是在学表达的部分, 因为理解是解析器搞的事情, 但是呢, 作为表达者, 我们多少也需要去了解 "听者" 是否明白我们说的话.

表达

let value: string;

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

理解

而作为理解方. 当 variable 的 value 不是 string 时, 必须要警示, 这个叫检测.

同时, 在编程时, 需要提供 intellisense (help tips), 这几叫补助.

整个过程就是, 我们表达类型 > IDE 理解类型 > 并做出检测和补助. 这个循环就起来了.

语言的发展与局限

在写 TypeScript 的时候, 我们会有以下几个感受, 这些都是因为 TypeScript 还不够完善.

1. 表达不出来. (感受: 逻辑编不出...只能 hardcode)

2. 理解不对. (感受: er... 难道我表达的不对?)

3. 不够聪明. (感受: 笨, 不是告诉你了吗, 怎么没有出提示帮我?)

遇到这些情况时, 先不要气, 上 Github Issue 看看高 thumb 的 feature request. 可能会有 roadmap 或 workaround.

 

类型与 Annotations (声明类型的方式)

我们一边学类型, 一边学如何声明类型吧.

Type Annotation of Variables

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

const value1: string = '';
相等于 C# 的
string value1 = "";

如果 value 不是 string 那么 IDE 就会提示错误.

JavaScript 类型

之前复习 JS 时, 我写了一篇 JavaScript – 数据类型.

JS 有 8 个类型, string, number, boolean, object, null, undefined, bigint, symbol

通过 typeof 可以查出来, 除了 null 因为历史原因被错误的当成 object, 其它都很好理解. bigint 是 es2020 才加入的.

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

JS 的类型 TypeScript 都支持.

null is null not object

TS 纠正了 JS null is object 的混乱, null 就是 null, value 只能是 null

object vs Object

参考: Stack Overflow – Difference between 'object' ,{} and Object in TypeScript

object 类型其实不是我们直观理解的对象,它其实是 "除了其它 7 个类型以外的类型" (array 也算是 object 哦,因为它也是在 7 个类型之外)。

那为什么 object 不是 Object 呢?

首先你要了解 JS 的对象, 看这篇 JavaScript – 理解 Object, Class, This, Prototype, Function, Mixins

const obj1: Object = {};
const obj2: Object = new Object();
const obj3: Object = Object.create(Object.prototype);
const obj4: object = Object.create(null); // 它是 object not Object

obj 1,2,3 是等价的. 创建出来的对象 __proto__ 都指向 Object.prototype. 所以这个对象可以调用所有 Object.prototype 的方法 (e.g. hasOwnProperty)

但是 obj4 不同, 它的 __proto__ 是 null, 所以无法调用 Object.prototype 的方法.

而 TS 在这里就做了区分. Object 表示对象有原型链接到了 Object.prototype. 它可以调用 Object.prototype 的方法, 而 object 却没有, 这就是它们的区别.

Type Annotation of Function Parameters and Return

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

function doSomething(param1: string, parma2: number): string {
  return 'value';
}

Type Annotation of Class Property and Method

class Person {
  constructor(age: number) {
    this.age = age;
  }
  name: string = '';
  age: number;

  method(param1: string): string {
    return '';
  }
}

JS 以外的类型

除了 JS 的类型外, TS 扩展了很多很多的类型

void

TypeScript 用 void 来表示函数没有返回值.

这和 C# 是一致的.

JS 函数在没有返回值的时候, 依然会返回 undefined.

而 void 0 也可以用来表示 undefined. 所以用 void 来表达是挺贴切的.

const value1: undefined = void 0; // 赋值 void 0 到类型 undefined 是可以的
const value2: void = undefined; // 赋值 undefined 到类型 void 是可以的
function doSomething(): void {}

any

顾名思义, any 表示它是任何类型. 什么都能接受.

const value1: any = '';
const value2: any = 0;
const value3: any = false;

不管 value 是什么都可以放入 any 类型里. 

any 是最被滥用的 TypeScript 类型. 尤其是遇到 JS convert to TS 的项目. 为了 by pass error 经常会乱用 any. 以至于大家把这种代码称作 AnyScript...

一个好的 TS 代码要尽可能少的使用 any 类型.

unknown

由于 any 太乱来了. 于是 TypeScript 引入了一个叫 unknown 的类型. unknown 常用来表示"暂时还不知道"的类型.

一旦经过类型判断, 它就不在是 unknown 了.

function doSomething(value: unknown): string {
  // 一开始还不知道 value 类型, 所以 value 什么方法也没有.
  if (typeof value === 'string') {
    // 经过判断, 已知 value 是 string, 于是可以调用 string 方法
    return value.substring(0);
  } else if (typeof value === 'number') {
    // 经过判断, 已知 value 是 number, 于是可以调用 number 方法
    return value.toString();
  } else {
    return 'value';
  }
}

any vs unknown

const any: any = 'whatever type value';
// 因为是任何类型, 所以啥都可以调用, 不报错
any.age = 5;
any.method(); 

const unknown: unknown = 'whatever type value';
// 在不确定类型的时候, 什么都不能调用, 会报错
unknown.age = 5; // error
unknown.method(); // error

never

参考: Docs – never

never 用来表示连 undefined 都没有返回的函数.

上面介绍 void 时, 我们提过, JS 的函数即便没有写 return 它至少也会返回 undefined, 所以我们用 void 来表达这一点.

但有一种情况, 函数连 undefined 都不会返回.

function normal(): void {}

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

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

const value1 = normal(); // value is undefined
const value2 = infinityLoop(); // value is never
const value3 = throwError(); // value is never

其实也蛮好理解的, white (true) 函数就执行不完了, value2 自然啥也不是. never 挺贴切的.

throw 也是同样道理, 程序中断了, value3 自然什么也不是.

给一个真实场景用例:

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

param 不等于 empty string 程序就报错. 

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

logAndThrow 返回 void 的话, 无法满足 doSomething return string 的要求.

这时我们需要把 logAndThrow 的返回设置成 never 就正确了. 体会到 never 的用法了吗?

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

function logAndThrow(message: string): never {
  localStorage.setItem('error', message);
  throw new Error(message);
}

Array

const values1: string[] = [''];
const values2: Array<string> = ['']; // 这个写法和上面是等价的
values1.map(v => v); // 表达是 Array 后, IDE会提示 Array.prototype 的方法, e.g. map

变量函数

const method: (param1: string) => string = (value) => {
  return '';
};

函数 with args

const method: (...params: string[]) => string = (v1, v2) => {
  return '';
};

constructor able (class)

const personClass: new (...args: unknown[]) => void = class Person {};

Union Types (联合类型)

联合类型带有 "or" 的概念. 比如 value is string or number

const value1: string | number = ''; // string 可以接受
const value2: string | number = 0; // number 也可以接受

function doSomething(value: string | number) {
  value.toString(); // 在没有进一步判断类型时, IDE 只会显示 string 和 number 共同拥有的方法, 比如 toString, valueOf
  if (typeof value === 'string') {
    value.substring(0); // 进一步判断后就可以使用 string 的所有方法了.
  }
}

当 Union 遇上 never

never 是不会出现在 Union 里面的, 会自动被移除.

TS 还有个类型叫 Intersection Types (交叉类型), 它是 "and" 的概念, 我们晚点再介绍, 先看看别的.

小总结

到这里, 我们介绍了基本的类型声明方式, 和最常使用的 JS/TS 类型.

我尽量先介绍 JS / C# / Java 都有的特性, TS 独有的特性会缓慢一点一点丢出来. 

这样我们可以先把容易的部分吃掉. 消化后, 有了能量在继续吃 TS 独有特性.

 

静态类型语言有的特性 (类型管理与约束)

除了类型, TS 也提供了许多额外的类型管理和约束特性. 这些都是 C# / Java 有的.

Abstract Class and Method (抽象类)

abstract class Person {
  abstract method(param1: string): string;
  name: string = '';
}

class Ali extends Person {
  method(param1: string): string {
    return '';
  }
}

抽象 class 表示这个类不能直接被 new, 只能被其它 class 继承.

抽象方法表示父类只声明类型, 具体实现代码交由子类完成.

Access Modifiers (public, protected, private)

class Parent {
  private _onlyThis: string = ''; // only this class can access
  protected onlyThisAndChild: number = 0; // only this and derived class can access

  // all can access
  public method() {
    console.log(this._onlyThis);
  }
}
class Child extends Parent {
  method() {
    console.log(this.onlyThisAndChild); // access parent protected property
  }
}

Class 的属性可以用 private, protected, public 来限制访问.

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

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

public 表示任何都可以访问 (没有声明, 默认就是这个)

TS private vs JS private

注: TS 的 private 和 JS 的 private field 是不同的概念. TS private 只在 compile time 做检测, runtime 的时候是没有检测的, private 属性是 enumerable, 在 runtime 时是可以访问的.

而 JS private field 则是在 runtime 时检测的, 访问 private field 会报错的. 而且属性是 not enumerable.

Function / Method Overload (方法重载)

方法重载指的是, 一个同名方法有着不同数量或者类型的参数调用, 又或者有着不同类型的返回.

function doSomething(value: string): string; 
function doSomething(value: number): number;
function doSomething(value: string | number): string | number {
  return value;
}
const valueString = doSomething('');
const valueNumber = doSomething(0);

有 3 个 doSomething 函数, 头 2 个是用来声明不同类型的调用, 第一个是输入 string 返回 string, 第二个是输入 number 返回 number

第 3 个则是函数的实现, 这时参数的类型变成了 Union Types, 它是 string or number, 同样的返回类型也是 string or number.

方法重载对调用友好, 但对开发维护就比较累, 重载就好像写一堆的 if else else if 那样. 非常丑. 下面我会教如何用泛型来优化它.

小心坑

doSomething 方法不接受 string | number 输入

用 typeof 查看会发现它只声明了头两个函数类型. 最后那个实现函数是没有包括的, 参考: Github Issue

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

class 的方法也支持重载

class Person {
  doSomething(value: string): string; 
  doSomething(value: number): number;
  doSomething(value: string | number): string | number {
    return value;
  }
}
const person = new Person();
const value = person.doSomething(''); // value is string

注意 TS 的方法重载和 C# 在写法上是不一样的.

C# 的定义是这样

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

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

TS 只能有一个执行逻辑, 它只是在 declare type 上可以有 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# 比较方便

你可能不需要 Overload

对调用方, 有 2 个关注点.

第一个是函数的返回类型. 返回的类型要精准, 这样才容易后续的操作. 这种情况一定要用 Overload 实现. (或许以后可以通过 conditional return type 来实现)

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

const str = doSomething('');
str.substring(0); // can use as str
const num = doSomething(0);
num.toFixed(0); // can use as num

第二个是参数数量和类型

如果提供 Overload 那么调用时 IDE 可以给出每一个具体的调用提示

如果没有使用 Overload, 提示将变成混杂的 Union Types. 

这时就看你是否能接受了, 我个人是能接受 Union Types 的, 所以这种情况我不会使用 Overload.

Overload 对 v8 引擎优化有伤害?

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

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

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

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

 

Interface

class 通过 "implements" 声明接口, 这个是 Java 的语法. 

class 可以声明多接口, 不像 inherit 只能单继承.

interface CanDoSomething {
  name: string;
  doSomething(param1: string): string;
}

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

class Person implements CanDoSomething, CanFly {
  name: string = '';
  fly(param1: string): void {}
  
  doSomething(param1: string): string {
    return '';
  }
}

interface extends interface

接口是可以继承的, 而且可以 multiple inherit 哦

interface Parent1 {
  name: string;
}

interface Parent2 {
  age: number;
}

interface Child extends Parent1, Parent2 { // multiple extends
  fly(param1: string): void;
}

class Person implements Child {
  name: string = '';
  age: number = 0;
  fly(param1: string): void {}
}

multiple interface

当出现同名 interface 的时候, TS 会把它们 combine 起来哦

interface IPerson {
  name: string;
}

interface IPerson {
  age: number;
}

class Person implements IPerson {
  // 必须实现 name 和 age 两个属性
  name: string = '';
  age: number = 0;
}

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

interface Window {
  str: string;
  num: number;
}
const str = window.str; // str is string;
const num = window.num; // num is number;

interface for function

TS 的接口也可以用来声明函数类型

const doSomething1: (v1: string) => string = v1 => v1;
const doSomething2: (v1: string) => string = v1 => v1;
// 等价于
interface DoSomething {
  (v1: string): string;
}
const doSomething3: DoSomething = v1 => v1;
const doSomething4: DoSomething = v1 => v1;

JS 的 Function 除了是函数, 也是 Class 和对象. 不明白可以看这篇 : JavaScript – 理解 Object, Class, This, Prototype, Function, Mixins

所以 Interface 也可以表达 Class 和对象

interface IName {
  name: string;
}
interface IClassFunction {
  new (...args: unknown[]): IName; // 必须可以 new 然后返回对象必须实现 IName 接口
  age: number; // 有静态属性 age
}

class Person implements IName {
  name = '';
  static age = 0;
}
function doSomething(Class: IClassFunction) {
  const instance = new Class(); // able to new
  console.log(instance.name); // able access IName property
  console.log(Class.age); // able access static property
}

function normalFunction() {}
doSomething(Person); // ok
doSomething(normalFunction); // error Argument of type '() => void' is not assignable to parameter of type 'IClassFunction'.

当遇到 method overload

尽量不要用 interface 来声明方法重载, 很容易掉坑.

参考: 

Github – Allow overloads to be specified in interface

Github – Support overload resolution with type union arguments

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

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

const doSomething1: DoSomething = doSomething;

上面这样是 ok 的. 但如果我想用箭头函数就不行

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

function doSomething10(value: string | number): string | number {
  return value;
}
const doSomething11: DoSomething = doSomething10; // error

这 2 个都不行. 其原因就是需要提供一个满足所有 overload 的函数. 

v1 => v1 相等于 (string | number) => string | number 而已. 没有满足 string => string 和 number => number

同理 doSomething10 也是一样. 

虽然直觉上我们会认为是满足了的. 但对 TypeScript 来说并不是 (想搞清楚可以去看上面的 Github Issue). 我的建议是少用 interface 搞 method overload.

Enum

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

flags 这个概念 C# 也有 (看这里这里).

简单理解就是把一个 enum 变成类似 enum list

TypeScript 也支持这个语法, 参考: C# and TypeScript – Enum Flags

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

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

  if ((avaiableProcessStatus & status) > 0) { // 类似于 if (avaiableProcessStatus.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 transpile to 对象而已。

注 string 和 number 的 transpile 是不同的:

  1. string

  2. number

最终出来的对象长这样

number 可以双向获取,用 string key 获取到 number value (通常都是用这个),也可以反过来用 number key 获取到 string value (很罕见)。

string 比较简单,key 是 key,value 是 value。

想要获取所有的 key / values 可以使用 Object.keys(Status) 或者 Object.values(Status),如果是 number enum 的话记得把 number key / number value filter 掉哦。

console.log(Object.keys(Status1).filter(v => isNaN(Number(v)))); // ["Processing", "Completed", "Canceled", "WaitingApprove"]

Generic 泛型

静态类型语言的缺点就是不够灵活, 方法重载可以让它变得灵活一些, 但是它加重了维护. 于是泛型就出现了.

我们通过例子来体会它的灵活性

泛型函数

function doSomething<T>(param: T): T {
  return param;
}
const value1 = doSomething(0); // value1 is number
const value2 = doSomething(''); // value2 is string

这个 T 的意思是 Type, 其实它只是一个代号, 你要放什么字母都是可以的.

这 T 就像是参数, 类型的参数. 当调用者传入 number, 那么方法内部的 T 就表示 number, 传入 string 那么 T 就是 string. 

如果用方法重载实现上面的需求那会是这样的 

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

const value1 = doSomething(0); // value1 is number
const value2 = doSomething(''); // value2 is string

泛型类和接口

Class 和 Interface 也可以使用泛型

class Person<TValue, TParam> { // 泛型可以超过 1 个
  constructor(value: TValue) {
    this.value = value;
  }
  value: TValue;

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

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

// 接口用法和 Class 一样
interface IPerson<TValue> {
  value: TValue;
}
const person3: IPerson<string> = {
  value: '',
};

泛型默认值

TS 的泛型可以设置默认类型, 这点比 C# 还厉害呢.

function parseJson<T = { name: string }>(json: string): T {
  throw 'not implmentation';
}
const obj1 = parseJson<{ name: string; age: number }>(''); // { name: string; age: number }
const obj2 = parseJson(''); // { name: string }

泛型约束

function doSomething<T extends string | number>(param: T): T {
  return param;
}
const value1 = doSomething(0); // value1 is number
const value2 = doSomething(''); // value2 is string
const value3 = doSomething(null); // error

T extends string | number 表示, 传入的 T 类型只能是 string or number. 能被控制的灵活才叫灵活.

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

TS 的 extends 博大精深, 会涉及到鸭子类型, 异变, 协变等概念, 下面会详细讲.

小总结

这一 part 我们介绍了 TS / C# / Java 都有的许多特性. TypeScript 不仅仅只是多了一些类型, 它的目的是让编程的时候提早发现错误和提供编程补助.

但凡对这 2 点有帮助的特性都会被引入.

 

鸭子类型, 异变(covariance), 协变(contravariance)

鸭子类型

在我们继续介绍 TS 专属类型前, 先聊聊鸭子类型.

场景: 有 2 个类, Person 和 Product

class Person {
  name: string = '';
}

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

它们的共同点是都有 name 属性. 但是它们没有继承关系, 也没有共同实现的接口, 所以静态类型语言 (C#) 并不能理解它们的共同性.

接着, 有个 getName 的函数

function getName(obj: Person): string {
  return obj.name;
}

参数要求是 Person 类.

问: 如果我把 Product 对象传进去会报错吗?

const name = getName(new Product());

如果是 C# 那必须是要报错的. C# 的正确做法是, 声明一个 IName 接口, 然后让 Person 和 Product implements IName

在把 getName 参数改成 IName 接口, 这样就不报错了.

但是在 TypeScript 不需要搞这一套. 它完全不会报错. 因为 TS / JS 用的是鸭子类型, 

所谓鸭子类型就是: 我不管你是不是叫鸭子, 只要你满足鸭子的特性, 那我就把你当成鸭子.

换成 getName 函数就是:  我不管你 (传进来的对象类型 Product) 是不是叫 Person, 只要你(Product) 满足 Person 的特性 (有属性 name), 那我就把你(Product) 当成 Person 来处理.

总结: TypeScript 在对比类型的时候采用的是鸭子类型方案, 只要 A 满足所有 B 的特性, 那就可以把 A 当成 B 来看待. 

异变(covariance), 协变(contravariance)

参考: 协变与逆变 

"只要 A 满足所有 B 的特性, 那就可以把 A 当成 B 来看待" 听上去挺好理解的.

但仔细想想, A 怎样才算满足 B 的特性呢? 

以上面的例子来看, A 是 Product, B 是 Person

Person 的特性是拥有一个属性 name, 类型是 string

那么 Product 至少需要有一个类型 string 的 name 属性, 那就满足了. 如果有 extra 的属性 (e.g. age) 那是没有问题的. 可以多不可以少.

这个是比较容易的例子, 我们看看比较复杂的.

首先, 搞 3 个继承的类 

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

然后修改之前的 Person 和 Product

class Person {
  getName(param: Parent): Parent {
    return new Parent();
  }
}

class Product {
  getName(param: GrandParent): Child {
    return new Child();
  }
}

问: Product 满足 Person 吗?

function doSomething(param: Person) {}
doSomething(new Product());

其关键就是 Product.getName 方法是否可以替代 Person.getName 方法

协变, 逆变, 双向, 不变

在思考答案之前, 我们先来认识 4 个概念: 协变, 逆变, 双向, 不变, 它们用来描述替代原则. 

不变: A类只能用 A, 不可以用 A 的 parent(更抽象) 也不可以用 A 的 Child(更具体) 来替代

协变: A类可以用 A 或 A 的 child (更具体) 来替代, 但不可以用 A 的 parent(更抽象)

逆变: A类可以用 A 或 A 的 parent (更抽象) 来替代, 但不可以用 A 的 child(更具体)

双向: A类可以用 A 或者 A 的 parent (更抽象) 或者 A 的 child(更具体) 来替代.

继续思考我们的问题: Product.getName 方法是否可以替代 Person.getName 方法

我们先看参数的部分, Person.getName 的参数是 Parent. 调用者可能会传入 Parent 或者 Child (传入更具体的没关系, 函数内可以不用. 传入更抽象则不行, 因为函数内会不够用)

那么 Product.getName 要想替代 Person.getName, 首先它的参数就必须可以接受 Parent 或者 Child. 那就是"逆变"原则. 可以更抽象不可以更具体. Parent, GrandParent, Ancestor 往上都是可以作为参数的.

在来看看返回类型. Person.getName 返回类型是 Parent, 消费返回值的人至少会用到 Parent 的属性 (返回更具体的 Child 是没关系的, 返回更抽象就不行.)

那么 Product.getName 的返回值则是"协变"原则, 可以更具体不可以更抽象, Parent, Child, GrandChild 往下都是可以作为返回类型的.

总结: 要判断一个方法是否可以替换另一个方法. 参数必须是逆变(更抽象), 返回必须是协变(更具体).

Product 的参数是 GrandParent 比 Person 的参数 Parent 更抽象, 所以 ok, Product 的返回是 Child 比 Person 的 Parent 更具体, 所以也 ok. 

最后答案是 Product.getName 可以替换 Person.getName, 所以 Product 满足 Person, doSomething(new Product()); 不会报错.

tsconfig – strictFunctionTypes

参数类型默认情况下是双向的 (开启 strictFunctionTypes 之后就换成逆变).

没开启 strictFunctionTypes 的时候

type methodParent = (p: Parent) => Parent;
type methodChild = (c: Child) => Child;
type result = IsExtends<methodChild, methodParent>; // true

开启之后就不行了, 改成父类就 ok 

type methodParent = (p: Parent) => Parent;
type methodChild = (c: Parent) => Child; // 参数换成 Parent
type result = IsExtends<methodChild, methodParent>; // true

strictFunctionTypes 默认是 false, 但如果有开启 strict mode 那默认是 true.

泛型 unknown 逆变的例子

class Person<T> {
  method: (value: T) => T;
}

一个泛型 Person class,里面有一个 method,参数是泛型类型。

问:Person<string> 可以替代 Person<unknown> 吗?

type canReplace = Person<string> extends Person<unknown> ? true : false;

我们可以用 TypeScript 编程语言的特性快速得到答案 (这个特性下一篇会教,这里我们先不管它的原来)

答案是不可以。why? 

因为要替换 method,参数是逆变,类型需要更抽象,不可以更具体,而 string 显然比 unknown 更具体,所以不行。

我们一步一步看它会怎么产生 runtime error。

下面这个是 Person<unknown>

class PersonUnknown {
  method = (value: unknown) => {
     if (typeof value === 'string') {
       value.toLowerCase();
     }
  }
}

const person = new PersonUnknown();
person.method(1);
person.method('helo');

调用 method 时可以传入任何参数,因为 method 内会判断 unknown 类型做相应的处理。

下面这个是 Person<string>

class PersonString {
  method = (value: string) => {
    value.toLowerCase();
  }
}
const person = new PersonString();
person.method(1); // Error: Argument of type 'number' is not assignable to parameter of type 'string'
person.method('helo');

调用 method 时只可以传入 string 参数,因为 method 内会直接调用 value.toLowerCase 方法,这个是 string 类型才有的方法。

因此,想拿 Person<string> 替代 Person<unknown> 就会像下面这样报错

let person = new PersonUnknown();
person = new PersonString(); // Error: Type 'PersonString' is not assignable to type 'PersonUnknown'.
person.method(1); // 这里会传入 number, PersonString 的话会 runtime error
person.method('helo');

原本使用 Person<unknown> 调用 method 时可以传入 number,但替换成 Person<string> 就会 runtime error 了,所以 Person<string> 无法替代 Person<unknown>。

注:如果把 unknown 换成 any 就不会有 compile time error 了,但 runtime error 一样会有,any 只是 by pass TypeScript 检测而已。

 

TypeScript 独有类型与特性

TypeScript 从静态类型语言 (C# / Java) 借鉴的特性基本讲完了. 接下来继续介绍 TypeScript 独有的类型和特性.

类型推断 Type Inference (C# 也有)

let value1: string = '';
let value2 = ''; 
// C#
string value1 = "";
var value2 = "";

这两句的结果都是 value is string. 但它们在类型表达上, 其实是不一样的意思.

第一句明确声明了类型, 然后赋值的时候 IDE 进行检测, 这时如果值的类型不是 string 那就会报错. 最后 value 自然是 string 类型.

第二句没有明确声明类型, 赋值的时候 IDE 不会进行任何检测. 最后 IDE 依据值的类型反向去认定 value = 值的类型 (例子中就是 string)

为什么需要类型推断?

搞这么一套, 自然是为了让程序员省点力气咯.

你想想, 表达类型的目的是什么? 是为了让 IDE 检测和补助. 

let value: string = 0;

你觉得会有人写出上面这样的错误代码吗? 前脚声明 variable 是 string 类型, 后脚赋值尽然给了一个 number 0. 还需要 IDE 提示错误 ?!... 这程序员素质得有多差呢...

既然不需要检测, 那么就可以省略掉类型声明咯

let value = '';

赋值也是一种类型表达手法丫, 毕竟值的类型 TypeScript 是知道的. 所以上面这样一句代码已经足够表达 value is string 了, 后续 IDE 就可以做补助了.

什么情况下, 声明类型比推断好?

function doSomething() {
  return '';
}
const value = doSomething(); // value is string

函数返回值也是可以反推的. 

但这种情况下反推有 2 个弊端

1. 容易出错, 因为间隔

从声明变量类型到赋值, 时间间隔是很短暂的, 程序员不容易犯错. 

但从声明函数返回类型到写完函数内容, 时间间隔是很长的, 程序员是有概率搞错返回类型的, 这时 IDE 检测就可以减少犯错机会.

2. 理解代码

当我们在 code review 或 study code 的时候, 变量类型推断不会对阅读造成问题, 毕竟值就在旁边, 依然可以一目了然.

但是函数返回就没办法了 (因为 return 藏在多行代码之中), 这时直接声明类型才能让阅读一目了然.

Satisfies Operator

satisfies 是 v4.9 才推出的语法. 它是用来弥补类型声明和推断类型的不足的.

interface Person {
  name: string,
  [prop: PropertyKey]: unknown
}

有一个 Person 接口, 拥有属性 name 和其它 (可扩展)

使用类型声明

const person: Person = {
  name : 'Derrick',
  age: 21
}

它的不足是无法推断出 age: number

使用类型推断

虽然成功推断出了 age, 但是 name 却没有类型保护了, 输入 number 也被通过...

鱼和熊掌如何兼得呢? 答案是 satisfies

const person = {
  name: 'Derrick',
  age: 21
} satisfies Person;

效果

satisfies 意为 "满足", 它的逻辑是 "验证" 满足抽象, 同时 "推断" 具体.

Optional Parameter (C# 也有)

JS 函数的参数天生就是 Optional 的. 但 TS 没有 follow 这个规则, 默认情况下 TS 函数声明了参数, 调用时就必须传入参数, 不然就会报错.

要声明 optional param 很简单, 添加一个小问号就可以了 

function doSomething(value?: string) {}
doSomething();

value?: string 表示, 调用时不需要传入 value 参数.

Optional Property

class Person {
  constructor() {
    this.value1 = '';
  }
  value1: string; // no error 因为 constructor 有赋值
  value2: string = ''; // no error, 因为直接赋值相等于 constructor 赋值

  value3: string; // error, 没有赋值就会报错
  value4?: string; // no error, 加入问号表示这个属性是 optional
}

也是加入问号即可, 加入 ? 后, value4 的类型变成了 Union Types: string | undefined.

no property !== property undefined

没有属性和属性值等于 undefined 对 JS 来说是有区别的. 一个可以被 Object.keys 列出, 一个不行. 但是对于 TS 来说默认是没有区别的. 

参考: Docs – Exact Optional Property Types

去 tsconfig.json 开启 exactOptionalPropertyTypes: true, undefined 就不可以赋值给 optional property 了.

interface Person {
  str: string;
  num?: number;
  num1?: number | undefined; // | undefined 表示值可以是 undeifned, 这样才可以赋值 undefined
}

const person: Person = {
  str: '',
  num: 0,
};
delete person.num;
person.num1 = undefined; // ok
person.num = undefined; // Error : Type 'undefined' is not assignable to type 'number' with 'exactOptionalPropertyTypes: true'.

题外话: Object.assign 和 conditional add property

把 property undefined 当成 optional property 使用, 最常翻车的地方就是 Object.assign 

optional property 是这样的, 最终 num 是 0

const obj1 = { str: '', num: 0 };
const obj2 = { str: 'value' }; // 没有 num 属性 (optional)
const obj3 = Object.assign({}, obj1, obj2); // 等价于 { ...obj1, ...obj2 }
console.log(obj3); // { str: 'value', num: 0 } num 依然是 0

property undefined 是这样的, 最终 num 是 undefined

const obj1 = { str: '', num: 0 };
const obj2 = { str: 'value', num: undefined }; // 用 undefined 来表示 optional
const obj3 = Object.assign({}, obj1, obj2); // 等价于 { ...obj1, ...obj2 }
console.log(obj3); // { str: 'value', num: undefined } num 变成了 undefined

那如果我们想分清楚该怎么做呢? 答: 用 conditional add property

下面这段代码, 最终结果 num 值是 undefined

function doSomething(num?: number) {
  const obj = {
    str: '',
    num, // 相等于 num : num
  };
  console.log(obj);
}
doSomething(); // { str: '', num: undefined }

因为它等价于 obj.num = undefined

正确做法应该是先判断才决定是否添加 property, 这才是 optional property

if (nun !== undefined) {
  obj.num = num;
}

那有没有更优雅的写法呢? 有的, 参考: Stack Overflow – In JavaScript, how to conditionally add a member to an object?

function doSomething(num?: number) {
  const obj = {
    str: '',
    ...(num !== undefined && { num }),
  };
  console.log(obj);
}

doSomething(); // { str: '' } 没有 num 属性

condition && variable 意思是, 如果 true 就返回 variable, false 就返回 condition

...false, 不会添加任何属性, 因为只要 ...后不是对象, 那么 JS 会无视它.

assertion 断言惊叹号 ! (C# 也有)

使用了 Optional Property 以后, 属性的类型就变成了 string | undefined.

它就不能直接当 string 使用了. 看看下面这个例子

class Person {
  value?: string;  
}
const person = new Person();
const value: string = person.value; // error, 因为 person.value may be undefined

有两个方法可以解除 error 

1. handle undefined 的情况

const value: string = person.value ?? 'default value'; // 设置 default value
// 或者
if (person.value === undefined) throw Error('报错'); // 直接 runtime 报错
const value: string = person.value;

只要有处理 undefined, TypeScript 就不会报错了

2. 断言

断言的意思是, 我告诉 TypeScript, 这里我确信它不可能是 undefined. 所以不需要担心, 简单说就是 by pass 检测.

const value: string = person.value!; // 在 person.value 结尾加入惊叹号 !

这个做法有一点点危险. 但是很常见. 因为有经验的程序员总会找到一种 balance. 让代码在可控范围内尽量去偷懒...

! on property

还有一种写法是这样的

class Person {
  value1: string; // error : Property 'value1' has no initializer and is not definitely assigned in the constructor
  value2!: string; // no error
}

属性 value1 报错了, 因为它没有 init value, TS 很严格的, 声明属性就要给予它值. 

而 value2! 则表示, 这个 value2 没有 init value, 但是它又不是 optional, 它只是暂时没有值, 你不用担心, 在我使用它之前, 我一定会放入值.

有了这样一个声明, TS 就不会报错了, 但你需要自己确保使用前放入值, 不小心忘了的话就 runtime error 了.

断言 as (C# 也有, 或者叫强转)

class Person {
  age : number = 0;
}

function doSomething(person: Person) {}

const obj: object = {};
doSomething(obj); // 报错, object != Person
doSomething(obj as Person); // 我不管, 我就要说 obj 是 Person

as 也是一种断言方式, 它告诉 TypeScript, 你不用操心, 我告诉你 obj 就是 Person. (注: C# 也有这个特性哦)

断言是有可能失败的哦, 因为 TypeScript 觉得他比你聪明, 多半是你逻辑搞错了. 

function doSomething(nullValue: null) {}
const obj: object = {};
doSomething(obj as null); // still error : Conversion of type 'object' to type 'null' may be a mistake because neither type sufficiently overlaps with the other.

这时你可以使用大绝招

doSomething(obj as unknown as null);

强行把 obj 先强转成 unknown 在转去其它类型. 这样就一定可以 by pass 了.

断言函数 this 类型

function doSomething(this: string) {}
doSomething.call('');

声明函数内的 this 类型.

这招可以用在 extension methods.

Object.defineProperty(String.prototype, 'toKebabCase', {
  enumerable: false,
  value(this: string): string {
    return this.toLowerCase().replace(' ', '-');
  },
});

Readonly Property

JS 的属性可以配置 config writable false, 这就相等于 readonly 属性

class Person {
  readonly age : number = 0;
}

const person = new Person();
person.age = 5; // error: Cannot assign to 'age' because it is a read-only property

注意: TypeScript 不会真的去 set writable: false 哦, TypeScript 的定位是在 compile time 检测报错而已, 而不是 runtime.

注意:readonly 在 constructor 里是还可以赋值的哦,这点和 C# 一样,不过 TypeScript 没有 C# 的 const。

String Literal

String Literal 是一种比 string 更具体的 string 类型.

let status: 'Processing' | 'Completed' | 'Canceled' = 'Processing';
status = 'Completed';
status = 'Completed';
status = 'other value'; // error : Type '"other value"' is not assignable to type '"Processing" | "Completed" | "Canceled"'.

status 是 string 并且只能是 'Processing' or ‘Completed’ or 'Canceled', 其余 value 都不行.

这种约束概念就和泛型约束是一样的, 约束的越多, 表达就越精准. 这样 IDE 就可以提供更多的检测和补助.

Type Aliases (Reusable Types)

顾名思义, Type Aliases 就是给类型一个别名, 让它可以被复用(reusable).

function doSomething1(status: 'ok' | 'no') {}
function doSomething2(status: 'ok' | 'no') {}

要如何封装 status 类型, 让它可以被复用呢?

type Status = 'ok' | 'no';
function doSomething1(status: Status) {}
function doSomething2(status: Status) {}

你可以把它看作是 TypeScript 这门编程语言对 variable assign 的一种写法.

type Status 声明了一个变量, 叫 Status

= 给这个变量 assign 一个 value

'ok' | 'no' 就是那个 value, 它是一个 Union Types (TS 是一门用来写 JS 类型的编程语言, 所以它赋值的都是各种类型)

下一篇, 你将会看到大量的 Type Aliases 使用.

Tuple (C# 也有)

如果 String Literal 是 String 的约束, 那么 Tuple 就是 Array 的约束.

Common Tuple

Tuple 长这样

const values: [string, number] = ['', 0];

它明确表明了这个 Array 只有 2 个 value, 而且第一个 value 的类型是 string, 第二个类型是 number.

于是 IDE 就可以提供更好的检测和补助

function doSomething(values: [string, number]) {
  const [strValue, numValue] = values;
  strValue.substring(0); // strValue is string
  numValue.toFixed(2); // numValue is number
}

doSomething([0, '']); // error
doSomething(['', 0]); // ok

Without Tuple

没有使用 Tuple 的话, 就会像下面这样

function doSomething(values: (string | number)[]) {
  const [strValue, numValue] = values;
  if (typeof strValue !== 'string') throw new Error('values[0] must be string'); // 需要提供类型错误处理
  strValue.substring(0);
  (numValue as number).toFixed(2); // 或者需要断言
}

doSomething([0, '']); // 没有错误提示

整个代码肮胀了许多.

Tuple with Name

const range: [start: number, end: number] = [0, 100];

tuple 没有名字看上去会有点乱, 这时就需要添加名字啦. 名字只是 for code study 而已, 没有其它用途哦.

Tuple with Rest and Optional

Tuple 还可以配搭 Rest 和 Optional

const values1: [string, ...number[]] = ['', 0, 0];
const values2: [...number[], string] = [0, 0, ''];
const values3: [string, ...number[], string] = ['', 0, 0, ''];
const values4: [string, number?] = [''];

它的用处是配搭解构, 可以直接获取准确类型

const values: [string, ...number[]] = ['', 0, 0];
const [strValue, ...numberList] = values; // strValue is string, numberList is number[]

JS 的局限

// 虽然 TypeScript 可以表达, 但 JS 没有办法可以解构出来. 所以 IDE 无法有效补助
const values: [...number[], string] = [0, 0, ''];
const [...numberList, strValue] = values; // error : A rest element must be last in a destructuring pattern (JS 不支持)

破解之法

const values: [...number[], string] = [0, 0, ''];

// 配搭一个泛型方法就可以搞出类型了, 看出 TypeScript 的玩法了吗?
function getLastValue<T>(values: [...any[], T]): T {
  return values[values.length - 1];
}

const strValue = getLastValue(values); // strValue is string

Object Literal

Object Literal 不能说是 Object 的约束版本. 因为 Object 本来就被约束的死死的

比如

const obj = new Object();
obj.age = 11; // error : Property 'age' does not exist on type 'Object'

在 JS, Object 是可以添加属性的, 但 TS 把这个逻辑改了. by default 对象是不可以随意添加/删除属性的.

所以 Object Literal 更像是对象的具体版本

function doSomething(obj: { str: string; num: number }) {}
doSomething({ str: '', num: 0 });

参数声明了, 必须传入对象, 并且对象要有 str 和 num 2 个属性.

如果没有 Object Literal, 我们需要搞一个 Interface 或 Class 才能表达 (结构有点重). 所以 Object Literal 也可以算是一种替代 Class/Interface 的简化版本.

Object Literal vs Interface vs Class

既然大家都是搞对象的, 那什么时候用什么呢? 很简单

要 new 的就用 Class 咯

多个对象有相同特性, 那就可以搞个 Interface 来表达咯.

如果只是单一对象, 那么就用 Object Literal 可以了.

表达动态属性

const obj: {
  age?: number;
  [prop: string | symbol | number]: any;
} = {
  age: 11,
  whatEver: 'whatEver',
  0: 'whatEver',
};
obj[Symbol()] = 'whatEver';
delete obj.age;

immutable object / array (readonly as const)

const obj = { str: '', num: 0 };
obj.str = 'value'; // ok
const obj2 = { str: '', num: 0 } as const;
obj2.str = 'dada'; // error: Cannot assign to 'str' because it is a read-only property

const arr = [1, 2] as const;
arr.push(3); // error: Property 'push' does not exist on type 'readonly [1, 2]'

const arr2: ReadonlyArray<number> = [1, 2];
arr2.push(3); // error: Property 'push' does not exist on type 'readonly number[]'.

const arr3: readonly number[] = [1, 2];
arr3.push(3); // error: Property 'push' does not exist on type 'readonly number[]'.

写法有好几种,但效果都是一样的。

ReadonlyArray !== Array

function doSomething<T>(items: T[]) {}

const values = ['a', 'b', 'c'] as const; 
doSomething(values); // Error : The type 'readonly ["a", "b", "c"]' is 'readonly' and cannot be assigned to the mutable type 'unknown[]'

报错了,原因是 doSomething 的参数 items 类型是 Array,而外面 declare 的 values 是 ReadonlyArray。

外面说不能改,里面说有可能要改,这样不行。

反过来就不同,假如外面说可以改,里面说没有要改,那就 ok 的。

所以,在定义函数的时候一定要思考清楚,如果函数真的会修改参数 items 才使用 Array,不然就应该用 ReadonlyArray,或者用更抽象的 Iterable 更好。

不要像 Angular Material 那样

什么叫,Array 也行,ReadonlyArray 也行。你没有修改就是 ReadonlyArray,有就是 Array,没有两个的。

当 as const 遇上 string

as const 还能把 string 变成 String Literal

const a = ['a', 'b', 'c'];          // stirng[]
const b = ['a', 'b', 'c'] as const; // ['a', 'b', 'c']
const c = { value: 'a' };           // { value: string }
const d = { value: 'a' } as const;  // { value: 'a' }

Type Aliases + Object Literal vs Interface

这是一个比较混乱的问题. 因为它们是各司其职的, 只是碰巧组合在一起的能力和其它特性重叠了.

Interface 来自于静态类型语言概念, Object Literal 是轻量版的 Interface, Type Aliases 的能力是封装类型 (任何类型).

Object Literal 和 Interface 本来就像, 我上面说过, 单个对象就用 Object Literal, 要复用就用 Interface. 而 Type Aliases 也有复用的能力. 所以 Type Aliases + Object Literal 视乎就等于了 Interface

但 Interface 不只有封装对象类型的能力, 它还有其它的, 比如 multiple interface. 这时 Type Aliases 就 cover 不来了.

所以呢, 我个人的习惯是, 如果我的思路是静态类型语言, 那么我就用 Interface. 如果我的思路是封装类型而刚巧类型是 Object Literal 那就用 Type Aliases + Object Literal.

下面我列出它们各自无可替代的地方. 参考: Interface vs Type alias in TypeScript 2.7

multiple interface

同名 Interface 可以定义多个, TS 会 combine 它们的属性 (这也让我们有能力扩展原生 interface, 比如 Window)

而 Type Aliases 是不可以 duplicate name 的.

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

type TPerson = {
  name: string;
};
type TPerson = { // Duplicate identifier 'TPerson'
  age: number;
};

Template Literal

String 约束太宽松, String Literal 约束太紧, 我们需要两者之前的, 于是就有了 Template Literal.

顾名思义, 就是一个 string 模板, 约束大部分的 string, 然后开放局部让它灵活一点.

看例子理解

比如我想约束一个 string, 开头必须是 Version_ 然后后面是一个数字, 多少都可以.

const version: 'Version_1' = 'Version_1'; // ok
const version: 'Version_1' = 'Version_2'; // error : Type '"Version_2"' is not assignable to type '"Version_1"'

有了 Template Literal

const version1: `Version_${number}` = 'Version_1'; // ok
const version2: `Version_${number}` = 'Version_2'; // ok

是不是灵活多了?

配上 Union Type  = 笛卡尔积

type firstName = 'Derrick' | 'David' ;
type lastName = 'Yam' | 'Tan';
type fullName = `${firstName} ${lastName}`; // "Derrick Yam" | "Derrick Tan" | "David Yam" | "David Tan"

当 Union 时, 它还会有笛卡尔积的效果哦.

Intersection Types (交叉类型)

上面我们学过 Union Types 它带有 "或者" 的概念

而交叉类型则是带有 "和" 的概念, 比如 value is Person and Animal

例子:

class DefaultConfig {
  name = '';
}
class CustomConfig {
  age = 0;
}

function combineConfig(customConfig: CustomConfig): DefaultConfig & CustomConfig {
  return Object.assign({}, new DefaultConfig(), customConfig);
}

const finalConfig = combineConfig(new CustomConfig());
console.log(finalConfig.name);
console.log(finalConfig.age);

最后 finalConfig 拥有了所有 DefaultConfig 和 CustomConfig 的属性.

interface extends vs object literal + intersection

interface IPerson1 {
  name: string;
}
interface IPerson2 {
  age: number;
}
interface IPerson3 extends IPerson1, IPerson2 {}

//上面下面基本是等价的

type TPerson1 = {
  name: string;
};
type TPerson2 = {
  age: number;
};
type TPerson3 = TPerson1 & TPerson2;

类似上面提过的 Type Aliases + Object Literal vs Interface

由于它们功能有重叠, 所以每次遇到它们都有总该选谁的情况...

官网给出的想法是 

当继承的时候遇到同名属性但不同类型时....因该就是它所谓的 conflict 吧.

interface 是直接报错. object literal + intersection 没有报错, 但 duplcate 的属性类型变成了 never

小总结

大部分特性都是 TS 独有的, 即便有些 C# 也有, 那也是 C# 6.0 后才加进去的 (C# 6 以后, 整个语言进步的节奏就快了很多)

这些特性的共同点就是让静态类型更加灵活. 语言的目的就是表达, 表达的标准就是精准度. 所有特性都是为了达成这个目的而设计的.

 

TypeScript 中的类型判断 (Narrowing & Type Guards)

当接收到一个比较抽象的类型时 (比如最抽象的 unknown), IDE 啥也干不了, 这时我们就需要通过类型判断来明确出它的具体类型, 这个过程叫 Narrowing.

typeof

typeof 可以帮助 TS 明确出具体类型

function doSomething(param: string | number | boolean) {
  if(typeof param === 'string' ) {
    param.substring(0);
  }
  else if (typeof param === 'number') {
    param.toFixed();
  }
  else {
    // TS 很聪明的, 它知道 param 不是 string, 不是 number, 那么就只能是 boolean 了
    param.valueOf();    
  }
}

instanceof

instanceof 可以帮助 TS 明确出对象 under 什么 Class.

class Person {
  name = '';
}
class Animal {
  age = 0;
}
function doSomething(param: Person | Animal) {
  if (param instanceof Person) {
    console.log(param.name);
  } else {
    console.log(param.age);
  }
}

in operator

和上面相同的题, 只是把 instanceof 换成了 in operator.

通过判断属性 name 是否存在于 param 也可以间接推断出 param 是 Person 还是 Animal. 你是不是突然觉得 TS 很聪明呢? 

但这得有个前提, 那就是 Person 和 Animal 不可以两者都有 name 属性哦. 不然就推断不出来, 只能用 instanceof 了.

class Person {
  name = '';
}
class Animal {
  age = 0;
}
function doSomething(param: Person | Animal) {
  if ('name' in param) {
    console.log(param.name);
  } else {
    console.log(param.age);
  }
}

type property

interface Cat {
  type: 'Cat';
  firstName: string;
}
interface Dog {
  type: 'Dog';
  lastName: string;
}
interface Fish {
  type: 'Fish';
  fullName: string;
}

type Animal = Cat | Dog | Fish;

interface 没法使用 instanceof 做 Narrowing,我们可以使用 type property。

function doSomething(person: Animal) {
  if (person.type === 'Cat') {
    console.log(person.firstName);
    console.log(person.lastName); // Compile Error: Property 'lastName' does not exist on type 'Cat'
  }
  if (person.type === 'Dog') {
    console.log(person.lastName);
  }
  if (person.type === 'Fish') {
    console.log(person.fullName);
  }
}

经过 type property 的判断,TypeScript 便可以推导出类型了。

is (type predicates)

上面几个方式都是 JS 的语法, 只是 TS 利用了它来做类型判断. 显然这些无法 cover 所有场景.

于是 TS 引入了新的语法叫 “is”

class Person {
  name = '';
}

function isPerson(value: unknown): value is Person {
  return value instanceof Person;
}
// 箭头函数的写法是这样, 和上面是等价的
// const isPerson = (value: unknown): value is Person => {
//   return value instanceof Person;
// };

function doSomething(value: unknown) {
  if (isPerson(value)) {
    console.log(value.name);
  }
}

通过一个函数, 声明返回是 “参数 is 类型”

在 if(isPerson(value)) { 内 }, TS 就能确认 value is Person 了

至于 isPerson 的返回逻辑, 我们可以用任何方法去实现. 比如 instanceof, in operator 等等.

在看一个 Array.filter 的例子

const values: (string | null)[] = [];
const notNullValues = values.filter((value): value is string => value !== null); // notNullValues is string[]
// const notNullValues = values.filter((value): value is NonNullable<typeof value> => value !== null); // 高级写法,以后会讲解

中间穿插了 value is string, 这样就告知了 TS 返回的类型是 string, 没有 null 了.

更新:TypeScript v5.5 以后 built-in 了 Array.filter + is,我们不需要像上面这样自己写 value is string 了。

断言 assert function

上面有提到过断言, !, as, this. 这里教一个高级版.

assert function 和上面教的 narrowing 概念是一样的, 只是表达和处理有点不同而已.

narrowing 通常配搭 if 来使用.assert function 则是 skip 掉 if. 所以它叫断言.

// 安全判断版本
function isString(value: unknown): value is string {
  return true;
}
const value: unknown = 'whatever';
if (isString(value)) {
  value.substring(0); // 可以调用 string 方法了
} else {
  // handle not string condition
}


// 断言版本
if (!isString(value)) throw new Error('error'); // 报错.
value.substring(0); // 可以调用 string 方法了

当遇到想写断言版本的时候, TS 提供了一个 assert function 的优雅写法

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('value not string');
  }
}

const value: unknown = 'whatever';
assertIsString(value); // 断言
value.substring(0); // 可以调用 string 方法了

你甚至可以这样写.

function assert(condition: unknown): asserts condition {
  if (!condition) {
    throw new Error('assert failed');
  }
}

const value: unknown = 'whatever';
assert(typeof value === 'string'); // 任何断言
value.substring(0); // 可以调用 string 方法了

泛型与参数的类型推断 (generic, const, rest)

function getValues<TValues>(values: TValues): TValues {
  return values;
}

这是一个函数,接收参数返回参数。

调用

const values = getValues([1, 2, 'value']); // (string | number)[]

[1, 2, 'value'] 被推断类型为 array (string | number)[]。

这和使用 typeof 做类型推断的结果是一样的

const values = [1, 2, 'value'];
type Values = typeof values; // (string | number)[]

如果我们希望它推断出来的是 Tuple,那可以在 JS value 上添加 as const 让 array 变成 readonly。

const values = getValues([1, 2, 'value'] as const); // readonly [1, 2, "value"]

或者在泛型上加 const (这个是 TypeScript 泛型独有的语法)

function getValues<const TValues>(values: TValues): TValues {
  return values;
}
const values = getValues([1, 2, 'value']); // readonly [1, 2, "value"]

如果不希望返回 readonly 可以加上 extends any[]

function getValues<const TValues extends any[]>(values: TValues): TValues {
  return values;
}
const values = getValues([1, 2, 'value']); // [1, 2, "value"]

另外,还有一个不上不下的方式是使用 rest

function getValues<TValues extends any[]>(values: [...TValues]): TValues {
  return values;
}
const values = getValues([1, 2, 'value']); // [number, number, string]

它返回的类型是 Tuple [number, number, string]。

比起 array (string | number)[] 更具体。

但比起 [1, 2, 'value'] 又抽象,所以我认为这是一个一半一半的方案。想知道原理可以参考这篇:Tuples in rest parameters and spread expressions

 

Native Extension (Window, Method, Global Variable, Event)

Extension Window

上面介绍 mutilple interface 也给过这个例子。

declare global {
  interface Window {
    str: string;
    num: number;
  }
}

const str = window.str; // str is string;
const num = window.num; // num is number;

Extension Methods

许多类型都有 build-in 的扩展方法, 比如

const str = '';
str.substring(0);

const num = 0;
num.toFixed();

const arr = [];
arr.push();

如果我们想添加一个自定义方法该如何声明呢? 

先看看最终调用方式

const str = 'Hello World';
const v2 = str.toKebabCase(); // hello-world

首先使用 multiple interface 特性扩展 String 的方法 (没错, 同样是利用了 multiple interface 特性)

declare global {
  interface String {
    toKebabCase(): string;
  }
}

然后是具体实现

Object.defineProperty(String.prototype, 'toKebabCase', {
  enumerable: false,
  value(this: string): string {
    return this.toLowerCase().replace(' ', '-');
  },
});

注意看 value 方法中 this 的类型声明方式。

Extension Methods の Pipe Pattern

扩展 String,Date,Array 这些,虽然调用的时候很爽,但是它的实现方式也会导致一些问题:

  1. not tree-shakable

    Object.defineProperty String.prototype,这属于设置全局变量了,没有 modular 概念,无法 tree shake。

  2. 先前兼容

    假设你扩展了一个功能,某年某月游览器也实现了相同名字的功能,但是效果和你的有所不同,这时代码维护就会很乱。

所以大部分人都不鼓励直接扩展原生类型,取而代之的是另写一个函数。

下面是 2 个扩展 Date 对象功能的函数

function addMilliseconds(date: Date, ms: number): Date {
  const clonedDate = new Date(date);
  clonedDate.setMilliseconds(clonedDate.getMilliseconds() + ms);
  return clonedDate;
}
function addSeconds(date: Date, sec: number): Date { return addMilliseconds(date, sec * 1000); }

虽然可以 tree-shaking,也向前兼容,但是调用起来体验不好,尤其是链式调用。

const date = new Date();
let date1 = addMilliseconds(date, 50);
date1 = addSeconds(date1, 5);
// 或者
const newDate = addSeconds(addMilliseconds(date, 50), 5); // 顺序是反的

对比原本的

const date = new Date().addMilliseconds(50).addSeconds(5);

差远了😔

Pipe Pattern

为了优化调用,我们可以使用 pipe pattern。这里我给一个完整的例子。

首先做一个 pipe 函数

function pipe(source: unknown, ...pipeFns: PipeFn<unknown, unknown>[]): unknown {
  let result = source;
  for (const pipeFn of pipeFns) {
    const fnReturn = pipeFn(result);
    if (fnReturn !== undefined) {
      result = fnReturn;
    }
  }
  return result;
}

TypeScript 完整版 (需要很多 overload)

type PipeFn<T, R> = (param: T) => R;

function pipe<T, R1>(source: T, fn1: PipeFn<T, R1>): R1;
function pipe<T, R1, R2>(source: T, fn1: PipeFn<T, R1>, fn2: PipeFn<R1, R2>): R2;
function pipe<T, R1, R2, R3>(source: T, fn1: PipeFn<T, R1>, fn2: PipeFn<R1, R2>, fn3: PipeFn<R2, R3>): R3;
function pipe<T, R1, R2, R3, R4>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
): R4;
function pipe<T, R1, R2, R3, R4, R5>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
  fn5: PipeFn<R4, R5>,
): R5;
function pipe<T, R1, R2, R3, R4, R5, R6>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
  fn5: PipeFn<R4, R5>,
  fn6: PipeFn<R5, R6>,
): R6;
function pipe<T, R1, R2, R3, R4, R5, R6, R7>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
  fn5: PipeFn<R4, R5>,
  fn6: PipeFn<R5, R6>,
  fn7: PipeFn<R6, R7>,
): R7;
function pipe<T, R1, R2, R3, R4, R5, R6, R7, R8>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
  fn5: PipeFn<R4, R5>,
  fn6: PipeFn<R5, R6>,
  fn7: PipeFn<R6, R7>,
  fn8: PipeFn<R7, R8>,
): R8;
function pipe<T, R1, R2, R3, R4, R5, R6, R7, R8, R9>(
  source: T,
  fn1: PipeFn<T, R1>,
  fn2: PipeFn<R1, R2>,
  fn3: PipeFn<R3, R4>,
  fn4: PipeFn<R3, R4>,
  fn5: PipeFn<R4, R5>,
  fn6: PipeFn<R5, R6>,
  fn7: PipeFn<R6, R7>,
  fn8: PipeFn<R7, R8>,
  fn9: PipeFn<R8, R9>,
): R9;
function pipe(source: unknown, ...pipeFns: PipeFn<unknown, unknown>[]): unknown;
function pipe(source: unknown, ...pipeFns: PipeFn<unknown, unknown>[]): unknown {
  let result = source;
  for (const pipeFn of pipeFns) {
    const fnReturn = pipeFn(result);
    if (fnReturn !== undefined) {
      result = fnReturn;
    }
  }
  return result;
}
View Code

pipe 函数的职责是 for loop 调用 functions,function 的输入 (paramter) 是上一个的输出 (return)。

接着做一个 wrapper 函数

function wrapToPipeFn<TArguments extends unknown[], TValue, TReturn>(
  pipeFn: (value: TValue, ...args: TArguments) => TReturn,
) {
  return (...args: TArguments) =>
    (value: TValue) =>
      pipeFn(value, ...args);
}

它的职责是把 pipe function wrap 起来。

接着 rename 原本的扩展函数

function _addMilliseconds(date: Date, ms: number): Date {
  const clonedDate = new Date(date);
  clonedDate.setMilliseconds(clonedDate.getMilliseconds() + ms);
  return clonedDate;
}
function _addSeconds(date: Date, sec: number): Date {
  return _addMilliseconds(date, sec * 1000);
}
function _addMinutes(date: Date, min: number): Date {
  return _addSeconds(date, min * 60);
}
function _addHours(date: Date, hours: number): Date {
  return _addMinutes(date, hours * 60);
}
function _addDays(date: Date, days: number): Date {
  return _addHours(date, days * 24);
}
function _addWeeks(date: Date, weeks: number): Date {
  return _addDays(date, weeks * 7);
}
function _addMonths(date: Date, months: number): Date {
  const clonedDate = new Date(date);
  clonedDate.setMonth(clonedDate.getMonth() + months);
  return clonedDate;
}
function _addYears(date: Date, years: number): Date {
  return _addMonths(date, 12);
}
View Code

然后 wrap 它们

const addMilliseconds = wrapToPipeFn(_addMilliseconds);
const addSeconds = wrapToPipeFn(_addSeconds);
const addMinutes = wrapToPipeFn(_addMinutes);
const addHours = wrapToPipeFn(_addHours);
const addDays = wrapToPipeFn(_addDays);
const addWeeks = wrapToPipeFn(_addWeeks);
const addMonths = wrapToPipeFn(_addMonths);
const addYears = wrapToPipeFn(_addYears);

链式调用扩展函数

const date = new Date(2024, 3, 5);
const newDate = pipe(date, addYears(1), addMonths(2), addWeeks(1), addMilliseconds(500));

虽然大费周章搞了那么多东西,但整体还算能接受,因为调用确实比较好了,而且原本的函数也基本没被破坏,只是 wrap 了一层而已。

Extension DOM

其原理和 Extension Method 是一样的. 这里给一个比较实战的例子

type IsComponent = (className: string) => boolean;

function viewChildrenExtension<E extends Element = HTMLElement>(
  this: ParentNode,
  selectors: string,
  config?: { isComponent?: IsComponent }
) {
  const defaultIsComponent: IsComponent = className => className.endsWith('-component');
  const { isComponent = defaultIsComponent } = config ?? {};
  const elements = Array.from(this.querySelectorAll<E>(selectors)).filter(el => {
    let parent = el.parentElement;
    while (true) {
      if (parent == null || parent === this) break;
      if (Array.from(parent.classList).some(className => isComponent(className))) {
        return false;
      }
      parent = parent.parentElement;
    }
    return true;
  });
  return elements;
}

export function viewChildExtension<E extends Element | null = HTMLElement>(
  this: ParentNode,
  selectors: string,
  config?: { isComponent?: IsComponent }
): E {
  return viewChildrenExtension.call<
    ThisParameterType<typeof viewChildrenExtension>,
    Parameters<typeof viewChildrenExtension>,
    ReturnType<typeof viewChildrenExtension<NonNullable<E>>>
  >(this, selectors, config)[0];
}

const viewChildrenPropertyDescriptor: PropertyDescriptor = {
  enumerable: false,
  value: viewChildrenExtension,
};
const viewChildPropertyDescriptor: PropertyDescriptor = {
  enumerable: false,
  value: viewChildExtension,
};
Object.defineProperty(Element.prototype, 'viewChildren', viewChildrenPropertyDescriptor);
Object.defineProperty(Document.prototype, 'viewChildren', viewChildrenPropertyDescriptor);
Object.defineProperty(Element.prototype, 'viewChild', viewChildPropertyDescriptor);
Object.defineProperty(Document.prototype, 'viewChild', viewChildPropertyDescriptor);

declare global {
  interface Element {
    viewChildren: typeof viewChildrenExtension;
    viewChild: typeof viewChildExtension;
  }
  interface Document {
    viewChildren: typeof viewChildrenExtension;
    viewChild: typeof viewChildExtension;
  }
}
View Code

功能是模拟 Angular 的 @ViewChildren, 在 querySelector 的时候 skip 掉子层的 Component

document.viewChildren('.container');

Global Variable

参考: Stack Overflow – Purpose of declare keyword in TypeScript

有一些 thrid party library 会使用 global variable 曝露接口 (尤其是不支持模块化引入了. 比如 Google Analytics 的 gtag)

globalObj.doSomething(); // erorr : Cannot find name 'globalObj'.

这时就需要用 declare variable 来声明

declare const globalObj: {
  doSomething(): void;
};

globalObj.doSomething(); // ok

它告诉 TS, 我保证这个 scope 里能引用 variable globalObj.

declare 只是声明给 TS 而已最终不会出现在 transpile 后的 JS

这跟我们 define let variable 不同哦 

let str: string;
declare const num: number;

// transpile JS
let str; // 最终会出现
// num 没有出现

CustomEvent

参考: Stack Overflow – How do you create custom Event in Typescript?

其实它和扩展 Method 是一样的, 只不过多了一个重载概念而已.

我们可以去看 lib.dom.d.ts 它对 addEventListener 方法的类型定义

关键就是那个 DocumentEventMap, 我们可以完全学它的模式.

// 定义 event detail (passing data)
interface StateChangeEventDetail {
  str: string;
  num: number;
}
// 定义 CustomEvent Class
class StateChangeEvent extends CustomEvent<StateChangeEventDetail> {
  constructor(eventInitDict?: CustomEventInit<StateChangeEventDetail>) {
    super('statechange', eventInitDict);
  }
}

// 定义 EventMap, 类似 DocumentEventMap
interface StateChangeEventHandlersEventMap {
  statechange: StateChangeEvent; // 把所有 custom event 丢进去.
}

// 定义重载 addEventListener
// 从 lib.dom.d.ts 里抄 addEventListener
declare global {
  interface Document {
    // DocumentEventMap 换成上面的 EventMap
    addEventListener<K extends keyof StateChangeEventHandlersEventMap>(
      type: K,
      listener: (this: Document, ev: StateChangeEventHandlersEventMap[K]) => any,
      options?: boolean | AddEventListenerOptions
    ): void;
  }

  interface Element {
    addEventListener<K extends keyof StateChangeEventHandlersEventMap>(
      type: K,
      listener: (this: Element, ev: StateChangeEventHandlersEventMap[K]) => any,
      options?: boolean | AddEventListenerOptions
    ): void;
  }
}

// addEventListener
document.addEventListener('statechange', event => {
  console.log(event.detail);
});

// dispatchEvent
document.dispatchEvent(
  new StateChangeEvent({
    detail: { str: 'Derrick', num: 100 },
  })
);

 

Class Advanced 讲解

这里补上一些关于 Class 知识点.

Getter Setter 类型

only get

class Person {
  get num(): number { // 可以声明类型
    return 0;
  }
  get str() { // 也可以让 TS 推断类型
    return '';
  }
}

only set

class Person {
  set str(value: string) {}
}

只有 set 的情况, value 必须声明类型.

when both and set no define type

当 get set 都存在, 同时 set 没有声明类型, 那么 set 的类型 base on get

class Person {
  get str() {
    return '';
  }
  set str(value) {}
}

when both and set has define type

当 get set 都存在, 同时 set 有声明类型, 那么 get 的类型必须和 set 一致.

class Person {
  get str() {
    return 5; // error:  Type 'number' is not assignable to type 'string'.
  }
  set str(value: string) {}
}

when both have type

当 get set 都存在, 同时双方都有声明类型, 那么 get 的类型至少要是其中一个 set 的类型 (set 的类型可以是 Union Types)

注意:v5.1 后,这个限制被突破了,get set 可以完全不同类型了。

class Person {
  get str(): number { // error: The return type of a 'get' accessor must be assignable to its 'set' accessor typets
    return 5;
  }
  set str(value: string | boolean) {} // 没有 number 
}

下面这样才 ok, 这也是我们最常用的

class Person {
  get str(): number { // no more error
    return 5;
  }
  set str(value: string | boolean | number) {} // has number
}

Override Property / Method

JS 子类可以覆盖父类属性和方法, 

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. 这对 study code 不友好, 像 C# 就要求声明 override 或 new 关键字来表达意图

于是 TS 也借鉴了这一点

class Child extends Parent {
  override method() { // 加入关键字 override
    console.log('child method');
  }
}

假如子类声明了 override 但父类没有这个属性/方法 (可能不小心写错), IDE 就会报错. 这样就减少了出 bug 的概率.

另外, TS 还可以关闭隐式 override, 强制一定要使用关键字声明 override. 不然就报错, 在 tsconfig.json 开启  "noImplicitOverride": true

Type-only Field Declarations

参考: Docs – Type-only Field Declarations

首先你要了解 JS Class 冷知识

目前 TypeScript 4.8 默认配置的结果是不符合 JS 的

class Person {
  name!: string;
  age = 0;
}
const person = new Person();
console.log(Object.keys(person)); // ['age'] 没有 name

我们可以通过 tsconfig 把 target 设置成 >= es2022 或者 useDefineForClassFields: true 来让它和 JS 效果一致

配置后 TS transpile 出来的 JS 就不同了.

那如果我只是想声明类型呢?

用 declare property

class Person {
  declare name: string;
  age = 0;
}
const person = new Person();
console.log(Object.keys(person)); // ['age']
person.name = 'value'; // able to add property here, because have declare name property
console.log(Object.keys(person)); // ['name', 'age']

declare 就是先声明一个属性和类型, 表示一开始没有 init value 后来会补上.

这个语法比之前的好多了, 再也不需要断言 ! 了.

注意: 目前 esbuild 好像有 bug, useDefineForClassFields: true 视乎被无视了.

Parameter Property

一种语法糖

class Person {
  constructor(public str: string) {} // 声明时加多一个 public/protected/private/readonly, 它就会变成 property 了, 单单 readonly 相等于 public readonly
  // 等价于
  // constructor(str: string){
  //   this.str = str;
  // }
  // str: string;
}
const person = new Person('');

This type

参考: Docs – this Types

这是一个专门用在 class 里面的类型

return this

Parent class 有个 returnSelf 方法. 它就返回 this.

class Parent {
  returnSelf(): Parent {
    return this;
  }
}
class Child extends Parent {}
const child = new Child();
const self = child.returnSelf(); // self is Parent

如果没有 this type, 那么我们只能声明返回 Parent, 但如果用 this, 那在有派生类的情况, 它返回的是 Child, 这更精确.

class Parent {
  returnSelf(): this { // 改成 this
    return this;
  }
}
class Child extends Parent {}
const child = new Child();
const self = child.returnSelf(); // self is Child <-- 变成 Child 了

注意: 它并不是一定要返回 this (指针), 而只是必须返回和 this 一样的类型就可以了. 

parameter : this

class Parent {
  doSomething(value: this): void {}
}
class Child extends Parent {
  childValue = '';
}

const parent = new Parent();
const child1 = new Child();
const child2 = new Child();
child1.doSomething(child1); // ok
child1.doSomething(child2); // still ok
child1.doSomething(parent); // error

doSomething 要求传入一个参数, 类似必须和 this 一样. 记得, 它不是说要传入 this 指针, 而只是要求和 this 相同类型即可.

注意: parameter this 和 function this is 是不同的

class Parent {
  doSomething(value: this): void {} // 这个表示 value 是 this 类型
}

function doSomething(this: string) {} // 这个表示 doSomething 内的 this 类型是 string
doSomething.call('');

这两个完全不是一个概念哦.

this 配上 is guard type

class Person {
  name?: string;
  hasName(): this is { name: string } {
    return this.name !== undefined;
  }
}
const person = new Person();
if (person.hasName()) {
  console.log(person.name); // name is string no undefined
}

挺聪明的封装手法。

Overload constructor return generic type

例子说明

class Person {
  value: any;

  constructor(value: any) {
    this.value = value;
  }
}

const person = new Person('value');
const value = person.value; // value: any

有一个 Person 类,value 类型是 any。

改成泛型后变成这样

假如我有一个需求

当初始值是 number 时,类型要自动加上 nullable。

我们无法直接使用 overload 做到这一点

overload constructor 可以,但是加上 return type 就不行

相关 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('value');
const value1 = person1.value; // value1: string

const person2 = new Person(5566);
const value2 = person2.value; // value2: number | null

注:interface Person 和 const Person 名字是一样的,但一个是类型,一个是 variable 所以 export 时不会撞。

Overload method when matched Generic

class Person<T> {
  method(value: string): T;
  method(value: number): void;
  method(value: string | number): void | T {
    console.log(value);
  }
}

一个 Person 有 2 个 overload methods。

const person = new Person();
person.method('abc');
person.method(123);

传入 string 或 number 都可以。

我们把 string method 改成这样

method(this: Person<string>, value: string): T;

它的意思是,只有 this 是 Person<string> (T 泛型是 string),这个 method 才有效。

报错了,因为 Person 没有声明 T 泛型,拿自然就不是 string,match 不到 string method。

要加上泛型 string 才可以。 

const person = new Person<string>();
person.method('abc');

这招我也是从 Angular FormGroup 学来的。

 

 

大总结

这篇带大家复习了 TS 的基本类型和各种特性用法.

开头是以静态类型语言的角度去看待 TS, 后面就慢慢看到了 TS 的独特之处. 这是我们迈向进阶之路啊~

下一篇让我们进入 TS 第二阶段 – 把 TypeScript 当编程语言使用

 

posted @ 2022-10-22 20:25  兴杰  阅读(934)  评论(0编辑  收藏  举报