TypeScript 最初是为了把传统的面向对象类型引入 JavaScript,好让 Microsoft 的程序员能把传统的面向对象程序带到 Web 上。随着它的发展,TypeScript 的类型系统已经演变为能够为原生 JavaScript 开发者编写的代码建模。最终形成的系统强大、有趣,也有些杂乱。
本入门指南面向希望学习 TypeScript 的 Haskell 或 ML 程序员。它描述了 TypeScript 的类型系统与 Haskell 的类型系统有何不同,也介绍了 TypeScript 类型系统因对 JavaScript 代码建模而产生的独特特性。
本入门指南不涉及面向对象编程。在实践中,TypeScript 中的面向对象程序与其他具有面向对象特性的流行语言类似。
前置知识
在本指南中,我假设你了解以下内容:
- 如何用 JavaScript 中好的那部分进行编程。
- C 系语言的类型语法。
如果你需要学习 JavaScript 中好的那部分,请阅读《JavaScript: The Good Parts》。如果你知道如何用按值调用、词法作用域、具有大量可变性且少有其他特性的语言编写程序,也许可以跳过这本书。R4RS Scheme 就是一个很好的例子。
《The C++ Programming Language》是学习 C 风格类型语法的好地方。与 C++ 不同,TypeScript 使用后缀类型,例如:x: string 而不是 string x。
Haskell 中没有的概念
内置类型
JavaScript 定义了 8 种内置类型:
| 类型 | 说明 |
|---|---|
Number |
双精度 IEEE 754 浮点数。 |
String |
不可变的 UTF-16 字符串。 |
BigInt |
任意精度格式的整数。 |
Boolean |
true 和 false。 |
Symbol |
通常用作键的唯一值。 |
Null |
等价于单元类型。 |
Undefined |
同样等价于单元类型。 |
Object |
类似于记录(record)。 |
TypeScript 为这些内置类型提供了对应的原始类型:
numberstringbigintbooleansymbolnullundefinedobject
其他重要的 TypeScript 类型
| 类型 | 说明 |
|---|---|
unknown |
顶类型(top type)。 |
never |
底类型(bottom type)。 |
| 对象字面量 | 例如 { property: Type } |
void |
用于没有文档化返回值的函数 |
T[] |
可变数组,也可写作 Array<T> |
[T, T] |
元组,长度固定但可变 |
(t: T) => U |
函数 |
注意:
-
函数语法包含参数名。这相当难以适应!
tslet fst: (a: any, b: any) => any = (a, b) => a;// or more precisely:let fst: <T, U>(a: T, b: U) => T = (a, b) => a; -
对象字面量类型语法与对象字面量值语法非常相似:
tslet o: { n: number; xs: object[] } = { n: 1, xs: [] }; -
[T, T]是T[]的子类型。这与 Haskell 不同,在 Haskell 中元组与列表没有关系。
装箱类型
JavaScript 有原始类型的装箱对应物,它们包含程序员与这些类型关联的方法。TypeScript 通过例如原始类型 number 与装箱类型 Number 之间的区别来反映这一点。装箱类型很少需要,因为它们的方法返回原始值。
ts(1).toExponential();// equivalent toNumber.prototype.toExponential.call(1);
注意,在数字字面量上调用方法时需要用括号括起来,以方便解析器处理。
渐进式类型
当 TypeScript 无法判断表达式的类型时,会使用 any 类型。与 Dynamic 相比,把 any 称为一种类型是夸大其词。它只是在出现的地方关闭类型检查器。例如,你可以把任何值推入 any[],而无需对该值做任何标记:
tsTry// with "noImplicitAny": false in tsconfig.json, anys: any[]constanys = [];anys .push (1);anys .push ("oh no");anys .push ({anything : "goes" });
而且你可以在任何地方使用类型为 any 的表达式:
tsanys.map(anys[1]); // oh no, "oh no" is not a function
any 还具有传染性——如果你用类型为 any 的表达式初始化变量,该变量也会具有 any 类型。
tslet sepsis = anys[0] + anys[1]; // this could mean anything
要在 TypeScript 产生 any 时报错,请在 tsconfig.json 中使用 "noImplicitAny": true 或 "strict": true。
结构化类型
对大多数函数式程序员来说,结构化类型是一个熟悉的概念,尽管 Haskell 和大多数 ML 语言并不是结构化类型的。其基本形式相当简单:
ts// @strict: falselet o = { x: "hi", extra: 1 }; // oklet o2: { x: string } = o; // ok
这里,对象字面量 { x: "hi", extra: 1 } 有一个匹配的字面量类型 { x: string, extra: number }。该类型可以赋值给 { x: string },因为它具有所有必需的属性,并且这些属性的类型都是可赋值的。多余的属性不会妨碍赋值,只是使它成为 { x: string } 的子类型。
具名类型只是给类型起个名字;就可赋值性而言,下面的类型别名 One 和接口类型 Two 没有区别。它们都有属性 p: string。(不过在递归定义和类型参数方面,类型别名的行为与接口不同。)
tsTrytypeOne = {p : string };interfaceTwo {p : string;}classThree {p = "Hello";}letx :One = {p : "hi" };lettwo :Two =x ;two = newThree ();
联合类型
在 TypeScript 中,联合类型是无标签的。换句话说,它们不像 Haskell 中的 data 那样是可辨识联合。不过,你常常可以利用内置标签或其他属性来区分联合中的类型。
tsTryfunctionstart (arg : string | string[] | (() => string) | {s : string }): string {// this is super common in JavaScriptif (typeofarg === "string") {returncommonCase (arg );} else if (Array .isArray (arg )) {returnarg .map (commonCase ).join (",");} else if (typeofarg === "function") {returncommonCase (arg ());} else {returncommonCase (arg .s );}functioncommonCase (s : string): string {// finally, just convert a string to another stringreturns ;}}
string、Array 和 Function 具有内置的类型谓词,方便地将对象类型留给 else 分支。不过,也可能生成在运行时难以区分的联合。对于新代码,最好只构建可辨识联合。
以下类型具有内置谓词:
| 类型 | 谓词 |
|---|---|
| string | typeof s === "string" |
| number | typeof n === "number" |
| bigint | typeof m === "bigint" |
| boolean | typeof b === "boolean" |
| symbol | typeof g === "symbol" |
| undefined | typeof undefined === "undefined" |
| function | typeof f === "function" |
| array | Array.isArray(a) |
| object | typeof o === "object" |
注意,函数和数组在运行时是对象,但有各自的谓词。
交叉类型
除了联合类型,TypeScript 还有交叉类型:
tsTrytypeCombined = {a : number } & {b : string };typeConflicting = {a : number } & {a : string };
Combined 有两个属性 a 和 b,就像它们被写成一个对象字面量类型一样。发生冲突时,交叉和联合会递归处理,因此 Conflicting.a: number & string。
单元类型
单元类型是只包含一个原始值的原始类型的子类型。例如,字符串 "foo" 的类型是 "foo"。由于 JavaScript 没有内置的枚举,常用一组众所周知的字符串来代替。字符串字面量类型的联合让 TypeScript 能为这种模式指定类型:
tsTrydeclare functionpad (s : string,n : number,direction : "left" | "right"): string;pad ("hi", 10, "left");
需要时,编译器会将该单元类型拓宽——转换为超类型——为原始类型,例如把 "foo" 拓宽为 string。这在使用可变性时会发生,可能会妨碍可变变量的某些用法:
tsTrylets = "right";Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.2345Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.pad ("hi", 10,); // error: 'string' is not assignable to '"left" | "right"' s
错误是这样发生的:
"right": "right"s: string,因为"right"在赋值给可变变量时会拓宽为string。string不能赋值给"left" | "right"
你可以为 s 添加类型注解来绕过这一点,但这反过来又会阻止把类型不为 "left" | "right" 的变量赋值给 s。
tsTrylets : "left" | "right" = "right";pad ("hi", 10,s );
与 Haskell 类似的概念
上下文类型
TypeScript 有一些明显可以推断类型的地方,例如变量声明:
tsTrylets = "I'm a string!";
但它在另外一些地方也会推断类型,如果你用过其他 C 语法风格的语言,这些地方可能出乎你的意料:
tsTrydeclare functionmap <T ,U >(f : (t :T ) =>U ,ts :T []):U [];letsns =map ((n ) =>n .toString (), [1, 2, 3]);
在这个例子中,n: number 也是如此,尽管在调用之前并没有推断出 T 和 U。事实上,在用 [1,2,3] 推断出 T=number 之后,又用 n => n.toString() 的返回类型推断出 U=string,从而使 sns 的类型为 string[]。
请注意,推断可以按任意顺序进行,但 IntelliSense 只能从左到右工作,因此 TypeScript 更倾向于把数组作为 map 的第一个参数来声明:
tsTrydeclare functionmap <T ,U >(ts :T [],f : (t :T ) =>U ):U [];
上下文类型也会递归地作用于对象字面量,以及那些否则会被推断为 string 或 number 的单元类型。它还能从上下文推断返回类型:
tsTrydeclare functionrun <T >(thunk : (t :T ) => void):T ;leti : {inference : string } =run ((o ) => {o .inference = "INSERT STATE HERE";});
o 的类型被确定为 { inference: string },因为
- 声明的初始化表达式由声明的类型进行上下文类型化:
{ inference: string }。 - 调用的返回类型把上下文类型用于推断,因此编译器推断出
T={ inference: string }。 - 箭头函数使用上下文类型来为其参数标注类型,因此编译器给出
o: { inference: string }。
而且它是在你输入的过程中完成的,因此输入 o. 之后,你就能得到属性 inference 的补全,以及真实程序中会有的其他属性。总的来说,这一特性会让 TypeScript 的推断看起来有点像统一的类型推断引擎,但它并不是。
类型别名
类型别名只是别名,就像 Haskell 中的 type 一样。编译器会尝试在源代码中使用该别名的地方都沿用别名名称,但并不总能成功。
tsTrytypeSize = [number, number];letx :Size = [101.1, 999.9];
与 newtype 最接近的等价物是带标签的交叉类型:
tstype FString = string & { __compileTimeOnly: any };
FString 就像普通字符串一样,只不过编译器认为它有一个名为 __compileTimeOnly 的属性,而该属性实际上并不存在。这意味着 FString 仍可以赋值给 string,但反过来则不行。
可辨识联合
与 data 最接近的等价物是带判别属性的类型联合,在 TypeScript 中通常称为可辨识联合:
tstype Shape =| { kind: "circle"; radius: number }| { kind: "square"; x: number }| { kind: "triangle"; x: number; y: number };
与 Haskell 不同,标签(或称判别式)只是每个对象类型中的一个属性。每个变体都有一个相同的属性,但取不同的单元类型。这仍然是一个普通的联合类型;开头的 | 是联合类型语法中可选的部分。你可以用普通的 JavaScript 代码来区分联合中的成员:
tsTrytypeShape =| {kind : "circle";radius : number }| {kind : "square";x : number }| {kind : "triangle";x : number;y : number };functionarea (s :Shape ) {if (s .kind === "circle") {returnMath .PI *s .radius *s .radius ;} else if (s .kind === "square") {returns .x *s .x ;} else {return (s .x *s .y ) / 2;}}
请注意,area 的返回类型被推断为 number,因为 TypeScript 知道该函数是全函数。如果某个变体没有被覆盖,area 的返回类型则会是 number | undefined。
此外,与 Haskell 不同,公共属性在联合类型中同样会出现,因此你可以方便地一次区分联合中的多个成员:
tsTryfunctionheight (s :Shape ) {if (s .kind === "circle") {return 2 *s .radius ;} else {// s.kind: "square" | "triangle"returns .x ;}}
类型参数
与大多数源自 C 的语言一样,TypeScript 要求先声明类型参数:
tsfunction liftArray<T>(t: T): Array<T> {return [t];}
对大小写没有要求,但按照惯例,类型参数是单个大写字母。类型参数还可以约束到某个类型,其行为有点类似类型类约束:
tsfunction firstish<T extends { length: number }>(t1: T, t2: T): T {return t1.length > t2.length ? t1 : t2;}
TypeScript 通常能根据参数的类型,从调用中推断出类型实参,因此通常不需要写出类型实参。
由于 TypeScript 是结构化类型系统,它不像名义类型系统那样需要类型参数。具体来说,要让函数具备多态性并不需要类型参数。类型参数只应用于传递类型信息,例如约束多个参数为同一类型:
tsfunction length<T extends ArrayLike<unknown>>(t: T): number {}function length(t: ArrayLike<unknown>): number {}
在第一个 length 中,T 并非必需;注意它只被引用了一次,因此并没有用来约束返回值或其他参数的类型。
高阶类型
TypeScript 没有高阶类型,因此下面的写法不合法:
tsfunction length<T extends ArrayLike<unknown>, U>(m: T<U>) {}
无点编程
无点编程——大量使用柯里化和函数组合——在 JavaScript 中是可行的,但可能很冗长。在 TypeScript 中,无点程序的类型推断常常会失败,因此你最终不得不指定类型参数而不是值参数。结果会非常冗长,所以通常最好避免无点编程。
模块系统
JavaScript 的现代模块语法与 Haskell 有点类似,区别在于任何含有 import 或 export 的文件都隐式地是一个模块:
tsimport { value, Type } from "npm-package";import { other, Types } from "./local-package";import * as prefix from "../lib/third-package";
你也可以导入 commonjs 模块——即使用 node.js 的模块系统编写的模块:
tsimport f = require("single-function-package");
你可以用导出列表来导出:
tsexport { f };function f() {return g();}function g() {} // g is not exported
或者逐个标记每个导出:
tsexport function f() { return g() }function g() { }
后一种风格更常见,但两种都是允许的,甚至可以在同一个文件中混用。
readonly 与 const
在 JavaScript 中,可变性是默认的,不过它允许用 const 声明变量,以表明该引用不可变。被引用的对象仍然是可变的:
jsconst a = [1, 2, 3];a.push(102); // ):a[0] = 101; // D:
TypeScript 还为属性提供了 readonly 修饰符。
tsinterface Rx {readonly x: number;}let rx: Rx = { x: 1 };rx.x = 12; // error
它还内置了映射类型 Readonly<T>,可让所有属性都变成 readonly:
tsinterface X {x: number;}let rx: Readonly<X> = { x: 1 };rx.x = 12; // error
它还有一个专门的 ReadonlyArray<T> 类型,该类型去掉了带副作用的方法,并禁止写入数组的索引,此外还有针对该类型的特殊语法:
tslet a: ReadonlyArray<number> = [1, 2, 3];let b: readonly number[] = [1, 2, 3];a.push(102); // errorb[0] = 101; // error
你也可以使用 const 断言,它作用于数组和对象字面量:
tslet a = [1, 2, 3] as const;a.push(102); // errora[0] = 101; // error
然而,这些选项都不是默认行为,因此在 TypeScript 代码中并没有被一致地使用。
后续步骤
本文对你日常代码中会用到的语法和类型做了高层概览。接下来,你可以:
- 从头到尾阅读完整手册
- 探索 Playground 示例