Skip to content

pipecompose:函数式流水线的构建

在函数式编程中,数据流是核心概念。我们希望数据像流水线一样,经过一系列纯函数的处理,最终得到结果。

pipecompose 正是实现这一模式的两大利器。它们将分散的函数连接成一条“管道”,让代码更声明式、更易读、更易测试。

虽然语义相似,但执行顺序的差异决定了它们的适用场景。

pipe:从左到右的流水线

核心语义

pipe(f, g, h)(x) 等价于 h(g(f(x)))

数据从左向右流动,符合人类阅读习惯。

ts
const add1 = (x: number) => x + 1
const mul2 = (x: number) => x * 2
const toString = (x: number) => x.toString()

const fn = pipe(add1, mul2, toString)
fn(3) // => toString(mul2(add1(3))) => toString(mul2(4)) => toString(8) => '8'

实现:reduce 累积函数调用

ts
const pipe = <T>(...fns: Array<(arg: any) => any>) => {
  return (value: T) => {
    return fns.reduce((acc, fn) => fn(acc), value)
  }
}

关键逻辑

  • ...fns 收集所有函数。
  • 返回一个新函数,接受初始值 value
  • reducevalue 为初始值,依次将上一步结果传给下一个函数。

compose:从右到左的组合

核心语义

compose(f, g, h)(x) 等价于 f(g(h(x)))

数据从右向左流动,符合数学中函数组合 f ∘ g ∘ h 的习惯。

ts
const fn = compose(toString, mul2, add1)
fn(3) // => toString(mul2(add1(3))) => '8' (结果相同)

实现:reduceRight

ts
const compose = <T>(...fns: Array<(arg: any) => any>) => {
  return (value: T) => {
    return fns.reduceRight((acc, fn) => fn(acc), value)
  }
}

对比 pipe vs compose

特性pipecompose
执行顺序左 → 右右 ← 左
阅读习惯更直观数学传统
推荐优先使用在需要 f ∘ g 语义时使用

在现代前端开发中,pipe 因其直观性更受欢迎。

类型推导:如何实现多函数链的类型安全?

pipe 的最大挑战是:如何让 TypeScript 正确推导出整个链的输入和输出类型?

简单实现中,any 会破坏类型安全。我们需要更精确的方案。

方案一:函数重载(推荐)

通过重载定义不同长度函数链的类型:

ts
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B
function pipe<A, B, C>(
  fn1: (a: A) => B,
  fn2: (b: B) => C
): (a: A) => C
function pipe<A, B, C, D>(
  fn1: (a: A) => B,
  fn2: (b: B) => C,
  fn3: (c: C) => D
): (a: A) => D
// 可继续扩展...

function pipe(...fns: Function[]) {
  return (value: any) => fns.reduce((acc, fn) => fn(acc), value)
}

优势

  • 类型推导精准。
  • IDE 支持自动补全和错误提示。
  • 适用于大多数实际场景(通常链长 ≤ 5)。

方案二:泛型数组推导(实验性)

尝试用递归类型推导任意长度链:

ts
type Pipe<T extends Array<(arg: any) => any>> = (
  T extends [(arg: infer A) => infer B]
    ? (a: A) => B
    : T extends [(arg: infer A) => infer B, ...infer Rest]
      ? (a: A) => Pipe<Rest>
      : never
)

但此方案复杂且易触发 TypeScript 的递归深度限制,不推荐生产使用

多参数函数的链式传递

pipe 默认设计为一进一出的函数链。若中间函数需要多参数,需提前柯里化(Curry)或封装。

问题:多参数函数无法直接接入

ts
const add = (a: number, b: number) => a + b
pipe(add, mul2)(3) // ❌ add 需要两个参数

解法:柯里化

将多参数函数转换为单参数函数的链:

ts
const add = (a: number) => (b: number) => a + b
const add5 = add(5)

pipe(add5, mul2)(3) // => mul2(add5(3)) => mul2(8) => 16

柯里化是函数式编程的基础技巧,使函数更灵活、可组合。

错误处理:中间函数抛错如何传递?

pipe 本身不处理错误,但错误会自然向上传播。

默认行为:异常中断

ts
const divide = (x: number) => (y: number) => {
  if (y === 0) throw new Error('Division by zero')
  return x / y
}

const safeDivide = pipe(divide(10), Math.sqrt)

safeDivide(0) // ❌ 抛出 'Division by zero'

错误会中断流水线,直接抛出,调用者需用 try/catch 捕获。

解法:异常安全的 pipe(Either 模式)

使用 Result<T, E> 模式,将错误作为值传递:

ts
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }

const safePipe = <T>(...fns: Array<(arg: T) => Result<T, any>>) => {
  return (value: T): Result<T> => {
    return fns.reduce(
      (result, fn): Result<T> => {
        if (!result.ok) return result
        try {
          return fn(result.value)
        } catch (error) {
          return { ok: false, error }
        }
      },
      { ok: true, value } as Result<T>
    )
  }
}

此模式将异常处理内化,适合需要高容错的场景。

实战:数据转换流水线

ts
// 原始用户数据
const rawData = [
  { name: ' Alice ', email: 'ALICE@EXAMPLE.COM', active: '1' },
  { name: ' Bob ', email: 'BOB@EXAMPLE.COM', active: '0' }
]

// 构建处理流水线
const processUser = pipe(
  (user: any) => ({ ...user, name: user.name.trim() }), // 清理姓名
  (user) => ({ ...user, email: user.email.toLowerCase() }), // 统一邮箱
  (user) => ({ ...user, active: user.active === '1' }) // 转换状态
)

const processedUsers = rawData.map(processUser)
// [
//   { name: 'Alice', email: 'alice@example.com', active: true },
//   { name: 'Bob', email: 'bob@example.com', active: false }
// ]

代码清晰表达了“数据经过哪些步骤”,而非“如何一步步操作”。

结语:pipe 是函数式思维的入口

pipecompose 不仅是工具函数,更是函数式编程的思维方式

  • 组合优于嵌套:将复杂逻辑拆解为小函数,再组合。
  • 不可变性:每一步都返回新值,不修改原数据。
  • 声明式:描述“做什么”,而非“怎么做”。

当你使用 pipe 构建数据流水线时,你已迈入函数式编程的大门。

而类型系统的加持,让这种组合不仅是运行时的动态链接,更是编译时的类型级程序构造——这才是 TypeScript 优先设计的真正力量。