TypeScript on the way:学习TypeScript

早该学学了.

之前写过Python的类型系统,如果对于写C++,Java,C#等这类语言来说,typing根本不成问题,所以理解TypeScript也不是问题.

特殊的类型

any,unknown与never

any,unknown是”顶层类型”,never是”底层类型”.never类型是所有类型共有的,any类型基本没有限制,unknown类型不能直接调用并且运算是有限的,只能进行比较运算.推荐使用unknown代替any然后使用as转换类型.

类型系统

String与string,Number与number

String与string是不同的,前者是可以包含后者的.但是在ts中,很多方法只能使用后者.

所以推荐只使用后者.

image-20240211171858237

1
2
3
4
let obj: Object;
let obj2:{};
obj = { name: "John" };
obj = true;

此外Object类型包括除了undefined和null的基本类型.所以这并不符合直觉,推荐使用object

1
2
3
let obj3:object;
obj3 = {name:"John"};
obj3 = 13; //报错 不能将number分配给类型object

object类型包含对象,数组,函数.

1
2
3
4
5
6
const ccx = { foo: 1 };
ccx.foo = 2;
let t = { foo: 1 };
t.foo = 3;
let hh:object = {foo:1}
// hh.foo 报错 类型不对

此外undefined和null也可以赋值为number,object等等.

TypeScript中单个值也是类型成为值类型

1
2
3
let t: "dfasdf";
const xy = "https";
console.log(xy);

将多个类型组合起来就是联合类型,如果严格检查也就是设置strictNullChecks,使得其他类型变量不能被赋值为undefined或null.这个时候就可以用联合类型

1
2
3
4
5
6
7
8
9
let setting: true | false;

let gender: "male" | "female";

let rainbowColor: "赤" | "橙" | "黄" | "绿" | "青" | "蓝" | "紫";
let name: string | null;

name = "John";
name = null;

对象的合成可以给对象添加新的属性,属于交叉类型.

1
let obj5: { foo: string } & { bar: number };

类型别名

1
2
type Age = number;
let age:Age = 55;

跟Python的typing和Go语言类似.

数组 元组

1
2
3
let arr: number[] = [];
let arr2: (number|string)[] = [];
let arr3: Array<number> = [];

const数组中的元素是可以改变的,所以在ts中增加了readonly,readonly数组是原本数组的子类型.

1
2
3
const arr5: number[] = [0, 1];
arr5[0] = 3;
let arr6: readonly number[] = arr5;

声明readonly数组

1
2
3
4
let aa: readonly number[] = [1, 2, 3];
let a1: ReadonlyArray<number> = [1, 2, 3];
let a2: Readonly<number[]> = [];
let a3 = [] as const;

TypeScript 推断类型时,遇到const命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。

const命令声明的变量,如果赋值为对象,并不会推断为值类型,这是因为 JavaScript 里面,const变量赋值为对象时,属性值是可以改变的(数组等同理)

元组tuple

1
const s: [string, string, boolean] = ["a", "b", true];

使用元组时必须声明类型不然会默认数组.

1
let ot: [number, string?] | undefined = [1];

使用扩展运算符可以不下成员数量的元组.

元组也有只读元组

1
2
let readonlyTuple: readonly [number] = [1];
let point = [3, 4] as const;

symbol类型

symbol主要用于类的属性.

ts增加了unique symbol作为symbol的子类型.

1
2
3
4
5
6
7
8
9
10
11
12
// 正确
const x: unique symbol = Symbol();

// 报错
let y: unique symbol = Symbol();
const x: unique symbol = Symbol();
// 等同于
const x = Symbol();

const a: unique symbol = Symbol();
const b: typeof a = a; // 正确

感觉平常可能用不上…

函数 对象 interface

1
2
3
4
5
6
7
8
9
10
11
12
13
function hello(txt: string): void {
console.log("hello " + txt);
}

// 写法一
const hello = function (txt: string) {
console.log("hello " + txt);
};

// 写法二
const hello: (txt: string) => void = function (txt) {
console.log("hello " + txt);
};

函数声明与函数变量声明.前者需要声明参数类型,否则默认为any.后者可以在选择在赋值时写出类型或者在声明变量时添加类型.此外还有这种写法

1
2
3
4
5
6
7
let add: {
(x: number, y: number): number;
};

add = function (x, y) {
return x + y;
};

箭头函数

1
const repeat = (str: string, times: number): string => str.repeat(times);

另外使用?表示可选参数

1
2
3
4
5
6
function f(x?: number) {
// ...
}

f(); // OK
f(10); // OK

默认值也类似.

1
2
3
4
5
function createPoint(x: number = 0, y: number = 0): [number, number] {
return [x, y];
}

createPoint(); // [0, 0]

rest参数也可以用于将多个值包裹为数组或元组

1
2
3
4
5
6
7
8
9
10
11
12
function joinNum(...nums: [...number[]]): string {
console.log(nums);
return nums.join(" ");
}
joinNum(1, 2, 3, 4, 5);


function joinNumAndString(...args: [string, number]) {
console.log(args);
}

joinNumAndString("a", 1);

参数也可以使用readonly进行修饰.

此外函数返回有void和never类型.前者表示没有返回值(或undefined)后者表示不会退出,常用于丢错误或循环.

函数重载

不同于其他语言重载,

有一些编程语言允许不同的函数参数,对应不同的函数实现。但是,JavaScript 函数只能有一个实现,必须在这个实现当中,处理不同的参数。因此,函数体内部就需要判断参数的类型及个数,并根据判断结果执行不同的操作。

1
2
3
4
5
6
7
function reverse(str: string): string;
function reverse(arr: any[]): any[];
function reverse(stringOrArray: string | any[]): string | any[] {
if (typeof stringOrArray === "string")
return stringOrArray.split("").reverse().join("");
else return stringOrArray.slice().reverse();
}

重载声明的排序很重要,因为 TypeScript 是按照顺序进行检查的,一旦发现符合某个类型声明,就不再往下检查了,所以类型最宽的声明应该放在最后面,防止覆盖其他类型声明

构造函数

1
2
3
4
5
6
type AnimalConstructor = new () => Animal;

function create(c: AnimalConstructor): Animal {
return new c();
}
create(Animal);

构造函数的类型写法,就是在参数列表前面加上new命令

此外也有对象形式写法

1
2
3
type F = {
new (s: string): object;
}

针对对象,既可以使用type别名也可以使用interface

1
2
3
4
5
6
7
8
9
interface ReadOnlyPerson {
readonly name: string;
readonly age: number;
}

let w:ReadOnlyPerson = {
name:"John",
age: 22
}

空对象是 TypeScript 的一种特殊值,也是一种特殊类型。

TypeScript 不允许动态添加属性,所以对象不能分步生成,必须生成时一次性声明所有属性。

1
2
const obj = {};
obj.prop = 123; // 报错

因为Object可以接受各种类型的值,而空对象是Object类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。

1
2
3
4
5
6
7
8
9
10
11
interface Empty {}
const b: Empty = { myProp: 1, anotherProp: 2 }; // 正确
b.myProp; // 报错
let d: {};
// 等同于
// let d:Object;

d = {};
d = { x: 1 };
d = "hello";
d = 2;

interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了某个模板的对象,就拥有了指定的类型结构。

1
2
3
4
5
interface Person {
firstName: string;
lastName: string;
age: number;
}

interface 可以表示对象的各种语法,它的成员有 5 种形式。

  • 对象属性
  • 对象的属性索引
  • 对象方法
  • 函数
  • 构造函数

interface 与 type 的区别有下面几点。

(1)type能够表示非对象类型,而interface只能表示对象类型(包括数组、函数等)。

(2)interface可以继承其他类型,type不支持继承。

可以在interface中写方法以及利用interface写函数,构造函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 写法一
interface A {
f(x: boolean): string;
}

// 写法二
interface B {
f: (x: boolean) => string;
}

// 写法三
interface C {
f: { (x: boolean): string };
}

interface Add {
(x: number, y: number): number;
}

const myAdd: Add = (x, y) => x + y;

interface ErrorConstructor {
new (message?: string): Error;
}

interface可以实现继承,而type不行.而且可以多继承.多重继承时,如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface Shape {
name: string;
}

interface Circle extends Shape {
radius: number;
}

interface Style {
color: string;
}

interface Shape {
name: string;
}

interface Circle extends Style, Shape {
radius: number;
}

type Country = {
name: string;
capital: string;
};

interface CountryWithPop extends Country {
population: number;
}

注意,如果type命令定义的类型不是对象,interface 就无法继承

多个同名接口会进行合并.

1
2
3
4
5
6
7
8
interface Box {
height: number;
width: number;
}

interface Box {
length: number;
}

举例来说,Web 网页开发经常会对windows对象和document对象添加自定义属性,但是 TypeScript 会报错,因为原始定义没有这些属性。解决方法就是把自定义属性写成 interface,合并进原始定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface A {
f(x: "foo"): boolean;
}

interface A {
f(x: any): void;
}

// 等同于
interface A {
f(x: "foo"): boolean;
f(x: any): void;
}

如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型

1
2
3
4
5
6
7
8
9
10
11
interface Circle {
area: bigint;
}

interface Rectangle {
area: number;
}

declare const s: Circle | Rectangle;

s.area; // bigint | number

对于顶层声明的属性,可以在声明时同时给出类型,如果不给声明默认any.

1
2
3
4
class Point {
x: number;
y: number;
}

TypeScript 有一个配置项strictPropertyInitialization,只要打开,就会检查属性是否设置了初值,如果没有就报错。

image-20240212174733858

如果打开了这个设置,但是某些情况下,不是在声明时赋值或在构造方法里面赋值,为了防止这个设置报错,可以使用非空断言。

1
2
3
4
class Point {
x!: number;
y!: number;
}

泛型类

1
2
3
4
5
6
7
8
9
10
11
12
13
class Box<Type> {
contents: Type;

constructor(value: Type) {
this.contents = value;
}
}

const b: Box<string> = new Box("hello!");
class Pair<K, V> {
key: K;
value: V;
}

抽象类

1
2
3
4
5
6
7
abstract class A {
foo: number;
}

abstract class B extends A {
bar: string;
}

抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。

泛型

1
2
3
function getFirst<Type>(arr: Type[]): Type {
return arr[0];
}

不过为了方便,函数调用时,往往省略不写类型参数的值,让 TypeScript 自己推断,有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出.

类型参数的名字,可以随便取,但是必须为合法的标识符。习惯上,类型参数的第一个字符往往采用大写字母。一般会使用T(type 的第一个字母)作为类型参数的名字。如果有多个类型参数,则使用 T 后面的 U、V 等字母命名,各个参数之间使用逗号(“,”)分隔。

泛型主要用在四个场合:函数、接口、类和别名。

1
2
3
4
5
6
7
8
9
10
11
12
13
function id<T>(arg: T): T {
return arg;
}

function id<T>(arg: T): T {
return arg;
}
let myid: <T>(arg: T) => T = id;
interface Box<Type> {
contents: Type;
}

let box: Box<string>;

类型别名

1
2
3
4
5
6
7
8
type Nullable<T> = T | undefined | null

type Container<T> = { value: T };
type Tree<T> = {
value: T;
left: Tree<T> | null;
right: Tree<T> | null;
};

类型参数默认值

1
2
3
function getFirst_<T = string>(arr: T[]): T {
return arr[0];
}

类型参数的约束条件

1
2
3
4
5
6
7
8
9
function comp<Type extends { length: number }>(a: Type, b: Type) {
if (a.length > b.length) {
return a;
}
return b;
}

type Fn<A extends string, B extends string = "world"> = [A, B];
type Result = Fn<"hello">

类型参数的约束条件如下

1
<TypeParameter extends ConstraintType>

泛型使用注意:

  1. 尽量少用泛型
  2. 类型参数越少越好
  3. 类型参数需要出现两次
  4. 泛型可以嵌套

Enum类型

1
2
3
4
5
6
7
8
9
10
11
enum Color {
Red, // 0
Green, // 1
Blue, // 2
}
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}

Enum 结构本身也是一种类型。比如,上例的变量c等于1,它的类型可以是 Color,也可以是number

多个同名的 Enum 结构会自动合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const enum MediaTypes {
JSON = "application/json",
XML = "application/xml",
}

const url = "localhost";

fetch(url, {
headers: {
Accept: MediaTypes.JSON,
},
}).then((response) => {
// ...
});

类型断言

1
2
3
4
5
// 语法一
let bar: T = <T>foo;

// 语法二
let bar: T = foo as T;

类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型

此外还有as const断言,s const断言只能用于字面量,as const也不能用于表达式

或者先断言为unknown.

1
expr as unknown as T;

对于那些可能为空的变量(即可能等于undefinednull),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号!

1
const root = document.getElementById("root")!;

断言函数

1
2
3
function isString(value: unknown): asserts value is string {
if (typeof value !== "string") throw new Error("Not a string");
}

模块和namespace

TypeScript 模块除了支持所有 ES 模块的语法,特别之处在于允许输出和输入类型。

1
export type Bool = true | false;

模块加载方式有classic和Node,也就是Command js和ES6.

namespace 用来建立一个容器,内部的所有变量和函数,都必须在这个容器里面使用。

它出现在 ES 模块诞生之前,作为 TypeScript 自己的模块格式而发明的。但是,自从有了 ES 模块,官方已经不推荐使用 namespace 了。

1
2
3
4
5
6
7
8
9
10
namespace Utils {
function isString(value: any) {
return typeof value === "string";
}

// 正确
isString("yes");
}

Utils.isString("no"); // 报错

如果要在命名空间以外使用内部成员,就必须为该成员加上export前缀,表示对外输出该成员

1
2
3
4
5
6
7
8
9
10
11
namespace Utility {
export function log(msg: string) {
console.log(msg);
}
export function error(msg: string) {
console.error(msg);
}
}

Utility.log("Call me");
Utility.error("maybe!");

装饰器

装饰器(Decorator)是一种语法结构,用来在定义时修改类(class)的行为。

在语法上,装饰器有如下几个特征。

(1)第一个字符(或者说前缀)是@,后面是一个表达式。

(2)@后面的表达式,必须是一个函数(或者执行后可以得到一个函数)。

(3)这个函数接受所修饰对象的一些相关值作为参数。

(4)这个函数要么不返回值,要么返回一个新对象取代所修饰的目标对象。

装饰器函数和装饰器方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Decorator = (
value: DecoratedValue,
context: {
kind: string;
name: string | symbol;
addInitializer?(initializer: () => void): void;
static?: boolean;
private?: boolean;
access: {
get?(): unknown;
set?(value: unknown): void;
};
}
) => void | ReplacementValue;


type ClassDecorator = (
value: Function,
context: {
kind: "class";
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function countInstances(value: any, context: any) {
let instanceCount = 0;

const wrapper = function (...args: any[]) {
instanceCount++;
const instance = new value(...args);
instance.count = instanceCount;
return instance;
} as unknown as typeof MyClass;

wrapper.prototype = value.prototype; // A
return wrapper;
}

@countInstances
class MyClass {}

const inst1 = new MyClass();
inst1 instanceof MyClass; // true
inst1.count; // 1

declare关键字

declare 关键字用来告诉编译器,某个类型是存在的,可以在当前文件中使用。

它的主要作用,就是让当前文件可以使用其他文件声明的类型。举例来说,自己的脚本使用外部库定义的函数,编译器会因为不知道外部函数的类型定义而报错,这时就可以在自己的脚本里面使用declare关键字,告诉编译器外部函数的类型。这样的话,编译单个脚本就不会因为使用了外部类型而报错。

declare 关键字可以描述以下类型。

  • 变量(const、let、var 命令声明)
  • type 或者 interface 命令声明的类型
  • class
  • enum
  • 函数(function)
  • 模块(module)
  • 命名空间(namespace)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

declare let x: number;
declare function sayHello(name: string): void;

sayHello("张三");
declare class Animal {
constructor(name: string);
eat(): void;
sleep(): void;
}
declare namespace AnimalLib {
class Animal {
constructor(name: string);
eat(): void;
sleep(): void;
}

type Animals = "Fish" | "Dog";
}

// 或者
declare module AnimalLib {
class Animal {
constructor(name: string);
eat(): void;
sleep(): void;
}

type Animals = "Fish" | "Dog";
}

d.ts类型声明文件

可以为每个模块脚本,定义一个.d.ts文件,把该脚本用到的类型定义都放在这个文件里面。但是,更方便的做法是为整个项目,定义一个大的.d.ts文件,在这个文件里面使用declare module定义每个模块脚本的类型

使用时,自己的脚本使用三斜杠命令,加载这个类型声明文件。

1
/// <reference path="node.d.ts"/>

如果没有上面这一行命令,自己的脚本使用外部模块时,就需要在脚本里面使用 declare 命令单独给出外部模块的类型。

单独使用的模块,一般会同时提供一个单独的类型声明文件(declaration file),把本模块的外部接口的所有类型都写在这个文件里面,便于模块使用者了解接口,也便于编译器检查使用者的用法是否正确。

类型声明文件里面只有类型代码,没有具体的代码实现。它的文件名一般为[模块名].d.ts的形式,其中的d表示 declaration(声明)

1
2
3
4
5
6
7
8
/// <reference path="node.d.ts"/>
import { test } from "./test";
declare let x: number;
x = 1;
console.log(x);
console.log(test);
let p: Post = { id: 1, title: "title", content: "content" };

1
2
3
4
5
6
// node.d.ts
interface Post {
id: number;
title: string;
content: string;
}

最后推荐两个练习网站:

参考资料

  1. https://typescript.p6p.net
  2. TypeScript: Handbook - The TypeScript Handbook (typescriptlang.org)
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道