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函数是完全一致的,也就是说两种写法是等价的。
观察之后可以发现chain和map的类型很相似。上面我们就是把a -> Option b类型的函数传给了map,得到了一个类型为Option a -> Option (Option b)的函数,而如果把同样的函数传给chain,它将返回给我们类型为Option a -> Option b的函数,就好像自动为我们对结果进行了flatten。那么,我们尝试直接将map和flatten替换为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>;否则,返回None。Option经常被用来表示计算结果可能成功或者失败,但是它不包含失败的具体信息。
Either
在函数式编程中,不采用throw exception的方法来处理异常,而是通过返回一个Either E A来表示计算结果可能出错。相比起Etiher,try-catch并不是 type-safe 的,编译器不知道 try 语句将会抛出什么样的错误。而相比起Option,Either多了更多关于失败的信息。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的处理,就如同Option的map对于None的处理。除此之外,Either e和Option还有很多相似之处,这里就不做过多展开了。看一个使用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可能失败是一目了然的。