面向函数式程序员的 TypeScript

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)。

详见 MDN 页面。

TypeScript 为这些内置类型提供了对应的原始类型:

  • number
  • string
  • bigint
  • boolean
  • symbol
  • null
  • undefined
  • object

其他重要的 TypeScript 类型

类型 说明
unknown 顶类型(top type)。
never 底类型(bottom type)。
对象字面量 例如 { property: Type }
void 用于没有文档化返回值的函数
T[] 可变数组,也可写作 Array<T>
[T, T] 元组,长度固定但可变
(t: T) => U 函数

注意:

  1. 函数语法包含参数名。这相当难以适应!

    ts
    let fst: (a: any, b: any) => any = (a, b) => a;
    // or more precisely:
    let fst: <T, U>(a: T, b: U) => T = (a, b) => a;
  2. 对象字面量类型语法与对象字面量值语法非常相似:

    ts
    let o: { n: number; xs: object[] } = { n: 1, xs: [] };
  3. [T, T] 是 T[] 的子类型。这与 Haskell 不同,在 Haskell 中元组与列表没有关系。

装箱类型

JavaScript 有原始类型的装箱对应物,它们包含程序员与这些类型关联的方法。TypeScript 通过例如原始类型 number 与装箱类型 Number 之间的区别来反映这一点。装箱类型很少需要,因为它们的方法返回原始值。

ts
(1).toExponential();
// equivalent to
Number.prototype.toExponential.call(1);

注意,在数字字面量上调用方法时需要用括号括起来,以方便解析器处理。

渐进式类型

当 TypeScript 无法判断表达式的类型时,会使用 any 类型。与 Dynamic 相比,把 any 称为一种类型是夸大其词。它只是在出现的地方关闭类型检查器。例如,你可以把任何值推入 any[],而无需对该值做任何标记:

ts
// with "noImplicitAny": false in tsconfig.json, anys: any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });
Try

而且你可以在任何地方使用类型为 any 的表达式:

ts
anys.map(anys[1]); // oh no, "oh no" is not a function

any 还具有传染性——如果你用类型为 any 的表达式初始化变量,该变量也会具有 any 类型。

ts
let sepsis = anys[0] + anys[1]; // this could mean anything

要在 TypeScript 产生 any 时报错,请在 tsconfig.json 中使用 "noImplicitAny": true 或 "strict": true。

结构化类型

对大多数函数式程序员来说,结构化类型是一个熟悉的概念,尽管 Haskell 和大多数 ML 语言并不是结构化类型的。其基本形式相当简单:

ts
// @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 } 的子类型。

具名类型只是给类型起个名字;就可赋值性而言,下面的类型别名 One 和接口类型 Two 没有区别。它们都有属性 p: string。(不过在递归定义和类型参数方面,类型别名的行为与接口不同。)

ts
type One = { p: string };
interface Two {
p: string;
}
class Three {
p = "Hello";
}
 
let x: One = { p: "hi" };
let two: Two = x;
two = new Three();
Try

联合类型

在 TypeScript 中,联合类型是无标签的。换句话说,它们不像 Haskell 中的 data 那样是可辨识联合。不过,你常常可以利用内置标签或其他属性来区分联合中的类型。

ts
function start(
arg: string | string[] | (() => string) | { s: string }
): string {
// this is super common in 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 {
// finally, just convert a string to another string
return s;
}
}
Try

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 还有交叉类型:

ts
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };
Try

Combined 有两个属性 a 和 b,就像它们被写成一个对象字面量类型一样。发生冲突时,交叉和联合会递归处理,因此 Conflicting.a: number & string。

单元类型

单元类型是只包含一个原始值的原始类型的子类型。例如,字符串 "foo" 的类型是 "foo"。由于 JavaScript 没有内置的枚举,常用一组众所周知的字符串来代替。字符串字面量类型的联合让 TypeScript 能为这种模式指定类型:

ts
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");
Try

需要时,编译器会将该单元类型拓宽——转换为超类型——为原始类型,例如把 "foo" 拓宽为 string。这在使用可变性时会发生,可能会妨碍可变变量的某些用法:

ts
let s = "right";
pad("hi", 10, s); // error: 'string' is not assignable to '"left" | "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"'.
Try

错误是这样发生的:

  • "right": "right"
  • s: string,因为 "right" 在赋值给可变变量时会拓宽为 string。
  • string 不能赋值给 "left" | "right"

你可以为 s 添加类型注解来绕过这一点,但这反过来又会阻止把类型不为 "left" | "right" 的变量赋值给 s。

ts
let s: "left" | "right" = "right";
pad("hi", 10, s);
Try

与 Haskell 类似的概念

上下文类型

TypeScript 有一些明显可以推断类型的地方,例如变量声明:

ts
let s = "I'm a string!";
Try

但它在另外一些地方也会推断类型,如果你用过其他 C 语法风格的语言,这些地方可能出乎你的意料:

ts
declare function map<T, U>(f: (t: T) => U, ts: T[]): U[];
let sns = map((n) => n.toString(), [1, 2, 3]);
Try

在这个例子中,n: number 也是如此,尽管在调用之前并没有推断出 T 和 U。事实上,在用 [1,2,3] 推断出 T=number 之后,又用 n => n.toString() 的返回类型推断出 U=string,从而使 sns 的类型为 string[]。

请注意,推断可以按任意顺序进行,但 IntelliSense 只能从左到右工作,因此 TypeScript 更倾向于把数组作为 map 的第一个参数来声明:

ts
declare function map<T, U>(ts: T[], f: (t: T) => U): U[];
Try

上下文类型也会递归地作用于对象字面量,以及那些否则会被推断为 string 或 number 的单元类型。它还能从上下文推断返回类型:

ts
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERT STATE HERE";
});
Try

o 的类型被确定为 { inference: string },因为

  1. 声明的初始化表达式由声明的类型进行上下文类型化:{ inference: string }。
  2. 调用的返回类型把上下文类型用于推断,因此编译器推断出 T={ inference: string }。
  3. 箭头函数使用上下文类型来为其参数标注类型,因此编译器给出 o: { inference: string }。

而且它是在你输入的过程中完成的,因此输入 o. 之后,你就能得到属性 inference 的补全,以及真实程序中会有的其他属性。总的来说,这一特性会让 TypeScript 的推断看起来有点像统一的类型推断引擎,但它并不是。

类型别名

类型别名只是别名,就像 Haskell 中的 type 一样。编译器会尝试在源代码中使用该别名的地方都沿用别名名称,但并不总能成功。

ts
type Size = [number, number];
let x: Size = [101.1, 999.9];
Try

与 newtype 最接近的等价物是带标签的交叉类型:

ts
type FString = string & { __compileTimeOnly: any };

FString 就像普通字符串一样,只不过编译器认为它有一个名为 __compileTimeOnly 的属性,而该属性实际上并不存在。这意味着 FString 仍可以赋值给 string,但反过来则不行。

可辨识联合

与 data 最接近的等价物是带判别属性的类型联合,在 TypeScript 中通常称为可辨识联合:

ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };

与 Haskell 不同,标签(或称判别式)只是每个对象类型中的一个属性。每个变体都有一个相同的属性,但取不同的单元类型。这仍然是一个普通的联合类型;开头的 | 是联合类型语法中可选的部分。你可以用普通的 JavaScript 代码来区分联合中的成员:

ts
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;
}
}
Try

请注意,area 的返回类型被推断为 number,因为 TypeScript 知道该函数是全函数。如果某个变体没有被覆盖,area 的返回类型则会是 number | undefined。

此外,与 Haskell 不同,公共属性在联合类型中同样会出现,因此你可以方便地一次区分联合中的多个成员:

ts
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}
Try

类型参数

与大多数源自 C 的语言一样,TypeScript 要求先声明类型参数:

ts
function liftArray<T>(t: T): Array<T> {
return [t];
}

对大小写没有要求,但按照惯例,类型参数是单个大写字母。类型参数还可以约束到某个类型,其行为有点类似类型类约束:

ts
function firstish<T extends { length: number }>(t1: T, t2: T): T {
return t1.length > t2.length ? t1 : t2;
}

TypeScript 通常能根据参数的类型,从调用中推断出类型实参,因此通常不需要写出类型实参。

由于 TypeScript 是结构化类型系统,它不像名义类型系统那样需要类型参数。具体来说,要让函数具备多态性并不需要类型参数。类型参数只应用于传递类型信息,例如约束多个参数为同一类型:

ts
function length<T extends ArrayLike<unknown>>(t: T): number {}
function length(t: ArrayLike<unknown>): number {}

在第一个 length 中,T 并非必需;注意它只被引用了一次,因此并没有用来约束返回值或其他参数的类型。

高阶类型

TypeScript 没有高阶类型,因此下面的写法不合法:

ts
function length<T extends ArrayLike<unknown>, U>(m: T<U>) {}

无点编程

无点编程——大量使用柯里化和函数组合——在 JavaScript 中是可行的,但可能很冗长。在 TypeScript 中,无点程序的类型推断常常会失败,因此你最终不得不指定类型参数而不是值参数。结果会非常冗长,所以通常最好避免无点编程。

模块系统

JavaScript 的现代模块语法与 Haskell 有点类似,区别在于任何含有 import 或 export 的文件都隐式地是一个模块:

ts
import { value, Type } from "npm-package";
import { other, Types } from "./local-package";
import * as prefix from "../lib/third-package";

你也可以导入 commonjs 模块——即使用 node.js 的模块系统编写的模块:

ts
import f = require("single-function-package");

你可以用导出列表来导出:

ts
export { f };
function f() {
return g();
}
function g() {} // g is not exported

或者逐个标记每个导出:

ts
export function f() { return g() }
function g() { }

后一种风格更常见,但两种都是允许的,甚至可以在同一个文件中混用。

readonly 与 const

在 JavaScript 中,可变性是默认的,不过它允许用 const 声明变量,以表明该引用不可变。被引用的对象仍然是可变的:

js
const a = [1, 2, 3];
a.push(102); // ):
a[0] = 101; // D:

TypeScript 还为属性提供了 readonly 修饰符。

ts
interface Rx {
readonly x: number;
}
let rx: Rx = { x: 1 };
rx.x = 12; // error

它还内置了映射类型 Readonly<T>,可让所有属性都变成 readonly:

ts
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // error

它还有一个专门的 ReadonlyArray<T> 类型,该类型去掉了带副作用的方法,并禁止写入数组的索引,此外还有针对该类型的特殊语法:

ts
let a: ReadonlyArray<number> = [1, 2, 3];
let b: readonly number[] = [1, 2, 3];
a.push(102); // error
b[0] = 101; // error

你也可以使用 const 断言,它作用于数组和对象字面量:

ts
let a = [1, 2, 3] as const;
a.push(102); // error
a[0] = 101; // error

然而,这些选项都不是默认行为,因此在 TypeScript 代码中并没有被一致地使用。

后续步骤

本文对你日常代码中会用到的语法和类型做了高层概览。接下来,你可以:

TypeScript 文档是一个开源项目。欢迎发送 Pull Request 帮助我们改进这些页面 ❤

最后更新于:2026 年 10 月 23 日