#AI#TypeScript

TypeScript 中的函数式编程

函数式不是学院派的玄学,它能让代码更可预测、更好测、更少 bug。本文讲清在 TS 里实用的 FP 思想:纯函数、不可变、函数组合、map/filter/reduce、Option/Either 处理空值与错误,以及"别走极端"——实用地用 FP,而非追求 monad 教条。

本文要点

  • 主题聚焦:AI、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月7日·更新于 2026年7月23日

TypeScript 中的函数式编程

提到函数式编程(FP),很多前端开发者的第一反应是:monad、functor、范畴论——一堆听起来就很学术的词。然后心里默默下了结论:"这是给写 Haskell 的人玩的,我用 Vue/React 写业务用不上。"

这是个误会。FP 的核心思想大多数时候都不是学院派的玄学,而是一组能让你代码 更可预测、更好测、更少 bug 的工程实践。你在日常里早就用过的 map、filter、reduce,就是 FP 的主力武器。

本文不讲范畴论,不追 monad 教条。只讲在 TypeScript 里能立刻用上、能切实改善代码质量的 FP 实践。


一、为什么我们该认真对待 FP

先说清楚 FP 在工程上的三个实用价值,这是后面所有讨论的立足点:

  1. 可预测性(Predictability):纯函数对相同输入永远返回相同输出,不偷偷改外部状态。读代码时你不需要在脑子里维护一个"当前世界的状态"。
  2. 可测试性(Testability):没有副作用的函数,测试就是 输入 → 期望输出,不用 mock 一堆东西。
  3. 组合性(Composability):小函数能像积木一样拼成大逻辑。map(filter(fn)) 这种写法能 work,本质上是因为这些函数都遵守了同样的"契约"。

注意,这三条里没有一条是"为了炫技"。FP 不是让你写出别人看不懂的代码,恰恰相反——它是让你写出 三个月后自己还能看懂 的代码。

OOP 不是敌人,后面会讲。先把 FP 这把刀磨好。


二、纯函数:可预测的基石

纯函数的两个要求:

  • 相同的输入,永远返回相同的输出。
  • 没有副作用:不修改外部变量、不写文件、不发请求、不改入参。

对比一下:

ts
// 不纯:依赖外部变量,还偷偷改了它
let discount = 0.8;
function price(cart: number[]) {
  return cart.reduce((sum, x) => sum + x * discount, 0);
}
discount = 0.9; // 改一下全局状态,所有调用结果都变了

// 纯:所有依赖都通过参数传入,不改任何外部状态
function totalPrice(cart: number[], discount: number): number {
  return cart.reduce((sum, x) => sum + x * discount, 0);
}

纯函数的好处是实打实的:

好测。不需要 setup、不需要 teardown,不需要 mock:

ts
expect(totalPrice([100, 200], 0.8)).toBe(240);

好推理。读到 totalPrice(cart, 0.8) 你就知道结果只取决于这两个值,不用去翻整个 codebase 找还有谁动了 discount。

可缓存(memoize)。既然相同输入永远相同输出,那就能用输入做 key 缓存结果。对于计算密集的纯函数,这是免费的性能优化:

ts
function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
  const cache = new Map<string, R>();
  return (...args: Args): R => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key)!;
  };
}

const fib = memoize((n: number): number =>
  n < 2 ? n : fib(n - 1) + fib(n - 2)
);

实战建议:把副作用推到边界。业务里一定会有 IO(读数据库、发请求、写日志),不可能全纯。但你可以把核心计算逻辑写成纯函数,只在最外层做 IO。这就是著名的 Functional Core, Imperative Shell 模式。


三、不可变:别再原地改对象了

JS 里最常见的 bug 来源之一,就是多个引用共享同一个对象,然后某个地方偷偷改了它:

ts
const user = { name: "Alice", age: 28 };
const admin = user;
admin.age = 30;
console.log(user.age); // 30 —— 你以为 user 没动,其实动了

不可变(Immutability)的核心思想是:不原地修改,而是返回一个新的副本。这样谁持有旧引用都不用担心被别人背刺。

TS/JS 里实现不可变的几种方式:

1. spread 操作符(浅拷贝)

ts
const updated = { ...user, age: 30 };
console.log(user.age);    // 28,原对象没动
console.log(updated.age); // 30

2. structuredClone(深拷贝,浏览器和 Node 17+ 原生支持)

ts
const nested = { list: [1, 2, { x: 1 }] };
const copy = structuredClone(nested);
copy.list[2].x = 999;
console.log(nested.list[2].x); // 1,深层的也没动

3. Immer(推荐用于复杂嵌套状态)

Immer 让你用"看似可变"的语法写不可变代码,底层帮你生成新对象。Redux Toolkit、Zustand 都内置了它:

ts
import { produce } from "immer";

const nextState = produce(state, (draft) => {
  draft.users[0].age = 30;
  draft.logs.push("updated");
});
// state 没动,nextState 是新对象,只有改过的部分是新的(结构共享)

不可变的最大收益是 状态变化可追踪。React 为什么要求 state 不可变?就是因为新旧 state 是不同引用,oldState !== newState 一行就能判断是否变化,从而决定要不要 re-render。如果你原地改对象,引用没变,框架根本不知道发生了什么。


四、函数组合与高阶函数:map/filter/reduce 是日常主力

高阶函数就是 接收函数作为参数,或返回函数 的函数。map、filter、reduce 是三巨头,几乎所有数组处理都能用它们优雅地解决。

来看一个真实的例子:从一批订单里,找出已完成的、金额大于 100 的,然后算总价。

命令式写法:

ts
let total = 0;
for (const order of orders) {
  if (order.status === "completed" && order.amount > 100) {
    total += order.amount;
  }
}

函数式写法:

ts
const total = orders
  .filter((o) => o.status === "completed")
  .filter((o) => o.amount > 100)
  .map((o) => o.amount)
  .reduce((sum, a) => sum + a, 0);

函数式版本更长一点,但每一步都是独立的、可读的、可测试的语义单元。"过滤已完成的"、"过滤金额大于 100 的"、"取金额"、"求和"——读起来就像在描述业务,而不是在描述循环细节。

compose / pipe:把小函数拼成大函数

当你的数据流是 f → g → h 时,可以把它组合成一个新函数:

ts
type Fn = (x: any) => any;

// compose:右到左执行(f(g(h(x))))
function compose(...fns: Fn[]) {
  return (x: any) => fns.reduceRight((acc, fn) => fn(acc), x);
}

// pipe:左到右执行,更符合阅读习惯
function pipe(...fns: Fn[]) {
  return (x: any) => fns.reduce((acc, fn) => fn(acc), x);
}

const normalize = pipe(
  (s: string) => s.trim(),
  (s: string) => s.toLowerCase(),
  (s: string) => s.replace(/\s+/g, "-")
);

normalize("  Hello World  "); // "hello-world"

实战上,链式调用 .filter().map().reduce() 已经覆盖了 90% 的场景。只有当你需要把组合后的函数作为值传递(比如传给 useMemo、useCallback),或者想让中间步骤延迟执行时,才需要 pipe/compose。


五、空值与错误:别让 try-catch 满天飞

null/undefined 被称为"十亿美元错误"。TS 里我们至少有类型系统,但 try-catch 仍然是个黑洞——你不知道某段代码会抛什么,调用方也不强制处理。

第一层防御:可选链 + 空值合并

ts
const city = user?.address?.city ?? "未知";

这是 TS 给的最便宜的武器,能不用嵌套判断就别用。

第二层防御:Option / Result 类型

当某个函数可能"没有值"或"会失败"时,不要返回 null 或抛异常,而是返回一个 显式表达成功/失败 的类型。这就是 FP 里的 Either/Option 模式。

用 neverthrow(推荐,API 友好)举例:

ts
import { ok, err, Result } from "neverthrow";

function parseAge(input: string): Result<number, string> {
  const n = Number(input);
  if (Number.isNaN(n)) return err("不是数字");
  if (n < 0 || n > 150) return err("年龄不合理");
  return ok(n);
}

const result = parseAge("28");
result
  .map((age) => age + 1)
  .map((age) => `明年 ${age} 岁`)
  .match(
    (msg) => console.log(msg),     // "明年 29 岁"
    (error) => console.error(error)
  );

好处在哪?

  1. 类型签名即文档。返回类型 Result<number, string> 明确告诉调用方:这函数可能失败,失败时错误是 string。编译器强制你处理。
  2. 错误像数据一样流动。map、mapErr、match 让你在不 try-catch 的情况下链式处理成功和失败分支。
  3. 没有隐藏的控制流。读代码时你知道哪里可能出错——所有可能出错的地方都返回了 Result,而不是突然 throw。

类似库还有 ts-results、fp-ts 的 Either。对 API 边界(解析外部输入、网络请求)用 Result 收益最大,内部纯函数能保证不抛异常的,就直接返回值就行,不必为了"纯 FP"把所有函数都包成 Result。


六、柯里化与偏应用:配置化函数

柯里化(Currying)是把 f(a, b, c) 变成 f(a)(b)(c) 的过程——每次只接收一个参数,返回一个接收下一个参数的函数。

它的实际用途是 参数复用 / 配置化:

ts
// 普通版本
function log(level: string, message: string, time: Date) {
  console.log(`[${level}] ${time.toISOString()} ${message}`);
}

// 柯里化版本
const curriedLog = (level: string) => (message: string) => (time: Date) =>
  console.log(`[${level}] ${time.toISOString()} ${message}`);

const logError = curriedLog("ERROR");        // 配置好 level
const logErrorNow = logError("something");   // 再配置 message
logErrorNow(new Date());                      // 最后传 time 才真正执行

TS 里手动写柯里化类型比较繁琐,实战中更常见的是 偏应用(Partial Application):固定部分参数,得到一个新函数。

ts
function makeFetcher(baseUrl: string, defaultHeaders: Record<string, string>) {
  return (path: string) =>
    fetch(`${baseUrl}${path}`, { headers: defaultHeaders });
}

const apiFetcher = makeFetcher("https://api.example.com", {
  Authorization: "Bearer xxx",
});

// 业务代码只用关心 path
apiFetcher("/users");
apiFetcher("/orders");

这种"配置工厂"模式在前端工程里随处可见:axios.create()、createRouter()、各种 SDK 的初始化函数,本质都是偏应用。

实战建议:不必追求把所有函数都柯里化。当你的函数有明显的"配置参数 + 业务参数"分层时,把它写成"先收配置、返回业务函数"的形式,就是最实用的柯里化。


七、别走极端:实用 FP,不是 monad 教条

到这一步必须泼一盆冷水。

FP 圈子有一种风气,把"全部用 monad、全部用 functor、全部 point-free"当成正确性本身。代码写得像谜语一样,然后管这叫"优雅"。

这是错的。代码是写给人看的,顺便让机器执行。

几个实用主义的原则:

  1. map/filter/reduce + 纯函数 + 不可变,这三件套已经能覆盖 80% 的收益。掌握它们,你就已经是更好的程序员了。

  2. Result/Option 在边界处用——解析输入、网络请求、外部 SDK 调用。内部纯计算不需要包。

  3. point-free(无参风格)慎用。下面这种写法看起来很酷,但读起来是灾难:

    ts
    // 别这样写
    const getResult = compose(
      fold,
      chain(parseJson),
      map(prop("data")),
      filter(isValid)
    );
    
    // 老老实实写成这样,所有人都看得懂
    const getResult = (raw: string) => {
      const parsed = parseJson(raw);
      if (!parsed) return null;
      const valid = parsed.filter(isValid);
      return valid.map((p) => p.data).flatMap(fold);
    };
    
  4. 可读性 > 简洁性 > 教条正确性。当一个 FP 写法需要写一段注释解释它干了什么,而命令式写法一眼能看懂时——用命令式。没有人会因为你在循环里用了 for 而开除你。

  5. 不要为了 FP 引入 fp-ts 这种重武器,除非你的团队真的能驾驭它。一个不熟悉它的同事接手代码,会陷入"改一行 bug 要先理解一小时类型体操"的泥潭。neverthrow 这种轻量选择在大多数项目里是更好的平衡点。

判断标准很简单:用了 FP 之后,onboarding 新人的成本是变低了还是变高了? 如果变高了,你用的就是教条 FP,不是实用 FP。


八、FP 与 OOP 不是对立的,组合着用

最后澄清一个常见误区:很多人觉得选了 FP 就要抛弃 OOP,或者反过来。

实际上,它们解决的是不同层面的问题:

  • OOP 擅长封装状态和行为。React 的 Class Component(虽然现在不常用)、领域模型里的 User、Order 实体,OOP 风格能很好地把相关数据和方法组织在一起。
  • FP 擅长数据流转和计算。处理集合、组合操作、处理空值和错误,FP 更优雅。

实战中大多数优秀的 TS 代码库都是 混合范式:

ts
// OOP:用 class 封装领域实体的状态和不变量
class ShoppingCart {
  private items: Item[] = [];

  add(item: Item): void {
    this.items = [...this.items, item]; // 不可变更新
  }

  // FP:用纯函数式的方法暴露计算
  total(discount: number): number {
    return this.items
      .filter((i) => i.active)
      .map((i) => i.price)
      .reduce((sum, p) => sum + p, 0) * discount;
  }
}

封装用 OOP,计算用 FP,IO 在边界,核心保持纯。这套组合拳比"纯 OOP"或"纯 FP"都更接近现实工程的复杂度。

React 本身就是个绝佳例子:组件树是声明式的、状态更新是不可变的(setState 永远是新对象)、hooks 是高阶函数的组合——但具体的业务模块你照样可以用 class 写 service、用 OOP 组织领域模型。范式是工具,不是信仰。


小结

回顾一下本文的几个要点:

  1. FP 的实用价值在于可预测、可测试、可组合,不是为了炫技,更不是为了写出让同事看不懂的代码。
  2. 纯函数 + 不可变是地基。把副作用推到边界,核心逻辑写成纯函数,状态更新永远返回新副本。
  3. map/filter/reduce 是日常主力,配上 pipe/compose 能优雅地表达数据流。但链式调用已经覆盖大多数场景,不必强求 point-free。
  4. 用 Result/Option 处理空值和错误,在 API 边界尤其有效;内部纯函数不必包。
  5. 实用 FP,拒绝 monad 教条。可读性永远优先,FP 和 OOP 不是对立面,组合着用最强大。

最后送一句我常对自己说的话:

最好的代码不是"最函数式"的代码,也不是"最面向对象"的代码——而是六个月后接手它的同事,能在十分钟内看懂的代码。 FP 给你的工具,是为了达成这个目标,而不是相反。

Related

相关推荐