Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

面向函数式程序员的 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任意精度格式的整数。
Booleantruefalse
Symbol通常用作键的唯一值。
Null等价于单元类型。
Undefined也等价于单元类型。
Object类似于记录(records)。

更多详细信息请参阅 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;
    
    // 或者更精确地说:
    
    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();
// 等价于
Number.prototype.toExponential.call(1);

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

渐进类型

当 TypeScript 无法确定表达式的类型应该是什么时,它会使用 any 类型。与 Dynamic 相比,称 any 为类型是夸大其词了。它只是在出现的地方关闭了类型检查器。例如,您可以将任何值推入 any[] 而无需以任何方式标记该值:

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

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

ts
anys.map(anys[1]); // 哦不,"oh no" 不是一个函数

any 也具有传染性——如果您用一个类型为 any 的表达式初始化一个变量,那么该变量也具有 any 类型。

ts
let sepsis = anys[0] + anys[1]; // 这可能意味着任何事情

要在 TypeScript 产生 any 时获得错误,请在 tsconfig.json 中使用 "noImplicitAny": true"strict": true

结构类型

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

ts
// @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。(但是,类型别名在递归定义和类型参数方面的行为与接口不同。)

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 { // 这在 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
;
} }
Try

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

以下类型具有内置谓词:

类型谓词
stringtypeof s === "string"
numbertypeof n === "number"
biginttypeof m === "bigint"
booleantypeof b === "boolean"
symboltypeof g === "symbol"
undefinedtypeof undefined === "undefined"
functiontypeof f === "function"
arrayArray.isArray(a)
objecttypeof 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); // 错误:'string' 不能赋值给 '"left" | "right"'
Argument 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];
}

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

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 未被导出

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

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; // 错误

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

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

它还有一个特定的 ReadonlyArray<T> 类型,它移除了有副作用的数组方法,并阻止写入数组索引,同时为此类型提供了特殊的语法:

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

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

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

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

下一步

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