学习使用类型语言编写类型,并通过你现有的JavaScript知识更快地掌握TypeScript。
类型本身就是一种复杂的语言
我以前认为typescript就只是在JavaScript之上添加了类型注释。在这种思维模式下,我经常发现编写正确的类型很棘手,让人望而生畏,以至于某种程度上它们妨碍了我构建实际的应用程序,而且经常导致我去使用any
。而使用了any
,我失去了所有的类型安全。
实际上,你可以使用类型,让类型变得非常复杂。在使用了一段时间的typescript之后,我觉得typescript语言实际上由两种子语言构成——一种是JavaScript,另外一种是类型语言:
- 对于JavaScript语言来讲,世界是由JavaScript values组成的
- 对于类型语言来讲,世界是由类型组成的
当我们编写typescript代码时,我们不停地在两个世界来回跳舞:我们在类型世界中创建类型,并使用类型注释(或者由编译器隐式地推断它们)在我们的JavaScript世界中“召唤”它们;我们也可以从另一个方向前进:在JavaScript变量、属性上使用typescript的typeof
关键字来检索对应的类型(这里不是指JavaScript提供的typeof
关键字来检查运行时的类型)。
JavaScript的语言表现力很强,而类型语言也是如此。事实上,强表现力的类型语言已经被证明是图灵完备的。
这里我不对图灵完备是好是坏做出任何价值判断,也不知道它甚至是出于设计还是出于意外(事实上,很多时候,图灵完备性是被意外实现的)。我的观点是,类型语言本身虽然看起来无害,但是肯定是强大的,高性能的,在编译时可以进行任意的计算。
当我开始把typescript中的类型语言当作一种成熟的编程语言时,我意识到它甚至具有函数式编程语言的一些特征。
- 使用递归而不是迭代
- 在typescript4.5中我们可以用尾部调用优化递归(在某些程度上)
- 类型(数据)是不可变的
在本文中,我们将通过与JavaScript的对比来学习typescript中的类型语言,这样你就可以利用现有的JavaScript知识来更快地掌握typescript。
变量声明
在JavaScript中,世界是由JavaScript值组成的,我们使用关键字var
、let
、const
来声明变量以此引用值。比如说:
const obj = { name: 'foo' }
复制代码
在类型语言中,世界是由类型组成的,我们使用关键字type
和interface
来声明类型变量。比如说:
type Obj = { name: string }
复制代码
注:对于“类型变量”,更准确的名称是类型同义词或者类型别名。我使用“类型变量”这个词来类比JavaScript变量如何引用一个值。
不过这并不是一个完美的类比,类型别名并没有创造或引入一个新的类型——它们只是现有类型的一个新名称。但我希望可以通过这个类比,使得解释类型语言的概念变得更加容易。
类型和值是非常相关的。一个类型,其核心是代表可能的值的集合,以及可以对这些值进行的有效操作。有时这个集合是有限的,例如type Name = 'foo' | 'bar'
,更多时候这个集合是无限的,例如type Age = number
。在typescript中,我们整合了类型和值,并使它们在一起工作,以确保运行时的值与编译时的类型相符合。
本地变量声明
我们讲了如何在类型语言中创建类型变量。然而,类型变量默认有一个全局作用范围。为了创建一个本地类型变量,我们可以在类型语言中使用infer
关键字。
type A = 'foo'; // global scope
type B = A extends infer C ? (
C extends 'foo' ? true : false // 只有在这个表达式中,C代表A
) : never;
复制代码
尽管这种创建作用域变量的特殊方式对JavaScript开发者来说可能很奇怪,但是它实际上在一些纯函数式编程语言中找到了根基。例如,在Haskell中,我们可以使用let
关键字结合in
来执行范围内的赋值,如let {assignments} in {expression}
:
let two = 2; three = 3 in two * three
// ↑ ↑
// two and three are only in scope for the expression `two * three`
复制代码
等价比较和条件分支
在JavaScript中,我们可以使用===
、==
和if语句或者条件(三元)运算符?
来执行相等校验和条件分支。
另一方面,在类型语言中,我们使用extends
关键字进行“相等检查”,并且条件(三元)运算符?
的使用也适用于条件分支:
TypeC = TypeA extends TypeB ? TrueExpression : FalseExpression
复制代码
如果TypeA
是可分配给TypeB
或者可替代TypeB
的,那么我们进入第一个分支,从TrueExpression
中获得类型并分配给TypeC
;否则我们从FalseExpression
中获得类型作为TypeC
的结果。
JavaScript中的一个具体例子:
const username = 'foo'
let matched
if (username === 'foo') {
matched = true
} else {
matched = false
}
复制代码
将其翻译为类型语言:
type Username = 'foo'
type Matched = Username extends 'foo' ? true : false
复制代码
extends
关键字是多功能的。它也可以对通用类型参数应用约束。例如:
function getUserName<T extends {name: string}>(user: T) {
return user.name
}
复制代码
通过添加通用约束,T extends { name: string }
,我们确保我们的函数参数总是由字符串类型的name
属性组成。
通过对对象类型的索引来检索属性的类型
在JavaScript中,我们可以用方括号来访问对象属性,例如obj['prop']
或者点操作符,例如obj.prop
。
在类型语言中,我们也可以用方括号提取属性类型。
type User = { name: string; age: number; }
type Name = User['name']
复制代码
这不仅适用于对象类型,我们还可以用元组和数组来索引类型。
type Names = string[]
type Name = Names[number]
type Tupple = [string, number]
type Age = Tupple[1]
type Info = Tupple[number]
复制代码
Functions
函数是任何JavaScript程序中主要的可重复使用的“构建块”。它们接收一些输入(some JavaScript values)并返回一个输出(也是some JavaScript values)。在类型语言中,我们有泛型。泛型将类型参数化,就像函数把值参数化一样。因而,泛型在概念上类似于JavaScript中的函数。
比如,在JavaScript中:
function fn (a, b = 'world') {
return [a, b]
}
const result = fn('hello') // ['hello', 'world']
复制代码
对于类型语言,可以这么做:
type Fn<A extends string, B extends string = 'world'> = [A, B]
// ↑ ↑ ↑ ↑ ↑
// name parameter parameter type default value function body/return statement
type Result = Fn<'hello'> // ['hello', 'world']
复制代码
但是这仍然不是一个完美的比喻:泛型绝对不是和JavaScript中的函数完全一样。比如有一点,与JavaScript中的函数不同的是,泛型不是类型语言中的一等公民。这意味着我们不能像将函数传给另一个函数那样,将一个泛型传给另一个泛型,因为typescript不允许泛型作为类型参数。
Map和filter
在类型语言中,类型是不可改变的。如果我们想改变一个类型的某个部分,我们必须将现有的类型转成新的类型。在类型语言中,数据结构(即对象类型)遍历细节和均匀地应用转换由映射类型抽象出来。我们可以用它实现概念上类似于JavaScript的数组map
和filter
方法。
在JavaScript中,假设我们想把一个对象的属性从数字转换为字符串。
const user = {
name: 'foo',
age: 28,
};
function stringifyProp (object) {
return Object.fromEntries(
Object.entries(object)
.map(([key, value]) => [key, String(value)])
)
}
const userWithStringProps = stringifyProp(user);
复制代码
在类型语言中,映射是用这种语法[k in keyof T]
完成的,其中keyof
操作符拿到的是属性名的一个字符串联合类型。
type User = {
name: string
age: number
}
type StringifyProp<T> = {
[K in keyof T]: string
}
type UserWithStringProps = StringifyProp<User> // { name: string; age: string; }
复制代码
在JavaScript中,我们可以基于一些标记来过滤掉一个对象的属性。例如,我们可以过滤掉所有非字符串类型的属性。
const user = {
name: 'foo',
age: 28,
};
function filterNonStringProp (object) {
return Object.fromEntries(
Object.entires(object)
.filter([key, value] => typeof value === 'string')
)
}
const filteredUser = filterNonStringProp(user) // { name: 'foo' }
复制代码
在类型语言中,还可以通过as
操作符和never
类型:
type User = {
name: string;
age: number;
};
type FilterNonStringProp<T> = {
[K in keyof T as T[K] extends string ? K : never]: string;
};
type FilteredUser = FilterNonStringProp<User>;
复制代码
在typescript中,有一堆内置工具类型(泛型)用于转换类型,所以很多时候你不必重复造轮子。
模式匹配
我们还可以用infer
关键字在类型语言中进行模式匹配。
例如,在JavaScript应用程序中,我们可以使用正则来提取字符串的某一部分:
const str = 'foo-bar'.replace(/foo-*/, '')
console.log(str) // 'bar'
复制代码
在类型语言中等价于:
type Str = 'foo-bar'
type Bar = Str extends `foo-${infer Rest}` ? Rest : never // 'bar'
复制代码
递归,而不是迭代
就像许多纯函数式编程语言一样,在类型语言中,没有for循环的语法结构来迭代一个数据列表。递归代替了循环的位置。
比方说,在JavaScript中,我们想写一个函数来返回一个数组,其中同一个项重复多次。下面是某种实现方法:
function fillArray(item, n) {
const res = [];
for(let i = 0; i < n; i++) {
res[i] = item;
}
return res;
}
复制代码
递归的写法是:
function fillArray(item, n, arr = []) {
return arr.length === n ? arr : filleArray(item, n, [item, ...arr]);
}
复制代码
我们如何在类型语言中写出这个等价关系?下面是如何得出一个解决方案的逻辑步骤:
- 创建一个叫做
FillArray
的泛型(还记得我们讲了泛型在类型语言中就像函数一样?)FillArray<Item, N extends number, Arr extends Item[] = []>
- 在“函数体”中,我们需要使用
extends
关键字检查Arr
的length
属性是否已经是N
- 如果它已经达到了
N
(基本条件),那么我们就简单地返回Arr
- 如果它还没有达到
N
,它就会递归并添加一个Item
到Arr
中。
- 如果它已经达到了
把这些放在一起,我们就有了:
type FillArray<Item, N extends number, Arr extends Item[] = []> =
Arr['length'] extends N ? Arr : FillArray<Item, N, [...Arr, Item]>;
type Foos = FillArray<'foo', 3> // ['foo', 'foo', 'foo']
复制代码
递归深度的上限
在typescript4.5之前,最大的递归深度是45。在typescript4.5,有尾部调用优化,上限增加到999。
避免生产代码中的类型体操
有时候类型编程被戏称为“类型体操”,当它变得非常复杂,花里胡哨,远比在一个典型的应用程序中需要的复杂。比方说:
- 模拟中国象棋
- 模拟井字棋游戏
- 实现算术
这些更像是学术练习,不适合用在生产应用上,因为:
- 很难理解,尤其是深奥的typescript特性
- 很难进行调试,因为编译器的错误信息太长,太隐晦
- 编译速度很慢
就像有LeetCode来练习你的核心编程技能一样,有类型挑战来练习你的类型编程技能。
结束语
在本文中已经讨论了很多的内容。这篇文章的重点不是为了教你typescript,而是重新介绍你从开始学习typescript以来就可能被忽略的“隐藏起来的”类型语言。
在typescript社区,类型编程是一个小众且未被充分讨论的话题,我不认为这有什么不妥——因为最终添加类型只是达到目的的一种手段,目的是在JavaScript中编写更可靠的Web应用程序。因此,对我来说,人们不经常像对待JavaScript或其它编程语言那样,花时间去“正经”地研究类型语言,是完全可以理解的,。