面向函数式程序员的 TypeScript

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

本介绍面向希望学习 TypeScript 的 Haskell 或 ML 程序员。它描述了 TypeScript 的类型系统与 Haskell 的类型系统的区别。它还描述了 TypeScript 类型系统中源于其对 JavaScript 代码建模的独特功能。

本介绍不涉及面向对象编程。实际上,TypeScript 中的面向对象程序与其他具有 OO 特性的流行语言中的面向对象程序类似。

先决条件

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

  • 如何在 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 truefalse
Symbol 一个通常用作键的唯一值。
Null 等同于单元类型。
Undefined 也等同于单元类型。
Object 类似于记录。

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

TypeScript 为内置类型提供了相应的原始类型

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

其他重要的 TypeScript 类型

类型 解释
unknown 顶层类型。
never 底层类型。
对象字面量 例如 { 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

stringArrayFunction 具有内置类型谓词,方便地将对象类型留给 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"
函数 typeof f === "function"
数组 Array.isArray(a)
object typeof o === "object"

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

交集

除了联合类型,TypeScript 还具有交集类型

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

Combined 具有两个属性,ab,就像它们被写成一个对象字面量类型一样。交集和联合类型在发生冲突时是递归的,因此 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 也是如此,尽管 TU 在调用之前还没有被推断出来。实际上,在 [1,2,3] 被用来推断 T=number 之后,n => n.toString() 的返回值类型被用来推断 U=string,导致 sns 的类型为 string[]

注意,推断可以按任何顺序进行,但智能提示只能从左到右工作,因此 TypeScript 倾向于先声明数组的 map 方法

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

上下文类型推断也可以递归地应用于对象字面量,以及那些原本会被推断为 stringnumber 的单元类型。它还可以从上下文中推断返回值类型

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 的,除了任何包含 importexport 的文件隐式地是一个模块。

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() { }

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

readonlyconst

在 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

您也可以使用常量断言,它适用于数组和对象字面量。

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

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

下一步

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

TypeScript 文档是一个开源项目。帮助我们改进这些页面 通过发送拉取请求

此页面的贡献者
OTOrta Therox (15)
MFMartin Fischer (1)
JRSDSJonas Raoni Soares da Silva (1)
RCRyan Cavanaugh (1)
Hhuying (1)
10+

上次更新时间:2024 年 3 月 21 日