I’ve had a problem with TypeScript for a long time that I thought was a problem with TypeScript.

(I can’t say that it’s a drawback, because JS works pretty well, but TS is a little bit awkward, but in fact, other languages can do this without being very straightforward, using methods such as overloading.)

Let me start with my question:

function processData(type: 1 | 2, value: number) {
  if (type= = =1 && value > 10) {
      // The actual situation would not directly return such simple data, probably through complex calculation
      return { a: 1}}else if (type= = =2) {
      // The actual situation would not directly return such simple data, probably through complex calculation
      return { b: 2}}// The actual situation would not directly return such simple data, probably through complex calculation
  return { c: 3}}const values = processData(1.100)
console.log(values.a + 1)
Copy the code

Looking at the code above, processData decides what value to return while the code is running, based on two input arguments. If we call this function elsewhere, its return value might be of one of three types:

PS: Due to the size, only two images were captured.

For this reason, a direct call to console.log(values.a + 1) returns an error because TypeScript says values.a might be undefined, which is normal.

To solve this problem, I would have forced the type directly, since I know more than the editor at this point, and I’m sure type is equal to 1 and value is greater than 10:

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

But writing a line like this makes me feel uneasy. In order to insure the period, I may write the check conditions again when using:

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

But what if there are a lot of if-else statements, do you have to do that with values, and you can imagine what a nightmare that would be.

This problem is emblematic of the many times I’m so frustrated with separating state in TypeScript that I use the above solution to avoid……

Now I’ve come up with a better solution, which is to use a type called Variant. This is a solution I’ve come up with by referring to the Either source code, and I’ll probably look back at it later and wonder if anyone who writes this code is mentally retarded.

The following code is a bit long, but it repeats a lot.

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';
}
Copy the code

In the interface, we use a constant _tag to identify the types of variants, and encapsulate the methods for setting and identifying variants.

The code used becomes:

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);
}
Copy the code

This way it is very safe to use. In fact, in normal work, the judgment condition will change from time to time, and if we use the old solution, if the judgment condition changes, we will change not only processData, but also other uses of its return value. But with Variant, we fix the query criteria inside processData, which improves the maintainability of the code.

We have expanded three variants this time. According to the same logic, we can expand more, depending on which one is the most in the project.

In addition to the above example, we can also apply to the interface return value. Sometimes, the back end may return different data structures according to the input parameter. In this case, we can use the Variant type to handle this.

That said, I was a little surprised to learn that you can separate TypeScript states in this way

What makes me curious is why FP-TS doesn’t offer similar functionality. But maybe IT’s because I didn’t find it.