如何优雅的在 TS 中分离状态

749 阅读4分钟

有一个问题困扰我很久了,在之前,我甚至都以为是 TypeScript 的弊端。

(也不能说弊端,是觉得 JS 可以写得很顺畅,但是到了 TS 就会有一点别扭,但是事实上,其他语言想做到,也没有很直接,也得通过使用诸如重载这样的手段)

先来说一下我的问题:

function processData(type: 1 | 2, value: number) {
  if (type === 1 && value > 10) {
      // 真实情况不会直接返回这么简单的数据,可能经过了复杂的计算
      return { a: 1 } 
  } else if (type === 2) {
      // 真实情况不会直接返回这么简单的数据,可能经过了复杂的计算
      return { b: 2 }
  }

  // 真实情况不会直接返回这么简单的数据,可能经过了复杂的计算 
  return { c: 3 }
}

const values = processData(1, 100)
console.log(values.a + 1)

且看上面这段代码,processData 会根据两个入参,在代码运行的时候来决定返回的值,如果我们在其他地方调用此函数,它的返回值可能就是三种类型:

image.png

PS: 大小原因,图片只截取了两个。

基于此原因,我们直接调用 console.log(values.a + 1) 会报错,因为 TypeScript 判断的结果是:values.a 可能是 undefined,这也是正常现象。

image.png

为了解决这个问题,如果是之前的话,我就会直接强制指定类型,毕竟此刻我知道的比编辑器多,我确定 type 等于 1 并且 value 大于 10:

console.log(values.a as number + 1);

但是写下这么一行的时候,不免让我觉得很不安。为了保险期间,我在使用的时候还可能会把校验条件写一遍:

 if (type === 1 && value > 10) {
    console.log(values.a as number + 1);    
 }

但是 if-else 语句很多的时候怎么办,在用值的时候也得这么写吗,你肯定也可以想象的到,那会是一个多么可怕的噩梦。

这个问题是一个代表,很多时候,我都对在 TypeScript 下分离状态比较束手无策,以至于使用上面的解决方案来逃避......

现在我想到了另外一个相对更好的解决方案,那就是使用一个叫做 Variant 的类型。下面这个方案是我参考 Either 的源码胡想的,我也是刚学习这一块,可能过一段时间再回来看,会觉得:这么写代码的人是智障吗?

下面这段代码虽然有点长,但是重复的部分很多。

export type Variant<T1, T2, T3> = Variant1<T1> | Variant2<T2> | Variant3<T3>

interface Variant1<T> {
  readonly _tag: 't1'
  readonly value: T
} 

interface Variant2<T> {
  readonly _tag: 't2'
  readonly value: T
} 

interface Variant3<T> {
  readonly _tag: 't3'
  readonly value: T
} 

function makeVariant1<T1, T2 = never, T3 = never>(e: T1): Variant<T1, T2, T3> {
  return {
    _tag: 't1',
    value: e
  }
}

function makeVariant2<T2, T1 = never, T3= never>(e: T2): Variant<T1, T2, T3> {
  return {
    _tag: 't2',
    value: e
  }
}

function makeVariant3<T3, T1 = never, T2= never>(e: T3): Variant<T1, T2, T3> {
  return {
    _tag: 't3',
    value: e
  }
}

function isVariant1<T>(e: Variant<T, unknown, unknown>): e is Variant1<T> {
  return e._tag === 't1';
}

function isVariant2<T>(e: Variant<unknown, T, unknown>): e is Variant2<T> {
  return e._tag === 't2';
}

function isVariant3<T>(e: Variant<unknown, unknown, T>): e is Variant3<T> {
  return e._tag === 't3';
}

在接口中,我们借助一个常量 _tag 来标识各个 Variant 的类型,同时封装了设置 Variant 和判断是哪一种 Variant 的方法。

使用的代码就变成了:

function processData(type: 1 | 2, value: number) {
  if (type === 1 && value > 10) {
      return makeVariant1({ a: 1 })
  } else if (type === 2) {
      return makeVariant2({ b: 2 })
  }

  return makeVariant3({ c: 3 })
}

const values = processData(1, 100);
if (isVariant2(values)) {
  console.log(values.value.b + 1);
}

这样子用起来就特别放心。其实,在平常工作中,判断条件是时不时的就会变的,如果我们使用旧的解决方法,判断条件一旦改变,我们要改的不止 processData,还有其他使用到它返回值的地方。但是使用了 Variant,我们把查询条件固定到了 processData 的内部,这样就提高了代码的可维护性。

我们这次就扩展了 Variant 的三个,依照同样的逻辑,我们还可以扩展更多,这个就具体根据项目中哪种最多了。

除了上面那个示例,我们还可以应用在接口返回值上面,有的时候后端会根据入参的不同,返回不同的数据结构,此时我们就可以借助 Variant 类型去处理。

说起来,这种写法还是让我小小的震惊了一下,原来还可以用这种方式分离 TypeScript 的状态,学到了 ~

让我好奇的是,为什么 fp-ts 不提供类似的功能。不过,也可能是因为我没有找到。