網站搜尋

TypeScript 將類型縮小至 `never` 不如預期


在工作的一個專案中(TypeScript 5.1.3),我偶然發現了在應用程式的抽象層傳遞錯誤的以下慣例:

export class Success<E, S> {
  readonly value: S;

  constructor(value: S) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return true;
  }

  isError(): this is MyError<E, S> {
    return false;
  }
}

export class MyError<E, S> {
  readonly value: E;

  constructor(value: E) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return false;
  }

  isError(): this is MyError<E, S> {
    return true;
  }
}

export type Either<E, S> = MyError<E, S> | Success<E, S>;

export const error = <E, S>(value: E): Either<E, S> => {
  return new MyError(value);
};

export const success = <E, S>(value: S): Either<E, S> => {
  return new Success(value);
};

因此,每個函數都將使用 Either 輔助類型來指定返回類型,然後返回 success(value) 或 error(value),然後被調用方使用 .isError() 或 isSuccess() 函數來評估結果。這裡是一個例子:

type SomeErrorType = { message: string };

function otherFunction(): Either<SomeErrorType, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    // in this case will evaluate to false since otherFunction returns a success("some value")
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  // will evaluate to true
  console.log(result.value);
}
main();

困擾我的是,如果我將 Either 的錯誤類型設為 any 或 unknown,並嘗試先處理 isError(),那麼結果會縮小為 never 類型。例如:

function otherFunction(): Either<string, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  console.log(result.value);
  // i can't access result.value because result is now narrowed to `never`
}
main();

我不明白為什麼這會被評估為 never 而不是 any。如果我將 Either 上的 Error 設為 boolean,它會正確縮小為 boolean,但設為 string 時,結果也縮小為 never。

答案#1

TypeScript 的型別系統大多是結構性的,這意味著如果兩個型別擁有相同的 結構 或 形狀(也就是相同型別的相同成員),那麼 TypeScript 就會認為這兩個型別是 相同型別。不幸的是,你對 Success<E, S> 和 MyError<E, S> 的定義只是將 E 和 S 交換而已。也就是說,TypeScript 無法分辨 Success<T, U> 與 MyError<U, T>。而 Either<T, T> 就是聯集型別 Success<T, T> | MyError<T, T>,它 看起來 像是兩個型別,但實際上會合併成只有一個型別。

在結構類型系統中,這是一個不佳的設計選擇。你應該在 Success 和 MyError 中添加一個區別成員,像這樣:

export class Success<E, S> {
  readonly success = true; // true, structurally distinct from MyError
  // ✂ ⋯ snip ⋯ ✂
}

export class MyError<E, S> {
  readonly success = false; // false, structurally distinct from Success
  // ✂ ⋯ snip ⋯ ✂
}

在這裡,我在兩者中都添加了一個 success 屬性,其中一個是字面類型 true,另一個是字面類型 false。這些是不同的類型,所以 TypeScript 不會再將 Success<T, U> 和 MyError<U, T> 混淆。

這會解決你的問題,但問題是關於 為什麼 會發生這種情況,所以我會繼續解釋這一點。


如我所提到的,你的 Success<T, U> 和 MyError<U, T> 在結構上是相同的。但情況比這更糟。Success<E, S> 在 S 上是協變的,並且完全獨立於 E,因此在 E 上是雙變的(參見 TypeScript 中變異、協變、逆變、雙變與不變的差異)。所以只要 S1 extends S2,就有 Success<E1, S1> extends Success<E2, S2>,並且只要 E1 extends E2,就有 MyError<E1, S1> extends MyError<E2, S2>。 所以對 TypeScript 來說,很容易把它們搞混。Success<E1, S1> extends MyError<E2, S2> 只要 E1 extends S2 或 S1 extends E2,這就意味著 Either<E, S> 實際上並不是兩個不同類型的聯合,只要 E extends S 或 S extends E。所以如果 S 和 E 相同,或者其中一個是像 unknown 或 any 這樣的寬泛類型,聯合裡的其中一種類型就會完全吸收另一個類型。

這意味著,如果你寫了一個類型謂詞,旨在將 Either<E, S> 縮小為 MyError<E, S>,那麼當類型謂詞返回 false 時,TypeScript 會從 Either<E, S> 中排除 MyError<E, S>。 但是在 Success<E, S> extends MyError<E, S> 的情況下,TypeScript 會從聯合類型中排除 兩者 Success 和 Error。 它會將範圍縮小到 never。

每當你有 Either<string, string> 或 Either<any, string> 或 Either<unknown, string> 時,就會發生這種情況。對於 Either<string, string>,TypeScript 說:“嗯,如果它不是 MyError<string, string>,那它也 不可能 是 Success<string, string>,因為它們是相同的類型。”對於 Either<any, string>,TypeScript 說:“嗯,如果它不是 MyError<any, string>,那它也 不可能 是 Success<any, string>,因為 string extends any。”糟糕。


再次,在運行時,MyError 和 Success 是兩個來自不同 聲明 的不同類,這並不是 TypeScript 能夠輕易“看到”的。TypeScript 的型別系統不是名義型別(nominal),所以它通常不在意兩個型別是否在不同地方或用不同名稱 聲明。讓 TypeScript 將兩個型別視為不同的方法是給它們不同的結構。一個類型為 true 或 false 的單一 success 屬性就足以讓 Either<E, S> 成為辨識聯合型別,但任何具區分性的結構也都可以。

程式碼的 Playground 連結