TypeScript 最初诞生于一种尝试:为 JavaScript 引入传统的面向对象类型系统,以便微软的程序员能够将传统的面向对象程序带到 Web 上。随着发展,TypeScript 的类型系统逐渐演变为能够建模原生 JavaScript 开发者编写的代码。最终形成的系统强大、有趣,同时也相当复杂。
这篇文章面向正在使用 Haskell 或 ML,但是希望学习 TypeScript 的程序员。文章将说明 TypeScript 的类型系统与 Haskell 类型系统之间的差异,同时也会介绍 TypeScript 为了建模 JavaScript 代码而具备的一些独特特性。
本介绍不涉及面向对象编程。在实践中,TypeScript 中的面向对象程序与其他主流具有面向对象特性的语言差别不大。
前置知识
在本文中,我们假设你已经具备以下前置知识:
- 能使用 JavaScript 编程,且了解其中的精华部分(the good parts)。
- 熟悉 C 系列语言的类型语法。
如果你需要了解 JavaScript 的精华部分,可以阅读 JavaScript: The Good Parts 。如果你已经会在一种按值调用、词法作用域、具有大量可变性、但特性不算复杂的语言中编写程序,也许可以跳过这本书。例如, R4RS Scheme 就是一个典型代表。
The C++ Programming Language 是学习 C 风格类型语法的不错选择。但与 C++ 不同的是,TypeScript 使用的是后置类型语法,例如 x: string ,而不是 string x。
和Haskell不同的概念
内置类型
JavaScript 一共定义了 8 种内置类型:
| Type |
Explanation |
| Number |
双精度 IEEE 754 浮点数。 |
| String |
不可变的 UTF-16 字符串。 |
| BigInt |
任意精度整数。 |
| Boolean |
true 和 false。 |
| Symbol |
用作键的唯一值。 |
| Null |
等同于 unit 类型。 |
| Undefined |
也等同于 unit 类型。 |
| Object |
类似 record。 |
更多细节可以参考 MDN。
TypeScript 为这些内置类型提供了对应的原始类型(primitive types):
number
string
bigint
boolean
symbol
null
undefined
object
其它重要TypeScript类型
| Type |
Explanation |
unknown |
顶类型(top type) |
never |
底类型(bottom type) |
| 对象字面量 |
例如 { property: Type } |
void |
用于没有明确返回值的函数 |
T[] |
可变数组,也可以写作 Array<T> |
[T, T] |
元组,长度固定但可变 |
(t: T) => U |
函数 |
函数类型语法会包含参数名,需要一定时间适应。
let fst: (a: any, b: any) => any = (a, b) => a;
// 更精确的写法如下:
let fst: <T, U>(a: T, b: U) => T = (a, b) => a;
对象字面量类型的语法与对象字面量的值语法高度对应:
let o: { n: number; xs: object[] } = { n: 1, xs: [] };
[T, T] 是 T[] 的子类型。这与 Haskell 不同,Haskell 中元组与列表之间没有关系。
包装类型
JavaScript 为原始类型提供了对应的包装类型,这些包装类型包含了与这些类型相关的方法。TypeScript也体现了这一点,例如原始类型 number 与包装类型 Number 的区别。通常不需要显式使用包装类型,因为它们的方法返回的依然是原始类型。
(1).toExponential();
// 等价于
Number.prototype.toExponential.call(1);
注意,在数值字面量上调用方法时,需要使用括号包裹数值以帮助解析器正确识别。
渐进式类型
TypeScript 会在无法判断某个表达式类型时使用 any。如果把 any 称为一种类型,那其实有些夸张;相比Dynamic,它更像是关闭了类型检查:只要出现 any,对应位置就不会再被检查。
译者注:
这里的Dynamic指的是动态类型系统,例如JavaScript、Python都采用的是动态类型系统。
例如,你可以往 any[] 中塞入任意值,而无需对这些值做任何标记:
// 在 tsconfig.json 中将 "noImplicitAny" 设为 false 时,anys 会被推断为 any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });
你也可以在任何地方使用类型为 any 的表达式:
anys.map(anys[1]); // oh no,"oh no" 并不是一个函数
any 还具有传染性——如果用一个 any 初始化变量,那么该变量本身也会变成 any。
let sepsis = anys[0] + anys[1]; // 结果可能是什么都说不准
如果你希望在 TypeScript 推断出 any 时就报错,可以在 tsconfig.json 中启用 "noImplicitAny": true,或开启 "strict": true。
结构化类型
结构化类型(structural typing)对大多数函数式程序员来说并不陌生,尽管 Haskell 和大多数 ML 语言本身并不是结构化类型系统。
它的基本形式相当简单:
// @strict: false
let o = { x: "hi", extra: 1 }; // ok
let o2: { x: string } = o; // ok
这里,对象字面量 { x: "hi", extra: 1 } 的字面量类型是 { x: string, extra: number }。该类型可以赋值给 { x: string },因为它包含所有必须的属性,并且这些属性的类型也都可赋值。额外的属性不会阻止赋值,只是让它成为 { x: string } 的子类型。
命名类型只是为某个类型起一个名字;在可赋值性(assignability)方面,下面的类型别名 One 与 interface 类型 Two 并没有区别。它们都包含一个 p: string 属性。 (不过,在递归定义和类型参数上,type alias 与 interface 的行为确实不同。)
type One = { p: string };
interface Two {
p: string;
}
class Three {
p = "Hello";
}
let x: One = { p: "hi" };
let two: Two = x;
two = new Three();
联合类型
在 TypeScript 中,联合类型是无标签的(untagged)。
换句话说,它们并不像 Haskell 中的 data 那样是可区分联合类型(discriminated unions)。
不过,你通常可以依靠内置标签或对象的其他属性来区分一个联合类型中的成员。
function start(
arg: string | string[] | (() => string) | { s: string }
): string {
// 这种写法在 JavaScript 中非常常见
if (typeof arg === "string") {
return commonCase(arg);
} else if (Array.isArray(arg)) {
return arg.map(commonCase).join(",");
} else if (typeof arg === "function") {
return commonCase(arg());
} else {
return commonCase(arg.s);
}
function commonCase(s: string): string {
// 最终只是把一个字符串转换成另一个字符串
return s;
}
}
在这个例子中,string、Array 和 Function 都对应内置类型谓词(type predicates),这样就可以在 else 分支中把对象类型留给最后处理。当然,也可能出现一些在运行时难以区分的联合类型。对于新的代码,最好只构建可区分联合类型(discriminated unions)。
下面这些类型都拥有内置的类型谓词:
| Type |
Predicate |
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" |
注意,函数和数组在运行时都属于对象,但它们拥有各自独立的类型谓词。
译者注:
谓词(predicate)指的是:一个返回布尔值的判断函数,用来检查某个值是否满足某种类型或条件。这个词最早来自逻辑学和数学,意思是一个对某个对象进行判断的表达式,结果要么是真,要么是假。例如:
- “x 是偶数”
- “字符串 s 的长度大于 3”
这些都是谓词,因为它们都会返回 true/false。
在 TypeScript / JavaScript 的语境中,类型谓词(type predicate) 指一种在运行时判断值是否属于某个类型的表达式或函数。
例如:
typeof x === "string"
它做的事情就是:判断 x 是否是 string 结果是 true 或 false,这就是一个类型谓词。
同样,判断数组的谓词是:
Array.isArray(x)
判断函数的谓词是:
typeof x === "function"
可以这样理解:谓词 = 用来判断类型或条件的布尔表达式
交叉类型
除了联合类型之外,TypeScript 还支持交叉类型(intersection):
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };
Combined 同时拥有 a 和 b 两个属性,就像把它们写在同一个对象字面量类型里一样。
当出现属性冲突时,交叉类型与联合类型一样会进行递归处理,因此 Conflicting.a 的类型会变成 number & string,也就是两个类型的交集。
单元类型
单元类型(unit types)是只包含一个原始值的原始类型子类型。
例如,字符串 "foo" 的类型就是 "foo"。
由于 JavaScript 没有内置枚举类型(enum),因此通常会使用一组约定好的字符串来替代枚举类型。字符串字面量类型的联合类型让 TypeScript 可以很好地为这种模式提供类型支持:
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");
当需要时,编译器会对单元类型执行扩展(widening) —— 也就是将它转换成其原始类型的超类型。
例如 "foo" 会被扩展为 string。这种扩展发生在可变性(mutability)场景下,而这可能会影响对可变变量的某些使用方式:
let s = "right";
pad("hi", 10, s); // error: 'string' 不能赋值给 '"left" | "right"'
错误信息:
Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.
错误的原因如下:
"right" 的类型是 "right"
- 把它赋给可变变量
s 时,类型会被扩展(widen)为 string
- 而
string 无法赋值给 "left" | "right"
你可以通过对 s 添加类型标注来规避这个问题,但这样就无法再把其他 string 类型的值赋给 s,只能赋值 "left" 或 "right":
let s: "left" | "right" = "right";
pad("hi", 10, s);
和Haskell相似的概念
上下文类型推断
TypeScript在一些显而易见的场景中可以进行类型推断,例如变量声明:
let s = "I'm a string!";
但在某些可能你非预期的地方,TypeScript也会进行类型推断,尤其是当你来自其他C语法系语言时:
declare function map<T, U>(f: (t: T) => U, ts: T[]): U[];
let sns = map((n) => n.toString(), [1, 2, 3]);
在这个示例中,n: number 的类型也被正确推断出来,尽管在函数调用之前 T 和 U 的类型并未被确定。
实际上,TypeScript会先根据 [1, 2, 3] 推断出 T = number,然后根据 n => n.toString() 的返回类型推断出 U = string,最终 sns 的类型就变成了 string[]。
需要注意的是,虽然推断可以以任意顺序进行,但智能提示只能从左到右工作,因此TypeScript更倾向于将数组参数放在前面来声明 map:
declare function map<T, U>(ts: T[], f: (t: T) => U): U[];
上下文类型推断也会递归地作用在对象字面量上,并能保留原本会被扩展为 string 或 number 的单元类型。同时,它也能够从上下文中推断返回类型:
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERT STATE HERE";
});
这里 o 的类型之所以被确定为 { inference: string },原因如下:
- 声明的初始值会根据声明的类型进行上下文类型推断,因此整体类型被定位为
{ inference: string }。
- 函数调用的返回类型会使用上下文类型来进行推断,因此编译器推断
T = { inference: string }。
- 箭头函数根据上下文类型推断其参数类型,因此
o 被推断为 { inference: string }。
并且这一切都是在你输入代码时即时发生的,因此当你输入 o. 时,智能提示会自动补全属性 inference,以及真实程序中可能存在的其他属性。总体而言,这一特性会让 TypeScript 的推断看起来有点像“统一类型推断引擎”,但它并不是。
类型别名
类型别名(type alias)只是别名,就像 Haskell 中的 type 一样。编译器会尝试在使用处保留这个别名的名字,但并不总是能做到。
type Size = [number, number];
let x: Size = [101.1, 999.9];
与 Haskell 的 newtype 最接近的形式是使用带标签的交叉类型(tagged intersection):
type FString = string & { __compileTimeOnly: any };
FString 与普通的 string 并无差别,只不过编译器会认为它额外带有一个名为 __compileTimeOnly 的属性,而这个属性在运行时实际上并不存在。也正因为如此,FString 仍然可以赋值给 string,但反过来却不行,不能 将string 赋值给 FString。
可区分联合类型
与 Haskell 的 data 最接近的写法,是在 TypeScript 中使用带有区分属性(discriminant property)的联合类型,这类模式通常被称为 可区分联合类型(discriminated unions):
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
与 Haskell 不同,这里的“标签”(tag)或“区分字段”(discriminant)只是对象类型中的一个普通属性,每个变体都有一个结构相同但单元类型不同的属性。它依然是一个普通的联合类型,前导的 | 只是语法上的可选写法。
译者注:
前导的 | 是联合类型语法中的可选部分。
也就是说:
type Shape =
| A
| B
| C;
和
type Shape =
A | B | C;
这两种写法在语法上完全等价,只是是否在每一行前加上 | 取决于开发者的书写风格,TypeScript都能正确识别。
你可以用普通的 JavaScript 代码来区分各个联合成员:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
function area(s: Shape) {
if (s.kind === "circle") {
return Math.PI * s.radius * s.radius;
} else if (s.kind === "square") {
return s.x * s.x;
} else {
return (s.x * s.y) / 2;
}
}
需要注意的是,area 的返回类型会被推断为 number,因为TypeScript能确定该函数对联合类型的所有变体都是完备的(total)。如果有某个变体没有被覆盖,返回类型就会变成 number | undefined。
另外,与 Haskell 不同的是,如果多个联合成员共享某些属性,那么这些共享属性在联合类型上也是可见的,因此你可以利用这些共同属性对多个成员做区分或处理:
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}
这里 square 与 triangle 都包含属性 x,因此在 else 分支中可以安全地访问它。
类型参数
与大多数C系语言一样,TypeScript需要显式声明类型参数:
function liftArray<T>(t: T): Array<T> {
return [t];
}
大小写没有强制要求,但按照惯例,类型参数通常使用单个大写字母。类型参数也可以通过约束绑定到某个类型,这种行为有点类似于类型类(type class)约束:
function firstish<T extends { length: number }>(t1: T, t2: T): T {
return t1.length > t2.length ? t1 : t2;
}
TypeScript 通常能够根据调用时的实参类型推断类型参数,因此大多数情况下不需要显式写出类型实参。
由于 TypeScript 使用结构化类型系统,它对类型参数的依赖比名义类型系统(nominal systems)要少。尤其是,类型参数不是用来让函数变成多态的前提条件。类型参数主要用于传递类型信息,比如让多个参数保持相同类型:
function length<T extends ArrayLike<unknown>>(t: T): number {}
function length(t: ArrayLike<unknown>): number {}
在第一个 length 中,类型参数 T 并不是必须的;它只被引用了一次,没有用于约束返回值类型或其他参数类型。
高阶类型
TypeScript 并不支持高阶类型,因此类似下面的写法是非法的:
function length<T extends ArrayLike<unknown>, U>(m: T<U>) {}
译者注:
高阶类型,英文为 higher-kinded types,简称 HKT,指的是接收一个“类型构造器”,并返回一个新的类型构造器或类型。
先理解什么是类型构造器,所谓类型构造器,指的是接收一个类型,返回一个新的类型。
例如:
Array<T>
Promise<T>
Option<A>(Haskell)
List<A>(Haskell)
这些都需要一个类型作为输入,返回一个新的类型。
高阶类型则对类型构造器本身再进行抽象,接收的是一个类型构造器,返回的是一个类型或类型构造器。
例如,Haskell 的 Functor:
class Functor f where fmap :: (a -> b) -> f a -> f b
这里的 f 不是一个普通类型,而是一个类型构造器:f :: * -> *
也就是说:在高阶类型中,类型参数本身是一个“类型构造器”。
总结一下:
- 类型构造器 = 接收类型 → 返回类型
- 高阶类型 = 接收类型构造器 → 返回类型或类型构造器
TypeScript 不支持高阶类型。
无点风格编程
无点风格编程(point-free programming)——通过柯里化和函数组合进行大量函数式写法——在 JavaScript 中是可行的,但往往比较啰嗦。在 TypeScript 中,由于类型推断在无点风格下经常失败,你通常需要显式指定类型参数而不是值参数。
结果就是代码会变得非常冗长,因此在 TypeScript 中一般不推荐使用无点风格编程。
译者注:
point-free programming,翻译成中文就是无点风格编程。这里的 point 指的就是函数的参数,而 point-free 就是没有写参数。
因此无点风格编程指的就是:定义函数时不写出显式的参数,只用函数组合来表达逻辑的一种风格,也是一种典型的函数式编程的风格。
有点风格(普通写法)
const incrementAll = arr => arr.map(x => x + 1);
这里 arr 和 x 都是参数,也可以看作是点(points)。
无点风格写法
如果用 Ramda 或类似的函数式工具库,可以写成:
const incrementAll = map(add(1));
这里我们没有写 arr 和 x,而是直接组合函数:
map(作用于数组)
add(1)(对每个元素 +1)
函数就是另一些函数的组合,这就是无点风格。这种风格通常依赖:
- 柯里化(currying)
- 函数组合(composition)
- 高阶函数(map、filter 等)
模块系统
现代JavaScript的模块语法和Haskell有些类似,不过只要文件中出现 import 或 export,该文件就会被隐式视为一个模块:
import { value, Type } from "npm-package";
import { other, Types } from "./local-package";
import * as prefix from "../lib/third-package";
你也可以导入 commonjs 模块 —— 即使用 node.js 模块系统编写的模块:
import f = require("single-function-package");
你可以使用导出列表来导出内容:
export { f };
function f() {
return g();
}
function g() {} // g不会被导出
或者逐个标记每一个导出:
export function f() { return g() }
function g() { }
后一种写法更常见,但两种方式都被允许,甚至可以在同一个文件中混用。
readonly与const
在JavaScript中,可变性(mutability)是默认行为,不过你可以通过 const 声明变量,使其引用本身不可变。但被引用的值依然是可变的:
const a = [1, 2, 3];
a.push(102); // ):
a[0] = 101; // D:
TypeScript额外提供了 readonly 修饰符用于属性:
interface Rx {
readonly x: number;
}
let rx: Rx = { x: 1 };
rx.x = 12; // error
同时,它还内置了一个映射类型 Readonly<T>,可以将所有属性标记为 readonly:
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // error
TypeScript还提供了一个专门的 ReadonlyArray<T> 类型,用于移除所有具有副作用的方法,并禁止对数组索引进行写操作。
同时,TypeScript还为这种类型提供了对应的特殊语法:
let a: ReadonlyArray<number> = [1, 2, 3];
let b: readonly number[] = [1, 2, 3];
a.push(102); // error
b[0] = 101; // error
你还可以使用 const-assertion(const 断言),它作用于数组和对象字面量:
let a = [1, 2, 3] as const;
a.push(102); // error
a[0] = 101; // error
然而,这些手段都不是默认行为,因此在TypeScript代码中并不会被一致地使用。
译者注:
这句话的意思是:尽管TypeScript给我们提供了很多方式让数据变成不可变(immutable)
例如:
const
readonly 属性修饰符
Readonly<T>
ReadonlyArray<T>
as const
但这些全部都不是默认行为。也就是说:
- 默认情况下所有东西都是可变的
- 只有开发者自己显式写了
readonly 或 as const 才会变成不可变
因此结果就是:
- 有些开发者会用 readonly
- 有些开发者完全不使用
- 有些代码库只在部分地方用
- 不同文件、不同团队的风格都可能不一样
因此缺乏一致性,TypeScript生态中无法形成统一的不可变风格。(不像 Rust、Haskell 等语言有默认的不可变性)。
译者解读:
这篇文档主要介绍了TypeScript和函数式编程的区别。这里选择了Haskell这门函数式编程语言来进行对比。
对比的时候从两个大的方面出发:
- 和Haskell不同的概念
- 和Haskell相同的概念
当然,既然涉及到了和Haskell这门语言的对比,那就难免会触及到Haskell这门语言的特性,例如高阶类型、无点风格编程,这些概念对于仅熟悉 JavaScript 语言的同学来讲,可能会比较陌生。
不过整篇文档在进行对比的时候,更多的是从TypeScript的角度出发,通过介绍TypeScript的特性来阐释和Haskell的不同,这也让我们能够提前了解到TypeScript的一些核心特性。
当然,很多同学读了下来,会有一种感觉。感觉即便是站在TypeScript的角度在介绍TypeScript特性,但有点走马观花,感觉每个特点就是简单的提了一下。这种感觉是正常的,因为这篇文章本来就不是正式介绍TypeScript某特性的文章,而是和Haskell的特性进行对比文章。
这篇文章所提到的所有TypeScript相关特性,如果你想深入了解,可以查阅更系统的 技术文档。对于函数式开发者来说,从 ES6+ 的现代 JavaScript 特性切入理解 TypeScript 会是一个不错的起点。