简要介绍fp-ts(二)

1,991 阅读6分钟

Option

Option的 kind 为 * -> *。也就是说,它和 Array 一样可以接受一个 concrete type a,产生另一个concrete type Option a。我们可以把Option a想象成一个盒子,里面可能存放着一个类型为a的值,也可能没有。Option在 fp-ts 中是这样定义的:

type Option<A> = None | Some<A>;

在 TypeScript 中,我们通常是使用undefined或者null类型来表示某个值可能不存在的。比如:

type CurrentUser = User | null;

那么相比上面这个定义,使用Option<User>来表示同样的语义所带来的好处是什么呢?假设,我们现在要实现这样一个函数:传入当前用户作为参数,如果用户存在,则返回用户的 name;否则,返回undefined。User 类型定义如下:

interface User {
  name: string;
  age: number;
  image?: {
    url: string;
  };
}

如果使用null来表示值的缺失,那么我们就需要使用if语句对 currentUser 是否存在进行判断,代码差不多会是这样:

function foo(currentUser: CurrentUser) {
  if (currentUser) {
    return currentUser.name;
  }
  return undefined;
}

而使用 Option<User> 版本的代码如下:

import * as O from "fp-ts/Option";
import { pipe } from "fp-ts/function";

function foo(currentUser: Option<User>) {
  return pipe(
    currentUser,
    O.map((user) => user.name),
    O.toUndefined
  );
}

其中,map函数是Functor这个 typeclass 定义的函数,就如同 Eq 定义了 equals 一样。map函数的类型定义如下:

const map: <A, B>(f: (a: A) => B) => (fa: Option<A>) => Option<B>

也就是说,map接受一个类型为a -> b的函数,返回一个类型为Option a -> Option b的函数。这个操作被称为lift。类型为Option a -> Option b意味着它知道如何处理值不存在的情况。来看map函数的实现:

const map: <A, B>(f: (a: A) => B) => (fa: Option<A>) => Option<B> = (f) => (fa) =>
  isNone(fa) ? none : some(f(fa.value))

原来,map函数偷偷的为我们对值是否存在进行了判断。在上面的代码中,O.map((user) => user.name) 返回了一个类型为Option User -> Option string的函数。该函数在接受 currentUser 之后,返回了一个类型为Option string的值,表示里面可能存在着用户名。而O.toUndefined就是把Option<string>类型变为了普普通通的string | undefined。这里插一句,map这个名字很容易让人想到array.map,果然Array也有Functor的 instance,而array.map就可以看作为 Array版本的map函数的实现。

假如现在需求改变了,我们要返回用户的 image 链接地址(image 可能不存在)。这时候我们需要判断的条件就增加了:

function foo(currentUser: CurrentUser) {
  if (currentUser && currentUser.image) {
    return currentUser.image.url
  }
  return undefined;
}

在optional chaining operator(?.)出现之前,为了取一个复杂的对象的属性而写出类似

if (foo && foo.bar && foo.bar.baz) {
  // ...
}

这样的代码是非常常见的。那Option能不能帮助我们避免这样的代码呢?我们继续使用 map 函数来帮助我们避免书写 if 语句:

interface User {
  name: string;
  age: number;
  image: Option<{
    url: string;
  }>;
}

function foo(currentUser: Option<User>) {
  return pipe(
    currentUser,
    map(u => pipe(u.image, map(i => i.url))),
    // ...
  );
}

这看起来不太妙,由于u.image仍然是一个Option,因此我们需要嵌套使用map来对u.image转换成为类型为Option string的值。这样嵌套使用map不仅麻烦,而且最终返回的类型也变成了Option (Option string)。如果对象变得更复杂一些,Option 就会像套娃一样层次变得越来越多,这也会导致我们需要使用的map嵌套层次也越来越多。这肯定不是非常理想的情况。我们希望能够将Option (Option string)类型值变成Option string类型的值。那么就来实现这样的一个函数flatten

const flatten = <A>(mma: Option<Option<A>>): Option<A> =>
  isNone(mma) ? none : mma.value;

有了flatten,我们就可以继续完成这个函数了:

function foo(currentUser: Option<User>) {
  return pipe(
    currentUser,
    map((u) =>
      pipe(
        u.image,
        map((i) => i.url)
      )
    ),
    flatten
  );
}

实际上,fp-ts 已经为我们提供了flatten函数,只是它的实现有点不太一样:

const flatten: <A>(mma: Option<Option<A>>) => Option<A> = chain(identity)

怎么又来了一个chain函数?chain函数是typeclass Monad所定义的函数,它的实现如下:

const chain: <A, B>(f: (a: A) => Option<B>) => (ma: Option<A>) => Option<B> = (f) => (ma) =>
  isNone(ma) ? none : f(ma.value)

代入identify,得到的结果和我们写的flatten函数是完全一致的,也就是说两种写法是等价的。

观察之后可以发现chainmap的类型很相似。上面我们就是把a -> Option b类型的函数传给了map,得到了一个类型为Option a -> Option (Option b)的函数,而如果把同样的函数传给chain,它将返回给我们类型为Option a -> Option b的函数,就好像自动为我们对结果进行了flatten。那么,我们尝试直接将mapflatten替换为chain

function foo(currentUser: Option<User>) {
  return pipe(
    currentUser,
    chain((u) =>
      pipe(
        u.image,
        map((i) => i.url)
      )
    ),
  );
}

结果和上面的版本完全一样。

Option还有其他的 typeclass 的 instance,以后看情况再进行补充。下面来看一个 fp-ts 使用Option的例子:

fp-ts/Array 提供的用于查找数组元素的findFirst函数类型定义如下:

interface Predicate<A> {
  (a: A): boolean
}

function findFirst<A>(predicate: Predicate<A>): (as: Array<A>) => Option<A>

findFirst 根据提供的 predicate 在数组中寻找满足条件的元素,如果存在则返回Some<A>;否则,返回NoneOption经常被用来表示计算结果可能成功或者失败,但是它不包含失败的具体信息。

Either

在函数式编程中,不采用throw exception的方法来处理异常,而是通过返回一个Either E A来表示计算结果可能出错。相比起Etihertry-catch并不是 type-safe 的,编译器不知道 try 语句将会抛出什么样的错误。而相比起OptionEither多了更多关于失败的信息。fp-ts中的Either定义如下:

Either<E, A> = Left<E> | Right<A>

Either的 kind 是* -> * -> *,它可以接受最多两个 type。可惜的是,要想成为Functor俱乐部的一员,类型的 kind 必须是* -> *。这也意味着Either不支持我们为它定义Functor的instance,不过还好Either e的 kind 是* -> *。于是我们为Either e定义map函数:

const map: <A, B>(f: (a: A) => B) => <E>(fa: Either<E, A>) => Either<E, B> = (f) => (fa) =>
  isLeft(fa) ? fa : right(f(fa.right))

Either e意味着Either的第一个变量不再自由,map或者chain都不能将第一个变量替换成除了e之外的其他类型了。比如,map的作用是将类型为a -> b的函数 lift 成为Either e a -> Either e b的函数。观察map的实现,可以发现它对Left的处理,就如同Optionmap对于None的处理。除此之外,Either eOption还有很多相似之处,这里就不做过多展开了。看一个使用Either的例子,然后就进入到下一个类型吧:

function stringify(
  ...args: Parameters<typeof JSON.stringify>
): Either<Error, string> {
  try {
    const stringified = JSON.stringify(...args);
    if (typeof stringified !== 'string') {
      throw new Error('Converting unsupported structure to JSON')
    }
    return right(stringified);
  } catch (error) {
    return left(error);
  }
}

如果不是查阅文档,开发者可能并不知道JSON.stringify是有可能抛出错误的,而使用 Either Error string作为返回值的stringify可能失败是一目了然的。