面向函数式程序员的 TypeScript

TypeScript 最初的诞生是为了将传统的面向对象类型引入 JavaScript,以便微软的程序员能够将传统的面向对象程序带到网页上。随着发展,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 中的面向对象程序与其他具有面向对象特性的流行语言类似。

🌐 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 编程,好的部分。
  • C 语言后裔的类型语法。

如果你需要学习 JavaScript 的精华部分,可以阅读 JavaScript: The Good Parts。 如果你已经知道如何在一个具有大量可变性、按值调用、词法作用域的语言中编写程序,而没有太多其他特性,你可能可以跳过这本书。 R4RS Scheme 是一个很好的例子。

《C++ 编程语言》 是学习 C 风格类型语法的好地方。与 C++ 不同,TypeScript 使用后缀类型,像这样:x: string 而不是 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 页面

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. 函数语法包括参数名称。这真的很难适应!

    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 中,元组与列表没有关联。

盒装类型

🌐 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

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

当 TypeScript 在 tsconfig.json 中生成 any、使用 "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 分支。然而,也有可能生成在运行时难以区分的联合类型。对于新代码,最好只构建区分型联合类型。

以下类型具有内置谓词:

🌐 The following types have built-in predicates:

类型 谓词
字符串 typeof s === "string"
数字 typeof n === "number"
大整数 typeof m === "bigint"
布尔值 typeof b === "boolean"
符号 typeof g === "symbol"
未定义 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

单元类型

🌐 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
  • string 不能赋值给 "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 } 进行上下文类型推断。
  2. 调用的返回类型使用上下文类型进行推断,因此编译器推断出 T={ inference: string }
  3. 箭头函数使用上下文类型来为其参数指定类型,因此编译器会给出 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

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

🌐 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: