面向函数式程序员的 TypeScript

TypeScript 的诞生是为了将传统的面向对象类型引入 JavaScript,以便 Microsoft 的程序员可以将传统的面向对象程序引入 Web。随着它的发展,TypeScript 的类型系统已经发展到可以为原生 JavaScript 编写的代码建模。由此产生的系统功能强大、有趣且混乱。

¥TypeScript began its life as an attempt to bring traditional object-oriented types to JavaScript so that the programmers at Microsoft could bring traditional object-oriented programs to the web. As it has developed, TypeScript’s type system has evolved to model code written by native JavaScripters. The resulting system is powerful, interesting and messy.

本简介专为想要学习 TypeScript 的在职 Haskell 或 ML 程序员而设计。它描述了 TypeScript 的类型系统与 Haskell 的类型系统有何不同。它还描述了 TypeScript 类型系统的独特功能,这些功能源自其对 JavaScript 代码的建模。

¥This introduction is designed for working Haskell or ML programmers who want to learn TypeScript. It describes how the type system of TypeScript differs from Haskell’s type system. It also describes unique features of TypeScript’s type system that arise from its modelling of JavaScript code.

本简介不包括面向对象编程。实际上,TypeScript 中的面向对象程序类似于其他具有 OO 特性的流行语言中的程序。

¥This introduction does not cover object-oriented programming. In practice, object-oriented programs in TypeScript are similar to those in other popular languages with OO features.

先决条件

¥Prerequisites

在本介绍中,我假设你了解以下内容:

¥In this introduction, I assume you know the following:

  • 如何用 JavaScript 编程,好的部分。

    ¥How to program in JavaScript, the good parts.

  • C 语言后裔的类型语法。

    ¥Type syntax of a C-descended language.

如果你需要学习 JavaScript 好的部分,请阅读 JavaScript:好的部分。如果你知道如何使用具有大量可变性的按值调用词法范围语言编写程序,则可以跳过这本书。R4RS 方案 就是一个很好的例子。

¥If you need to learn the good parts of JavaScript, read JavaScript: The Good Parts. You may be able to skip the book if you know how to write programs in a call-by-value lexically scoped language with lots of mutability and not much else. R4RS Scheme is a good example.

C++ 编程语言 是学习 C 风格类型语法的好地方。与 C++ 不同,TypeScript 使用后缀类型,例如:x: string 而不是 string x

¥The C++ Programming Language is a good place to learn about C-style type syntax. Unlike C++, TypeScript uses postfix types, like so: x: string instead of string x.

Haskell 中没有的概念

¥Concepts not in Haskell

内置类型

¥Built-in types

JavaScript 定义了 8 种内置类型:

¥JavaScript defines 8 built-in types:

类型 解释
Number 双精度 IEEE 754 浮点数。
String 一个不可变的 UTF-16 字符串。
BigInt 任意精度格式的整数。
Boolean truefalse
Symbol 通常用作键的唯一值。
Null 相当于单元类型。
Undefined 也相当于单元类型。
Object 类似于记录。

有关详细信息,请参阅 MDN 页面

¥See the MDN page for more detail.

TypeScript 为内置类型提供了相应的基础类型:

¥TypeScript has corresponding primitive types for the built-in types:

  • number

  • string

  • bigint

  • boolean

  • symbol

  • null

  • undefined

  • object

其他重要的 TypeScript 类型

¥Other important TypeScript types

类型 解释
unknown 顶层类型。
never 底层类型。
对象字面量 例如 { property: Type }
void 对于没有记录返回值的函数
T[] 可变数组,也写成 Array<T>
[T, T] 元组,它们是固定长度但可变的
(t: T) => U 函数

注意:

¥Notes:

  1. 函数语法包括参数名称。这很难习惯!

    ¥Function syntax includes parameter names. This is pretty hard to get used to!

    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. 对象字面量类型语法与对象字面量值语法非常相似:

    ¥Object literal type syntax closely mirrors object literal value syntax:

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

    ¥[T, T] is a subtype of T[]. This is different than Haskell, where tuples are not related to lists.

盒装类型

¥Boxed types

JavaScript 具有基本类型的盒装等价物,其中包含程序员与这些类型相关联的方法。TypeScript 反映了这一点,例如,基础类型 number 和装箱类型 Number 之间的差异。很少需要盒装类型,因为它们的方法返回基础类型。

¥JavaScript has boxed equivalents of primitive types that contain the methods that programmers associate with those types. TypeScript reflects this with, for example, the difference between the primitive type number and the boxed type Number. The boxed types are rarely needed, since their methods return primitives.

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

请注意,在数字字面上调用方法需要将其放在括号中以帮助解析器。

¥Note that calling a method on a numeric literal requires it to be in parentheses to aid the parser.

渐进类型

¥Gradual typing

TypeScript 在无法确定表达式的类型时使用类型 any。与 Dynamic 相比,将 any 称为类型是言过其实了。它只是在出现的任何地方关闭类型检查器。例如,你可以将任何值压入 any[] 而无需以任何方式标记该值:

¥TypeScript uses the type any whenever it can’t tell what the type of an expression should be. Compared to Dynamic, calling any a type is an overstatement. It just turns off the type checker wherever it appears. For example, you can push any value into an any[] without marking the value in any way:

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

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

¥And you can use an expression of type any anywhere:

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

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

¥any is contagious, too — if you initialize a variable with an expression of type any, the variable has type any too.

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

要在 TypeScript 生成 any 时出现错误,请在 tsconfig.json 中使用 "noImplicitAny": true"strict": true

¥To get an error when TypeScript produces an any, use "noImplicitAny": true, or "strict": true in tsconfig.json.

结构类型

¥Structural typing

结构类型是大多数函数式程序员熟悉的概念,尽管 Haskell 和大多数 ML 都不是结构类型的。它的基本形式非常简单:

¥Structural typing is a familiar concept to most functional programmers, although Haskell and most MLs are not structurally typed. Its basic form is pretty simple:

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

¥Here, the object literal { x: "hi", extra: 1 } has a matching literal type { x: string, extra: number }. That type is assignable to { x: string } since it has all the required properties and those properties have assignable types. The extra property doesn’t prevent assignment, it just makes it a subtype of { x: string }.

命名类型只是给一个类型起一个名字;出于可赋值性目的,类型别名 One 和下面的接口类型 Two 之间没有区别。他们都有属性 p: string。(但是,在递归定义和类型参数方面,类型别名的行为不同于接口。)

¥Named types just give a name to a type; for assignability purposes there’s no difference between the type alias One and the interface type Two below. They both have a property p: string. (Type aliases behave differently from interfaces with respect to recursive definitions and type parameters, however.)

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

联合

¥Unions

在 TypeScript 中,联合类型是未标记的。换句话说,它们不是像 Haskell 中的 data 这样的可区分联合。但是,你通常可以使用内置标签或其他属性来区分联合中的类型。

¥In TypeScript, union types are untagged. In other words, they are not discriminated unions like data in Haskell. However, you can often discriminate types in a union using built-in tags or other properties.

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

stringArrayFunction 有内置类型谓词,方便为 else 分支留下对象类型。但是,有可能生成在运行时难以区分的联合。对于新代码,最好只构建可区分的联合。

¥string, Array and Function have built-in type predicates, conveniently leaving the object type for the else branch. It is possible, however, to generate unions that are difficult to differentiate at runtime. For new code, it’s best to build only discriminated unions.

以下类型具有内置谓词:

¥The following types have built-in predicates:

类型 谓词
字符串 typeof s === "string"
数字 typeof n === "number"
大整数 typeof m === "bigint"
布尔值 typeof b === "boolean"
符号 typeof g === "symbol"
undefined typeof undefined === "undefined"
函数 typeof f === "function"
数组 Array.isArray(a)
对象 typeof o === "object"

请注意,函数和数组在运行时是对象,但有自己的谓词。

¥Note that functions and arrays are objects at runtime, but have their own predicates.

交叉

¥Intersections

除了联合,TypeScript 还有交叉:

¥In addition to unions, TypeScript also has intersections:

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

Combined 有两个属性,ab,就像它们被写成一个对象字面量类型一样。交叉和联合在冲突的情况下是递归的,所以 Conflicting.a: number & string

¥Combined has two properties, a and b, just as if they had been written as one object literal type. Intersection and union are recursive in case of conflicts, so Conflicting.a: number & string.

单元类型

¥Unit types

单元类型是仅包含一个基础值的基础类型的子类型。例如,字符串 "foo" 的类型为 "foo"。由于 JavaScript 没有内置枚举,因此通常使用一组众所周知的字符串来代替。字符串字面类型的联合允许 TypeScript 键入此模式:

¥Unit types are subtypes of primitive types that contain exactly one primitive value. For example, the string "foo" has the type "foo". Since JavaScript has no built-in enums, it is common to use a set of well-known strings instead. Unions of string literal types allow TypeScript to type this pattern:

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

当需要时,编译器会扩展(转换为超类型)单元类型为基本类型,例如 "foo"string。当使用可变性时会发生这种情况,这会妨碍可变变量的某些使用:

¥When needed, the compiler widens — converts to a supertype — the unit type to the primitive type, such as "foo" to string. This happens when using mutability, which can hamper some uses of mutable variables:

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

以下是错误的发生方式:

¥Here’s how the error happens:

  • "right": "right"

  • s: string 因为 "right" 在分配给可变变量时扩大到 string

    ¥s: string because "right" widens to string on assignment to a mutable variable.

  • string 不可分配给 "left" | "right"

    ¥string is not assignable to "left" | "right"

你可以使用 s 的类型注释解决此问题,但这反过来会阻止将非 "left" | "right" 类型的变量赋值给 s

¥You can work around this with a type annotation for s, but that in turn prevents assignments to s of variables that are not of type "left" | "right".

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

类似于 Haskell 的概念

¥Concepts similar to Haskell

上下文类型

¥Contextual typing

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

¥TypeScript has some obvious places where it can infer types, like variable declarations:

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

但它还在其他一些地方推断出类型,如果你使用过其他 C 语法语言,你可能不会想到这些地方:

¥But it also infers types in a few other places that you may not expect if you’ve worked with other C-syntax languages:

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 也是如此,尽管在调用之前尚未推断出 TU。实际上,在使用 [1,2,3] 推断 T=number 之后,n => n.toString() 的返回类型被用来推断 U=string,导致 sns 的类型为 string[]

¥Here, n: number in this example also, despite the fact that T and U have not been inferred before the call. In fact, after [1,2,3] has been used to infer T=number, the return type of n => n.toString() is used to infer U=string, causing sns to have the type string[].

请注意,推断可以按任何顺序进行,但智能感知只能从左到右进行,因此 TypeScript 更喜欢先用数组声明 map

¥Note that inference will work in any order, but intellisense will only work left-to-right, so TypeScript prefers to declare map with the array first:

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

上下文类型也可以通过对象字面量递归地工作,并且可以在否则会被推断为 stringnumber 的单元类型上工作。它可以从上下文推断返回类型:

¥Contextual typing also works recursively through object literals, and on unit types that would otherwise be inferred as string or number. And it can infer return types from context:

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 } 因为

¥The type of o is determined to be { inference: string } because

  1. 声明初始值设定项根据声明的类型进行上下文类型化:{ inference: string }

    ¥Declaration initializers are contextually typed by the declaration’s type: { inference: string }.

  2. 调用的返回类型使用上下文类型进行推断,因此编译器推断 T={ inference: string }

    ¥The return type of a call uses the contextual type for inferences, so the compiler infers that T={ inference: string }.

  3. 箭头函数使用上下文类型来键入它们的参数,因此编译器给出了 o: { inference: string }

    ¥Arrow functions use the contextual type to type their parameters, so the compiler gives o: { inference: string }.

它会在你键入时执行此操作,因此在键入 o. 后,你将获得属性 inference 的完成,以及你在真实程序中拥有的任何其他属性。总而言之,这个特性可以让 TypeScript 的推断看起来有点像一个统一的类型推断引擎,但事实并非如此。

¥And it does so while you are typing, so that after typing o., you get completions for the property inference, along with any other properties you’d have in a real program. Altogether, this feature can make TypeScript’s inference look a bit like a unifying type inference engine, but it is not.

类型别名

¥Type aliases

类型别名只是别名,就像 Haskell 中的 type 一样。编译器将尝试在源代码中使用别名的任何地方使用它,但并不总是成功。

¥Type aliases are mere aliases, just like type in Haskell. The compiler will attempt to use the alias name wherever it was used in the source code, but does not always succeed.

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

newtype 最接近的等效项是标记的交叉:

¥The closest equivalent to newtype is a tagged intersection:

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

FString 就像一个普通的字符串,只是编译器认为它有一个名为 __compileTimeOnly 的属性,但实际上并不存在。这意味着 FString 仍然可以分配给 string,但反之则不行。

¥An FString is just like a normal string, except that the compiler thinks it has a property named __compileTimeOnly that doesn’t actually exist. This means that FString can still be assigned to string, but not the other way round.

判别联合

¥Discriminated Unions

data 最接近的等效项是具有判别属性的类型联合,在 TypeScript 中通常称为判别联合:

¥The closest equivalent to data is a union of types with discriminant properties, normally called discriminated unions in TypeScript:

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

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

¥Unlike Haskell, the tag, or discriminant, is just a property in each object type. Each variant has an identical property with a different unit type. This is still a normal union type; the leading | is an optional part of the union type syntax. You can discriminate the members of the union using normal JavaScript code:

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

¥Note that the return type of area is inferred to be number because TypeScript knows the function is total. If some variant is not covered, the return type of area will be number | undefined instead.

此外,与 Haskell 不同的是,公共属性出现在任何联合中,因此你可以有效地区分联合的多个成员:

¥Also, unlike Haskell, common properties show up in any union, so you can usefully discriminate multiple members of the union:

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

类型参数

¥Type Parameters

与大多数 C 语言后裔一样,TypeScript 需要声明类型参数:

¥Like most C-descended languages, TypeScript requires declaration of type parameters:

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

没有大小写要求,但类型参数通常是单个大写字母。类型参数也可以被约束为一个类型,其行为有点像类型类约束:

¥There is no case requirement, but type parameters are conventionally single uppercase letters. Type parameters can also be constrained to a type, which behaves a bit like type class constraints:

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

TypeScript 通常可以根据参数的类型从调用中推断类型参数,因此通常不需要类型参数。

¥TypeScript can usually infer type arguments from a call based on the type of the arguments, so type arguments are usually not needed.

因为 TypeScript 是结构化的,所以它不像名义系统那样需要类型参数。具体来说,不需要它们来使函数多态。类型参数应该只用于传播类型信息,例如约束参数为同一类型:

¥Because TypeScript is structural, it doesn’t need type parameters as much as nominal systems. Specifically, they are not needed to make a function polymorphic. Type parameters should only be used to propagate type information, such as constraining parameters to be the same type:

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

第一个 length,T 不是必须的;请注意,它只被引用一次,所以它没有被用来约束返回值或其他参数的类型。

¥In the first length, T is not necessary; notice that it’s only referenced once, so it’s not being used to constrain the type of the return value or other parameters.

更高等的类型

¥Higher-kinded types

TypeScript 没有更高等的类型,所以以下是不合法的:

¥TypeScript does not have higher kinded types, so the following is not legal:

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

无点编程

¥Point-free programming

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

¥Point-free programming — heavy use of currying and function composition — is possible in JavaScript, but can be verbose. In TypeScript, type inference often fails for point-free programs, so you’ll end up specifying type parameters instead of value parameters. The result is so verbose that it’s usually better to avoid point-free programming.

模块系统

¥Module system

JavaScript 的现代模块语法有点像 Haskell 的,除了任何带有 importexport 的文件都是隐式模块:

¥JavaScript’s modern module syntax is a bit like Haskell’s, except that any file with import or export is implicitly a module:

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

你还可以导入 commonjs 模块 - 使用 Node.js 模块系统编写的模块:

¥You can also import commonjs modules — modules written using node.js’ module system:

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

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

¥You can export with an export list:

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

或者通过单独标记每个导出:

¥Or by marking each export individually:

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

后一种风格更常见,但两者都是允许的,即使在同一个文件中也是如此。

¥The latter style is more common but both are allowed, even in the same file.

readonlyconst

¥readonly and const

在 JavaScript 中,可变性是默认值,尽管它允许使用 const 的变量声明来声明引用是不可变的。引用对象仍然是可变的:

¥In JavaScript, mutability is the default, although it allows variable declarations with const to declare that the reference is immutable. The referent is still mutable:

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

TypeScript 还有一个 readonly 属性修饰符。

¥TypeScript additionally has a readonly modifier for properties.

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

它还附带一个映射类型 Readonly<T>,使所有属性成为 readonly

¥It also ships with a mapped type Readonly<T> that makes all properties readonly:

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

它有一个特定的 ReadonlyArray<T> 类型,可以删除副作用方法并防止写入数组的索引,以及这种类型的特殊语法:

¥And it has a specific ReadonlyArray<T> type that removes side-affecting methods and prevents writing to indices of the array, as well as special syntax for this type:

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

你还可以使用常量断言,它对数组和对象字面进行操作:

¥You can also use a const-assertion, which operates on arrays and object literals:

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

但是,这些选项都不是默认选项,因此它们在 TypeScript 代码中的使用并不一致。

¥However, none of these options are the default, so they are not consistently used in TypeScript code.

下一步

¥Next Steps

本文档是对你将在日常代码中使用的语法和类型的高级概述。从这里你应该:

¥This doc is a high level overview of the syntax and types you would use in everyday code. From here you should: