第 27 章

HKT 模拟:TypeScript 没有高阶类型,但可以绕过

第27章:HKT 模拟:TypeScript 没有高阶类型,但可以绕过

理解HKT 模拟是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用TypeScript 没有高阶类型,但可以绕过?关键的设计决策和陷阱是什么?

读完本章你将理解


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>;
}

你无法把 ArrayPromiseOption 这样的类型构造器作为类型参数传递。这使得编写真正通用的函数式抽象(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 函数对 ArrayOption 都工作,类型完全正确——这就是 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>
本章评分
4.8  / 5  (3 评分)

💬 留言讨论