HKT 模拟:TypeScript 没有高阶类型,但可以绕过
第27章:HKT 模拟:TypeScript 没有高阶类型,但可以绕过
理解HKT 模拟是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用TypeScript 没有高阶类型,但可以绕过?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 什么是高阶类型(HKT)
- 去函数化(Defunctionalization):绕过限制的核心技巧
- 具体实现:Array、Option、Task
Level 1 · 你需要知道的(1-3年经验)
什么是高阶类型(HKT)
先从 Haskell 说起——不是为了卖弄,而是因为 HKT 的概念在 Haskell 里是一等公民,对比才能清楚地看出 TypeScript 缺了什么。
在 Haskell 中,Functor 定义如下:
class Functor f where
fmap :: (a -> b) -> f a -> f b
这里的 f 是一个类型构造器(type constructor),它接受一个类型参数返回一个新类型。Maybe、[](List)、Either e 都可以是 f。关键点:f 本身不是一个具体类型,它是一个"类型的函数"——接受类型,产出类型。这就是高阶类型。
TypeScript 有泛型,但没有高阶泛型:
// 你可以这样写
interface Functor<A> {
map<B>(f: (a: A) => B): Functor<B>;
}
// 但你不能这样写
interface Functor<F<_>> { // ← 语法错误!TypeScript 不支持
map<A, B>(fa: F<A>, f: (a: A) => B): F<B>;
}
你无法把 Array、Promise、Option 这样的类型构造器作为类型参数传递。这使得编写真正通用的函数式抽象(Functor、Monad、Applicative)变得极其困难。
去函数化(Defunctionalization):绕过限制的核心技巧
这个技巧来自程序语言理论,原本用于将高阶函数转换成一阶函数。fp-ts 将其引入 TypeScript 类型系统。
第一步:建立 URI 注册表
// 每个类型构造器注册一个唯一的 URI 字符串标识
// 通过模块扩展(declaration merging)让注册可以分布在各处
interface URItoKind<A> {
// 这里的接口是空的,让各模块自己注册
}
// 取所有注册的 URI 的联合类型
type URIS = keyof URItoKind<unknown>;
// 核心映射:URI + 类型参数 → 具体类型
type Kind<F extends URIS, A> = URItoKind<A>[F];
第二步:注册具体的类型构造器
// 注册 Array
declare module "./hkt" {
interface URItoKind<A> {
readonly Array: Array<A>;
}
}
// 注册 Option(自定义类型)
type Option<A> = { _tag: "None" } | { _tag: "Some"; value: A };
declare module "./hkt" {
interface URItoKind<A> {
readonly Option: Option<A>;
}
}
// 注册 Promise
declare module "./hkt" {
interface URItoKind<A> {
readonly Promise: Promise<A>;
}
}
第三步:写通用接口
// 通用 Functor 接口
interface Functor<F extends URIS> {
readonly URI: F;
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>;
}
// 通用 Apply 接口(Applicative 的前置)
interface Apply<F extends URIS> extends Functor<F> {
ap<A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>): Kind<F, B>;
}
// 通用 Monad 接口
interface Monad<F extends URIS> extends Apply<F> {
of<A>(a: A): Kind<F, A>;
chain<A, B>(fa: Kind<F, A>, f: (a: A) => Kind<F, B>): Kind<F, B>;
}
具体实现:Array、Option、Task
Array 的 Functor 和 Monad
// 完整的 Monad 实例
const arrayMonad: Monad<"Array"> = {
URI: "Array",
map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f),
ap: <A, B>(fab: Array<(a: A) => B>, fa: Array<A>): Array<B> =>
fab.flatMap((f) => fa.map(f)),
of: <A>(a: A): Array<A> => [a],
chain: <A, B>(fa: Array<A>, f: (a: A) => Array<B>): Array<B> => fa.flatMap(f),
};
// 验证类型
const result1: Array<string> = arrayMonad.map([1, 2, 3], (n) => n.toString());
// ["1", "2", "3"] ✓
const result2: Array<number> = arrayMonad.chain([1, 2, 3], (n) => [n, n * 2]);
// [1, 2, 2, 4, 3, 6] ✓
Option 的完整实现
type None = { readonly _tag: "None" };
type Some<A> = { readonly _tag: "Some"; readonly value: A };
type Option<A> = None | Some<A>;
const none: None = { _tag: "None" };
const some = <A>(value: A): Some<A> => ({ _tag: "Some", value });
const optionMonad: Monad<"Option"> = {
URI: "Option",
map: <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
fa._tag === "None" ? none : some(f(fa.value)),
ap: <A, B>(fab: Option<(a: A) => B>, fa: Option<A>): Option<B> => {
if (fab._tag === "None") return none;
if (fa._tag === "None") return none;
return some(fab.value(fa.value));
},
of: <A>(a: A): Option<A> => some(a),
chain: <A, B>(fa: Option<A>, f: (a: A) => Option<B>): Option<B> =>
fa._tag === "None" ? none : f(fa.value),
};
Level 2 · 它是怎么运行的(3-5年经验)
用 HKT 写通用算法
HKT 的价值在于可以写对所有 Functor/Monad 都通用的算法:
// 对任何 Functor 都适用的 lift:把普通函数提升到 Functor 的世界
function lift<F extends URIS, A, B>(
F: Functor<F>,
f: (a: A) => B
): (fa: Kind<F, A>) => Kind<F, B> {
return (fa) => F.map(fa, f);
}
const double = (n: number) => n * 2;
const liftedDouble_Array = lift(arrayMonad, double);
liftedDouble_Array([1, 2, 3]); // [2, 4, 6] — 类型:Array<number> ✓
const liftedDouble_Option = lift(optionMonad, double);
liftedDouble_Option(some(5)); // Some(10) — 类型:Option<number> ✓
liftedDouble_Option(none); // None ✓
lift 函数对 Array 和 Option 都工作,类型完全正确——这就是 HKT 的意义。
通用 sequence:从 F[] 到 F<A[]>
// 把一个 Monad 值的数组转换成一个包含数组的 Monad 值
function sequence<F extends URIS, A>(
M: Monad<F>,
fas: Kind<F, A>[]
): Kind<F, A[]> {
return fas.reduce(
(acc: Kind<F, A[]>, fa: Kind<F, A>) =>
M.chain(acc, (as: A[]) =>
M.map(fa, (a: A) => [...as, a])
),
M.of([] as A[])
);
}
// Array Monad 下的 sequence(笛卡尔积)
const seqArrayResult = sequence(arrayMonad, [[1, 2], [10, 20]]);
// [[1, 10], [1, 20], [2, 10], [2, 20]] ✓
// Option Monad 下的 sequence(全有或全无)
const seqOptResult1 = sequence(optionMonad, [some(1), some(2), some(3)]);
// Some([1, 2, 3]) ✓
const seqOptResult2 = sequence(optionMonad, [some(1), none, some(3)]);
// None ✓(任何一个 None 导致整体 None)
同一个 sequence 函数,在 arrayMonad 下做笛卡尔积,在 optionMonad 下做"全部成功才成功"——这就是多态的力量。
支持多个类型参数:HKT2
Either<E, A> 有两个类型参数。需要扩展系统:
interface URItoKind2<E, A> {
// 各模块注册
}
type URIS2 = keyof URItoKind2<unknown, unknown>;
type Kind2<F extends URIS2, E, A> = URItoKind2<E, A>[F];
// 注册 Either
type Either<E, A> = { _tag: "Left"; left: E } | { _tag: "Right"; right: A };
declare module "./hkt2" {
interface URItoKind2<E, A> {
readonly Either: Either<E, A>;
}
}
// Monad2 接口
interface Monad2<F extends URIS2> {
readonly URI: F;
of<E, A>(a: A): Kind2<F, E, A>;
map<E, A, B>(fa: Kind2<F, E, A>, f: (a: A) => B): Kind2<F, E, B>;
chain<E, A, B>(fa: Kind2<F, E, A>, f: (a: A) => Kind2<F, E, B>): Kind2<F, E, B>;
}
// Either 的 Monad2 实例
const eitherMonad: Monad2<"Either"> = {
URI: "Either",
of: <E, A>(a: A): Either<E, A> => ({ _tag: "Right", right: a }),
map: <E, A, B>(fa: Either<E, A>, f: (a: A) => B): Either<E, B> =>
fa._tag === "Left" ? fa : { _tag: "Right", right: f(fa.right) },
chain: <E, A, B>(fa: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> =>
fa._tag === "Left" ? fa : f(fa.right),
};
Effect-ts 和 fp-ts 的生产实践
fp-ts 正是用这个模式构建的,实际的注册方式略有不同但原理相同:
// fp-ts 实际代码风格(简化)
import * as O from "fp-ts/Option";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
// pipe 让链式调用变得可读
const result = pipe(
O.some(42),
O.map((n) => n * 2), // Some(84)
O.chain((n) => // Some("84")
n > 0 ? O.some(n.toString()) : O.none
),
O.getOrElse(() => "default")
);
// result: "84" ✓,类型推断为 string
Effect-ts(fp-ts 的精神继承者)将这套系统推进到支持效果系统(Effect System):
import { Effect } from "effect";
// Effect<R, E, A>: 需要 R 环境,可能产生 E 错误,成功返回 A
const program = Effect.gen(function* () {
const config = yield* Effect.service(ConfigService);
const user = yield* fetchUser(config.apiUrl, 1);
return user.name;
});
// 类型推断出完整的 R, E, A 参数
// 这种 generator 语法是 HKT Monad 的语法糖
何时用,何时不用
HKT 模拟在 TypeScript 里有真实的成本:
| 场景 | 用 HKT | 不用 HKT |
|---|---|---|
| 写 fp-ts/effect 风格的函数式库 | ✓ 必须 | - |
| 需要 Functor/Monad 多态的通用算法 | ✓ 值得 | - |
| 普通业务逻辑 | - | ✓ 用普通泛型 |
| 只有 2-3 种容器类型 | - | ✓ 函数重载更简单 |
| 团队不熟悉 FP 概念 | - | ✓ 学习成本太高 |
| 性能敏感的热路径 | - | ✓ 虚函数派发有成本 |
核心判断:如果你需要写的算法对"容器类型"是多态的,且容器数量是开放的(第三方可以注册新容器),用 HKT。如果容器是固定的,用函数重载或条件类型。
Level 3 · 规范怎么定义的(资深)
高阶类型(HKT)在 TypeScript 中通过去函数化(defunctionalization)模拟:将类型构造器注册为 URI 字符串,通过 interface URItoKind<A> + declaration merging 建立映射,然后用 Kind<F, A> 索引访问还原具体类型。这个技巧来自 fp-ts 库,利用了 TypeScript 的 interface 声明合并特性使注册表可扩展。Effect-ts 将此模式扩展到支持效果系统(Effect<R, E, A>)。HKT 模拟的成本是代码可读性和学习曲线——只在需要对容器类型多态的开放式算法中使用。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 |
|---|---|
| 用 HKT 封装所有东西 | 代码可读性崩溃,学习曲线陡峭,同事无法维护 |
| URI 字符串拼写错误 | "Array" vs "array" 没有类型检查,导致 Kind<"array", number> 返回 unknown |
| 忘记注册 URI 就使用 | Kind<F, A> 变成 unknown,类型保护消失 |
| 在没有 pipe 的情况下深度链式调用 HKT API | 代码变成 M.chain(M.chain(M.map(...))) 嵌套地狱 |
总结
| 概念 | 作用 | 实现机制 |
|---|---|---|
| URI 注册表 | 把类型构造器映射到字符串 | interface URItoKind<A> + declaration merging |
Kind<F, A> |
还原具体类型 | 索引访问 URItoKind<A>[F] |
Functor<F> |
泛型映射操作 | map: (fa: Kind<F,A>, f: A→B) => Kind<F,B> |
Monad<F> |
顺序组合 | chain + of |
sequence |
翻转容器嵌套 | 用 chain + map + reduce 组合 |
| HKT2 | 双类型参数容器 | URItoKind2<E, A> + Kind2<F, E, A> |