【译】TypeScript的类型编程简介

学习使用类型语言编写类型,并通过你现有的JavaScript知识更快地掌握TypeScript。

类型本身就是一种复杂的语言

我以前认为typescript就只是在JavaScript之上添加了类型注释。在这种思维模式下,我经常发现编写正确的类型很棘手,让人望而生畏,以至于某种程度上它们妨碍了我构建实际的应用程序,而且经常导致我去使用any。而使用了any,我失去了所有的类型安全。

实际上,你可以使用类型,让类型变得非常复杂。在使用了一段时间的typescript之后,我觉得typescript语言实际上由两种子语言构成——一种是JavaScript,另外一种是类型语言:

  • 对于JavaScript语言来讲,世界是由JavaScript values组成的
  • 对于类型语言来讲,世界是由类型组成的

当我们编写typescript代码时,我们不停地在两个世界来回跳舞:我们在类型世界中创建类型,并使用类型注释(或者由编译器隐式地推断它们)在我们的JavaScript世界中“召唤”它们;我们也可以从另一个方向前进:在JavaScript变量、属性上使用typescript的typeof关键字来检索对应的类型(这里不是指JavaScript提供的typeof关键字来检查运行时的类型)。

image.png

JavaScript的语言表现力很强,而类型语言也是如此。事实上,强表现力的类型语言已经被证明是图灵完备的。

这里我不对图灵完备是好是坏做出任何价值判断,也不知道它甚至是出于设计还是出于意外(事实上,很多时候,图灵完备性是被意外实现的)。我的观点是,类型语言本身虽然看起来无害,但是肯定是强大的,高性能的,在编译时可以进行任意的计算。

当我开始把typescript中的类型语言当作一种成熟的编程语言时,我意识到它甚至具有函数式编程语言的一些特征。

  • 使用递归而不是迭代
    • 在typescript4.5中我们可以用尾部调用优化递归(在某些程度上)
  • 类型(数据)是不可变的

在本文中,我们将通过与JavaScript的对比来学习typescript中的类型语言,这样你就可以利用现有的JavaScript知识来更快地掌握typescript。

变量声明

在JavaScript中,世界是由JavaScript值组成的,我们使用关键字varletconst来声明变量以此引用值。比如说:

const obj = { name: 'foo' }
复制代码

在类型语言中,世界是由类型组成的,我们使用关键字typeinterface来声明类型变量。比如说:

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的数组mapfilter方法。

在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关键字检查Arrlength属性是否已经是N
    • 如果它已经达到了N(基本条件),那么我们就简单地返回Arr
    • 如果它还没有达到N,它就会递归并添加一个ItemArr中。

把这些放在一起,我们就有了:

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或其它编程语言那样,花时间去“正经”地研究类型语言,是完全可以理解的,。

原文

www.zhenghao.io/posts/type-…

猜你喜欢

转载自juejin.im/post/7079305963131371550