照片由 Chris Liverani 拍摄,来自 Unsplash。
在荒郊野外您可能听说过 TypeScript 的类型系统是 图灵完备的。实际上,这意味着您可以编写自己的编程语言或使用其类型来模拟 康威生命游戏。此外,现在甚至有完整的 2D 游戏 是用 TypeScript 类型编写的(最近有个新消息:现在有一个DOOM用 TypeScript 类型实现)!
在这篇文章中,我想展示 TypeScript 中类型级别编程的强大之处,从基础开始。对于程序员来说,开始点是什么呢?没错——数字。我想仅用 TypeScript 类型来创建数字系统。到本文结束时,您将能够仅通过类型来操作数字甚至数字数组,无需使用任何数字或操作符。让我们开始吧。
“ 走千里路,从第一步开始。” —— 老子曾说
每一个微积分或测量体系都从零开始。零是加法群中的单位元素,在群论中,加法是加法群的运算。抱歉,这听起来有点学术。简而言之,我们需要为我们的类型系统层级定义一个零,以此为基础构建其他一切。
类型 _Zero 代表乌克兰国旗的表情符号。
关于这行代码,这里有两条要点:
- 从数字中抽象出来。 我故意没有使用“0”字符(甚至没有用0作为类型,这是可以做到的)。为了完全脱离任何现有的数字表示形式,我决定用我的国家的国旗作为象征性的替代品(Доброго вечора!)
- 前缀下划线。 我将使用下划线来区分算术系统中的内部类型。当我们讨论系统的输入和输出时,我会解释内部类型和外部类型之间的关系。
“数字是神赐给我们的通用语言,用来证明真理。” — 圣奥古斯丁(希波的圣奥古斯丁)
注:去掉或简化注释部分。
最终翻译:
“数字是神赐给我们的通用语言,用来证明真理。” — 圣奥古斯丁(希波的)
在我们学习的代数里,我们将操作一组正整数,这些数的最大值是固定的。我们将到5为止,这已经足够展示TypeScript类型系统的强大了。
直到现在,你可能一直觉得,如果我们没给零添加任何上下文,并放了一个旗子表情符号,那么我们对其他数字该怎么办?在解释图灵完备系统的数字时,不论是阿拉伯数字、罗马数字还是其他符号都不重要。关系才是重点!
在我们的领域特定语言中,我们有一个零,我们可以定义该数字为递归类型项中的下一个。
类型 数字 = 零 | { prev: 数字 };
定义一个类型叫 数字,它可以是一个 零,或者是一个包含前一个数字的对象。
每个整数都是其前一个整数的后继,这种关系对于将来的操作非常重要。我们来定义第一个通用的,负责数字关系的角色,更符合数学术语的表达习惯。
定义一个泛型类型_Next,它接收一个泛型参数T,T需要是一个_Number的子类型,且_Next包含一个属性prev,其值类型为T。
最后,我们村的名人——那些数字!这里指的是那些重要的数字们!
类型 _One = _Next<_Zero>;
类型 _Two = _Next<_One>;
类型 _Three = _Next<_Two>;
类型 _Four = _Next<_Three>;
类型 _Five = _Next<_Four>;
类型 _Max 被定义为 _Five;
又一次,我们要确定我们计数系统的最大数值。设限在以后的操作中会很有帮助。
用户友好的I/O界面“一切应尽可能简单,但不能过于简化——阿尔伯特·爱因斯坦(Albert Einstein)”
目前,我们已经将'数字'定义为 TypeScript 的类型,并且即将开始创建一些计算的东西。
先说个不泄露大秘密的事,如果我说系统的_'功能'_是TS泛型,也不会泄露什么大秘密。泛型就像函数参数,TS会根据情况推断出返回值。
然而,使用我们已经定义的数字类型可能会很繁琐,因为用户可能需要随时保留这些类型定义。幸运的是,TypeScript 提供了字面量类型,可以被视为数值系统中的具体值。让我们暂时想一想在这种情况下加法函数会是什么样。
不过我们之前谈到不同类型之间的继承关系。具体的类型是如何保持这种关系的?
让我们将之前定义的数字视为我们数值系统中的_内部_类型,将字面类型视为_外部_类型。我们将它们以1:1的关系进行匹配——用户将提供字面类型的参数作为输入,并以字面类型的视图作为输出结果。然而,系统将根据内部类型进行结果类型的推断。
// "外部的数字"的定义
type _StringNumber = "Zero" | "One" | "Two" | "Three" | "Four" | "Five";
// 这里我们将内部的类型转换为外部的类型
type _StringifyNumber<T extends _Number> = T extends _Zero ? "Zero"
: T extends _One ? "One"
: T extends _Two ? "Two"
: T extends _Three ? "Three"
: T extends _Four ? "Four"
: T extends _Five ? "Five" : never;
// 反过来,将外部的类型转换为内部的类型
type _ParseNumber<T extends _StringNumber> = T extends "Zero" ? _Zero
: T extends "One" ? _One
: T extends "Two" ? _Two
: T extends "Three" ? _Three
: T extends "Four" ? _Four
: T extends "Five" ? _Five : never;
正如你所记得的,我提到带有下划线前缀的类型被视为内部类型。这意味着,使用我们类型系统的用户不会用它们来做用户的计算。为了展示用户特定的类型,让我们从一些热身开始,最终展示特定的用户类型。
增量:迈入微积分的第一个台阶“一星微火可成燎原之势。” — 但丁(阿利吉耶里)
对于计算机科学来说,增量操作是最基本也是最简单的操作。细心的读者可能已经注意到,我们已经将其实现为 _Next
类型。这没错,但让我们进一步完善它,考虑到我们代数的上限值。
类型 `_Increment<T extends _Number>` 表示当 `T` 是 `_Max` 类型时返回 `never`,否则返回 `_Next<T>`。这里 `T` 必须是 `_Number` 类型,`_Next<T>` 表示 `T` 的下一个数值类型。
正如你所看到的,我们将所有超出的计算值视为 never
。
让我们把这个数学函数和上一节的 I/O 系统结合起来。
参考上述结构,我们可以介绍第一个仅基于TypeScript类型的数学运算。
定义一个泛型类型Increment,其参数T需要扩展_StringNumber。Increment类型等于_StringifyNumber<_Increment<_ParseNumber<T>>>。
看看最后阶段的情况:
看!我们现在有一个完整的数学计算,竟然没有用到任何数字!
不用 “+” 符号的加法
「数学是给不同事物赋予相同名称的艺术。」— 亨利·庞加莱
接下来我们来创建加法,最常用的算术运算。通过查看我们的输入输出转换器实现,可以看出在类型级别编程中迭代并不常见。从第一个数字逐步迭代到所有数字之和的想法很直观,但在类型级别系统中实现起来却比较困难。
如果我们考虑一下迭代过程中的对立面——递归(recursion)——会怎么样?发现这就是关键所在!我们可以用如下的递归公式来解释加法:
我们能用我们的计数系统来实现这一点吗?咱们把公式拆解成一些有意义的标记并考虑它们。
(a, b)
参数签名 — 正如我们之前对增量函数所做的那样,通过泛型参数来处理这个问题b === 0 ? ... : ...
条件 — 通过extends
,TypeScript 提供了条件类型add(...)
递归调用 — 类型脚本允许泛型递归a + 1
— 我们在之前的段落中已经实现了增量功能(b — 1)
— 最后一个我们需要解决的秘密谜题
现在,,我建议你从头开始回想一下我们在数字系统中定义的“数”是什么。
type _Number = _Zero // 表示零
| { prev: _Number }; // 表示前一个数字
注意那个关于从一个数字中减一的微妙提示。
关于 prev
! 前一个数就是减一。所以,我们需要写一个函数,这个函数接收一个数字作为参数并返回它减一的结果。
这可能很危险,因为我们对数字的定义包括了零,而零并没有参考之前的值。
让我们再次看看加法的递归公式。在条件谓词中,我们检查参数是否等于零。我们仅在条件分支中使用b,而b不能是零。这意味着我们可以做一个非零声明,并把它用在我们的递减函数中。
好的,这次我们深入一点,从底部开始,一起浮出水面怎么样?
首先,非零的断言,使类型更具体:
定义一个泛型类型_NonZero,表示当T不是_Zero类型时,_NonZero<T extends _Number>等于T,否则为never。
接下来的一步是通过 prev 引用来执行一次减法:
类型 _Prev<T extends _Number> = _NonZero<T>["prev"]; // _Prev 定义了从非零数中获取前一个数的类型定义
最后,各位女士先生,加法的最终形态!
(注:保持了原文中的 "ladies and gentlemen" 不翻译,并保留了 Markdown 格式。)
type _加<A extends _数字, B extends _数字> =
B extends _零
? A
: A extends _最大值
? never
: _加<_下一个<A>, _前一个<B>>;
打包成用户友好的I/O
类型 Add<A extends _StringNumber, B extends _StringNumber> =
_StringifyNumber<_Add<_ParseNumber<A>, _ParseNumber<B>>>>;
最后,我们终于等来了期待已久的结果!
哎!结果超出我们的代数范围了。
减法操作也可以通过递归来表示。你能否猜出注释里的那个公式?我们也可以在我们的数字系统中实现这一点,作为留给你的练习。这将作为你的练习。
额外章节:数组映射指南“自然就是一个无限的球体,它的中心无处不在,而周长却无处可寻。”— Blaise Pascal
到目前为止,我们在数制中定义了数的概念,实现了数的基本数学运算,并最终信服了类型级别的编程的力量。
所以,如果我们把数字定义成 TypeScript 类型,有什么能阻止我们确定这些 "数字" 数组的类型呢?正确,没有什么能阻止!那么,今天的最后一个技巧将是实现一个返回数组长度的函数,这将是我们今天的最后一个技巧。
这个例子不需要像之前那样通过“解析-字符串化”操作来转换每一个数组项,我们并不在意数组里的具体数据。我们关心的是数组的结构,在函数式编程语言中尤其常见。
要获取数组的 头部 和 尾部,我们必须能够使用 推导类型。
type Head<T> = T extends [infer First, ...infer Rest] ? First : never;
可以理解为,Head<T> 类型用于获取泛型 T 的第一个元素,如果 T 是一个数组形式 [First, ...Rest],则返回 First,否则返回 never。(`never` 表示不满足条件的情况)
type Tail<T> = T extends [infer First, ...infer Rest] ? Rest : never;
类似地,Tail<T> 类型用于获取泛型 T 的剩余元素部分,如果 T 是一个数组形式 [First, ...Rest],则返回 Rest,否则返回 never。(`never` 表示不满足条件的情况)
那我们到底要用它来干什么呢?行吧,我们现在就来回到计算数组长度的那个点吧。
我们能不能把长度函数表示成递归的形式?当然可以!
我们已经有了实现递归所需的所有块,那我们就开始实现它吧!
_ArrLength 是一个泛型类型,它接受一个类型参数 T,该参数必须是 _StringNumber 类型数组的子类型。如果 T 是一个空数组,则 _ArrLength 的值为 _Zero;否则,_ArrLength 的值为 Tail<T> 的 _ArrLength 值递增后的结果。
并将结果转换成有意义的信息。
type ArrLength<T extends _StringNumber[]> = _StringifyNumber<_ArrLength<T>>;
我们来看看结果。
有点儿行,哈哈?
最后想说的就像我之前说的那样,我们做到了!这个仅用TypeScript类型构建的数值系统可以进一步发展成更复杂的形式——只有你的想象力能限制它了。
例如,我下一步是创建了一个带有转换回调函数作为泛型参数的_map_函数。你觉得下一步应该做什么呢?
谢谢阅读这篇文章。这篇文章在我脑海里有一年的时间,我终于把它写出来了。它是否让你哪怕只是一瞬间产生了好奇心?如果是的话,请给它点个“赞”或留下“评论”,如果你想看到更多这样的内容,请继续支持我。
让类型保持整齐🚀
参考文献:- 例如:TypeScript 的类型系统是图灵完备的
- 用类型层级的 TypeScript 编写的 Flappy Bird 游戏
- 康威生命游戏(Conway's Game of Life)
- TypeScript 类型可以运行《DOOM》
在你离开之前:
- 确保点赞并关注作者👏️
- 关注我们:X | LinkedIn | YouTube | Newsletter | Podcast | Differ
- 了解 CoFeed,智能方式获取科技最新资讯 🧪
- 在 Differ 上免费创建自己的 AI 博客 🚀
- 加入我们的内容创作者 Discord 交流群 🧑💻
- 更多内容,请访问 plainenglish.io + stackademic.com