Skip to content

🔥类型类(Typeclass):JS 中的“接口”模拟

——如 Semigroup(可合并)、Monoid(带单位元的 Semigroup)

“Typeclass 不是类,
也不是类型,
它是行为的契约。”

一、问题:如何让不同类型的值“统一合并”?

我们常做合并操作:

js
[1,2] + [3,4]     // [1,2,3,4] —— 数组合并
"hi" + "!"        // "hi!"     —— 字符串拼接
{a:1} + {b:2}     // ?         —— 对象合并

这些操作都叫“合并”,但语言没有统一机制。

类型类(Typeclass)就是为“合并”这种行为定义通用接口。

二、什么是类型类?—— 行为的抽象

类比:接口(Interface)

ts
interface Semigroup {
  concat(other: this): this
}

但 JS 没有泛型接口系统,所以我们用函数式方式模拟

核心思想:

一个类型“是”某个类型类的实例,
当它实现了该类要求的操作

比如:

  • ArraySemigroup —— 因为能 concat
  • StringSemigroup —— 因为能 +
  • Number 可以是 Semigroup(加法或乘法)

三、Semigroup:可合并的类型

定义:

支持 concat 操作,且满足结合律(a ⊕ b) ⊕ c ≡ a ⊕ (b ⊕ c)

实现一个 Semigroup 字典

js
// Semigroup 实例字典
const Semigroup = {
  Array: {
    concat: (a, b) => a.concat(b)
  },
  String: {
    concat: (a, b) => a + b
  },
  Sum: {  // 数值加法
    concat: (a, b) => a + b
  },
  Product: { // 数值乘法
    concat: (a, b) => a * b
  }
};

通用 concat 函数

js
const concat = (S, a, b) => S.concat(a, b);

// 使用
concat(Semigroup.Array, [1], [2,3])     // [1,2,3]
concat(Semigroup.String, "hello", "!")  // "hello!"
concat(Semigroup.Sum, 2, 3)             // 5

四、Monoid:带单位元的 Semigroup

比 Semigroup 多一个要求:

存在单位元(empty),满足: a ⊕ empty ≡ a ≡ empty ⊕ a

示例

类型concatempty(单位元)
Arrayconcat[]
String+""
Sum (加法)+0
Product (乘法)*1
Boolean (or)`
Boolean (and)&&true

实现 Monoid

js
const Monoid = {
  Array: {
    concat: (a, b) => a.concat(b),
    empty: () => []
  },
  String: {
    concat: (a, b) => a + b,
    empty: () => ""
  },
  Sum: {
    concat: (a, b) => a + b,
    empty: () => 0
  },
  Product: {
    concat: (a, b) => a * b,
    empty: () => 1
  }
};

五、Monoid 的强大:fold 的通用实现

因为有 empty,我们可以安全地折叠空集合。

js
const fold = (M, values) =>
  values.reduce(M.concat, M.empty());

// 使用
fold(Monoid.Array, [[1,2], [3], [4,5]])  // [1,2,3,4,5]
fold(Monoid.Sum, [1,2,3,4])             // 10
fold(Monoid.Product, [2,3,4])           // 24
fold(Monoid.String, ["a","b","c"])      // "abc"

fold 对任何 Monoid 都成立!

六、真实工程应用

1. 日志聚合

js
const LogEntry = message => ({ message });
const LogMonoid = {
  concat: (a, b) => ({ entries: a.entries.concat(b.entries) }),
  empty: () => ({ entries: [] })
};

const logs = fold(LogMonoid, userLogs);

2. 配置合并

js
const ConfigMonoid = {
  concat: (a, b) => ({ ...a, ...b }), // 浅合并
  empty: () => ({})
};

const config = fold(ConfigMonoid, [defaultCfg, envCfg, userCfg]);

3. 异步结果合并

js
const PromiseMonoid = (M) => ({
  concat: (pa, pb) =>
    Promise.all([pa, pb]).then(([a, b]) => M.concat(a, b)),
  empty: () => Promise.resolve(M.empty())
});

// 合并两个异步字符串
const combined = PromiseMonoid(Monoid.String)
  .concat(Promise.resolve("hello"), Promise.resolve("world"));
// "helloworld"

七、为什么叫“类型类”?

因为它不是值的类,而是类型的类

  • Number 可以属于 Monoid 类(加法)
  • Number 也可以属于另一个 Monoid 类(乘法)
  • 同一个类型可以属于多个类型类

这叫 多态实例(Overloading),是函数式多态的核心。

八、与 OOP 接口的区别

特性OOP 接口Typeclass
绑定方式类定义时实现运行时提供实例
扩展性需修改类可为第三方类型添加实例
多实例通常不支持支持(如 Sum vs Product)

优势:

你可以为 DateRegExp 等内置类型添加 Semigroup 实例,而无需修改其源码。

结语:Typeclass 是“行为即接口”

它让你基于“能做什么”,而不是“是什么”,来抽象代码。

当你写下:

js
fold(Monoid.Sum, [1,2,3])

你不在乎 Sum 是类还是函数,
你在乎的是它支持合并且有单位元

这就是鸭子类型的数学升级版:
“如果它能 concatempty,它就是 Monoid。”