面向函数式程序员的 TypeScript
TypeScript 最初是为了将传统的面向对象类型引入 JavaScript,以便微软的程序员能够将传统的面向对象程序带到 Web 上。随着它的发展,TypeScript 的类型系统已经演变为能够对原生 JavaScript 开发者编写的代码进行建模。由此产生的类型系统强大、有趣,但也有些杂乱。
本篇介绍是为那些希望学习 TypeScript 的、有经验的 Haskell 或 ML 程序员设计的。它描述了 TypeScript 的类型系统与 Haskell 的类型系统的不同之处。它还描述了 TypeScript 类型系统因其对 JavaScript 代码的建模而产生的独特特性。
本篇介绍不涵盖面向对象编程。在实践中,TypeScript 中的面向对象程序与其他流行的具有面向对象特性的语言中的程序相似。
先决条件
在本介绍中,我假设您了解以下内容:
- 如何用 JavaScript 编程,掌握其精华部分。
- 一种 C 系语言的类型语法。
如果您需要学习 JavaScript 的精华部分,请阅读 JavaScript: The Good Parts。 如果您知道如何用一种具有大量可变性且没有太多其他特性的、按值调用的词法作用域语言编写程序,那么您也许可以跳过这本书。 R4RS Scheme 就是一个很好的例子。
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 | 类似于记录(records)。 |
TypeScript 为这些内置类型提供了相应的原始类型:
numberstringbigintbooleansymbolnullundefinedobject
其他重要的 TypeScript 类型
| 类型 | 说明 |
|---|---|
unknown | 顶类型。 |
never | 底类型。 |
| 对象字面量类型 | 例如 { property: Type } |
void | 用于没有记录返回值的函数。 |
T[] | 可变数组,也写作 Array<T>。 |
[T, T] | 元组,长度固定但可变。 |
(t: T) => U | 函数。 |
注意:
函数语法包含参数名称。这很难习惯!
tslet fst: (a: any, b: any) => any = (a, b) => a; // 或者更精确地说: 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 之间的区别来反映这一点。装箱类型很少需要,因为它们的方法返回原始值。
(1).toExponential();
// 等价于
Number.prototype.toExponential.call(1);注意,在数字字面量上调用方法需要将其括在括号中以帮助解析器。
渐进类型
当 TypeScript 无法确定表达式的类型应该是什么时,它会使用 any 类型。与 Dynamic 相比,称 any 为类型是夸大其词了。它只是在出现的地方关闭了类型检查器。例如,您可以将任何值推入 any[] 而无需以任何方式标记该值:
// 在 tsconfig.json 中设置 "noImplicitAny": false 时,anys: any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });Try并且您可以在任何地方使用类型为 any 的表达式:
anys.map(anys[1]); // 哦不,"oh no" 不是一个函数any 也具有传染性——如果您用一个类型为 any 的表达式初始化一个变量,那么该变量也具有 any 类型。
let sepsis = anys[0] + anys[1]; // 这可能意味着任何事情要在 TypeScript 产生 any 时获得错误,请在 tsconfig.json 中使用 "noImplicitAny": true 或 "strict": true。
结构类型
结构类型对大多数函数式程序员来说是一个熟悉的概念,尽管 Haskell 和大多数 ML 语言不是结构类型的。它的基本形式非常简单:
// @strict: false
let o = { x: "hi", extra: 1 }; // 好的
let o2: { x: string } = o; // 好的这里,对象字面量 { x: "hi", extra: 1 } 具有匹配的字面量类型 { x: string, extra: number }。该类型可以赋值给 { x: string },因为它具有所有必需的属性,并且这些属性具有可赋值的类型。额外的属性不会阻止赋值,它只是使其成为 { x: string } 的子类型。
命名类型只是给一个类型起了一个名字;就赋值兼容性而言,下面的类型别名 One 和接口类型 Two 之间没有区别。它们都有一个属性 p: string。(但是,类型别名在递归定义和类型参数方面的行为与接口不同。)
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 那样是判别联合。但是,您通常可以使用内置标签或其他属性来区分联合中的类型。
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;
}
}Trystring、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 还有交叉类型:
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };TryCombined 有两个属性 a 和 b,就像它们被写成一个对象字面量类型一样。在冲突的情况下,交叉和联合是递归的,所以 Conflicting.a: number & string。
单元类型
单元类型是恰好包含一个原始值的原始类型的子类型。例如,字符串 "foo" 具有类型 "foo"。由于 JavaScript 没有内置的枚举,通常使用一组众所周知的字符串来代替。字符串字面量类型的联合允许 TypeScript 对这种模式进行类型化:
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");Try在需要时,编译器会 拓宽 — 转换为超类型 — 将单元类型转换为原始类型,例如将 "foo" 转换为 string。这在使用可变性时会发生,这可能会妨碍可变变量的某些用途:
错误的产生过程如下:
"right": "right"s: string因为"right"在赋值给可变变量时拓宽为string。string不能赋值给"left" | "right"
您可以通过为 s 添加类型注解来解决这个问题,但这又会阻止将不是 "left" | "right" 类型的变量赋值给 s。
与 Haskell 相似的概念
上下文类型
TypeScript 有一些明显的地方可以推断类型,比如变量声明:
但它也会在您可能意想不到的几个其他地方推断类型,如果您使用过其他 C 语法语言的话:
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[]。
请注意,推断可以按任何顺序进行,但智能感知只能从左到右工作,因此 TypeScript 更倾向于将 map 声明为数组在前:
上下文类型也递归地作用于对象字面量,以及那些否则会被推断为 string 或 number 的单元类型。并且它可以从上下文中推断返回类型:
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERT STATE HERE";
});Tryo 的类型被确定为 { inference: string },因为:
- 声明初始化器由声明的类型进行上下文类型化:
{ inference: string }。 - 调用的返回类型使用上下文类型进行推断,因此编译器推断
T={ inference: string }。 - 箭头函数使用上下文类型来为其参数进行类型化,因此编译器将
o的类型指定为{ inference: string }。
并且在您键入时就会发生这种情况,因此在键入 o. 之后,您将获得属性 inference 以及您在实际程序中可能拥有的任何其他属性的补全。 总而言之,这个特性可以使 TypeScript 的推断看起来有点像统一类型推断引擎,但事实并非如此。
类型别名
类型别名仅仅是别名,就像 Haskell 中的 type 一样。编译器会尝试在源代码中使用别名的地方使用别名,但并不总是成功。
与 newtype 最接近的等价物是 标签交叉类型:
type FString = string & { __compileTimeOnly: any };FString 就像一个普通字符串,只是编译器认为它有一个名为 __compileTimeOnly 的属性,而这个属性实际上并不存在。这意味着 FString 仍然可以赋值给 string,但反过来则不行。
可区分的联合类型
与 data 最接近的等价物是带有判别属性的类型联合,在 TypeScript 中通常称为可区分的联合:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };与 Haskell 不同,标签或判别器只是每个对象类型中的一个属性。每个变体都有一个具有不同单元类型的相同属性。这仍然是一个普通的联合类型;开头的 | 是联合类型语法的可选部分。您可以使用普通的 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;
}
}Try注意,area 的返回类型被推断为 number,因为 TypeScript 知道该函数是完整的。如果某个变体没有被覆盖,area 的返回类型将是 number | undefined。
此外,与 Haskell 不同,公共属性会出现在任何联合中,因此您可以有效地区分联合的多个成员:
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}Try类型参数
与大多数 C 系语言一样,TypeScript 需要声明类型参数:
function liftArray<T>(t: T): Array<T> {
return [t];
}没有 case 要求,但类型参数通常使用单个大写字母。类型参数也可以被约束为某个类型,其行为有点像类型类约束:
function firstish<T extends { length: number }>(t1: T, t2: T): T {
return t1.length > t2.length ? t1 : t2;
}TypeScript 通常可以根据参数的类型从调用中推断出类型参数,因此通常不需要类型参数。
由于 TypeScript 是结构类型的,它不像名义类型系统那样需要那么多类型参数。具体来说,它们不需要使函数成为多态函数。类型参数只应用于 传播 类型信息,例如将参数约束为相同类型:
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>) {}无点编程
无点编程 — 大量使用柯里化和函数组合 — 在 JavaScript 中是可能的,但可能很冗长。 在 TypeScript 中,类型推断对于无点程序常常失败,因此您最终会指定类型参数而不是值参数。结果会非常冗长,以至于通常最好避免无点编程。
模块系统
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 中,可变性是默认的,尽管它允许使用 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; // 错误它还附带了一个映射类型 Readonly<T>,它使所有属性变为 readonly:
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // 错误它还有一个特定的 ReadonlyArray<T> 类型,它移除了有副作用的数组方法,并阻止写入数组索引,同时为此类型提供了特殊的语法:
let a: ReadonlyArray<number> = [1, 2, 3];
let b: readonly number[] = [1, 2, 3];
a.push(102); // 错误
b[0] = 101; // 错误您还可以使用 const 断言,它适用于数组和对象字面量:
let a = [1, 2, 3] as const;
a.push(102); // 错误
a[0] = 101; // 错误然而,这些选项都不是默认的,因此它们在 TypeScript 代码中没有被一致地使用。
下一步
本文档是对日常代码中使用的语法和类型的高级概述。从这里您应该: