網站搜尋

JavaScript - TypeScript 判別聯合


我正在嘗試了解如何強制執行區分聯合類型。我有一個類型定義,技術上 TypeScript 會接受,但對我來說並沒有如預期般運作。

注意:我之前忘了提到,我需要這些類型定義是為了一個函數型元件。

有一種官方方法可以定義區別聯合類型。然而,在 IDE 中,它會拋出以下錯誤:Property 'startDate' does not exist on type 'MyComponentProps'.ts(2339)

TypeScript 接受的範例,但在 IDE 中顯示錯誤

import { FC } from "react";

interface BaseProps {
  name: string;
  age: number;
}

type IsEligibleProps = {
  eligible: true;
  startDate: string;
};

type NotEligibleProps = {
  eligible: false;
  reason: string;
};

type MyComponentProps = BaseProps & (IsEligibleProps | NotEligibleProps);

const MyComponent: FC<MyComponentProps> = ({
  name,
  age,
  eligible = false,
  startDate,
  reason
}) => {
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <p>Eligible: {eligible ? "true" : "false"}</p>
      <p>Response: {eligible ? startDate : reason}</p>
    </div>
  );
};

export default MyComponent;

我已經設法找到一個低效的解決方案,這個方案要求我為所有可能的變體重新聲明所有的屬性,但將我不需要的屬性設置為 null/never。

TypeScript 可接受的範例,且不會拋出任何錯誤

import { FC } from "react";

interface BaseProps {
  name: string;
  age: number;
}

type EligibleProps =
  | {
      eligible: true;
      startDate: string;
      reason?: never;
    }
  | {
      eligible?: false;
      reason: string;
      startDate?: never;
    };

type MyComponentProps = BaseProps & EligibleProps;

const MyComponent: FC<MyComponentProps> = ({
  name,
  age,
  eligible = false,
  startDate,
  reason
}) => {
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <p>Eligible: {eligible ? "true" : "false"}</p>
      <p>Response: {eligible ? startDate : reason}</p>
    </div>
  );
};

export default MyComponent;

我的問題是——有沒有辦法使用原始的區分聯合定義,並讓函數式組件也能讀取它,並在解構的 props 列表上確定相應的適當 props?

錯誤程式碼的連結

鏈接到可運行但效率低下的代碼

答案#1

目前,你只能將聯合類型解構成物件,如果該物件的屬性在聯合的每個成員中都存在。通常這是一個合理的限制,但當你有一個可區分的聯合類型時,它會解除這個限制。Microsoft/TypeScript#46318 有一個開放的功能請求標記為「等待更多反饋」,所以如果你希望這個功能實現,給該議題點個 👍 並可能描述一下你的使用案例為何具有說服力也無妨。(可能獲得單一的額外投票幫助不大……但也不會有壞處。)


在那之前,除非它被實現,否則你需要想辦法迴避。一種解決方法就是放棄拆解相關屬性,而以「正常」的方式訪問它們:

const MyComponent: FC<MyComponentProps> = ({
  name,
  age,
  ...rest
}) => {
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <p>Eligible: {rest.eligible ? "true" : "false"}</p>
      <p>Response: {rest.eligible ? rest.startDate : rest.reason}</p>
    </div>
  );
};

這裡的 rest 只是對應於聯合辨識符及其屬性的物件部分。然後,不再引用 eligible、startDate 或 reason,而是訪問 rest.eligible、rest.startDate 和 rest.reason,這樣就可以了。


另一種變通方法是增強區分聯合型別,使每個成員都提及所有其他成員的所有屬性,並通過將「交叉屬性」設為不可能的 never 型別的可選屬性來明確禁止它們。 這將確保當 eligible 為 true 時,reason 應被認為是 undefined。 暫且沒有更好的術語,我稱這種方法為對聯合型別進行「排他化」(exclusifying)。

事實上,這就是你所說的「低效率」的技術。幸運的是,你不需要手動做這件事。相反地,你可以寫一個實用型態函數來自動排他聯集。它可能看起來像這樣:

type Exclusify<
  T extends object,
  K extends PropertyKey = AllKeys<T>
> =
  T extends unknown ? (
    { [P in K]?: P extends keyof T ? unknown : never } & T
  ) extends infer U ? { [P in keyof U]: U[P] } : never : never;

 type AllKeys<T> = T extends unknown ? keyof T : never;

本質上,這是一種類型的分佈式條件,將 T 分解為其聯合成員(這就是 T extends unknown ? ⋯ : never 部分),並對每個成員,與任意聯合成員的所有鍵交集成一個物件類型,對於當前成員中不存在的任何鍵,類型為 never。

請注意,「來自任何聯合成員的所有鍵」需要在我們將 T 拆分成聯合成員之前計算;我通過引入 K 並給它一個默認的泛型類型參數 AllKeys<T> 來做到這一點,這是一個獨立的分配條件類型。

交集有時看起來也很醜,因此我已經使用在“如何查看 TypeScript 類型的完整展開合約?”中討論的技術將它們合併成單一物件類型。

讓我們來測試它:

type MyComponentProps = Exclusify<BaseProps & (IsEligibleProps | NotEligibleProps)>;

/* type MyComponentProps = {
    name: string;
    age: number;
    eligible: true;
    startDate: string;
    reason?: undefined;
} | {
    name: string;
    age: number;
    eligible?: false | undefined;
    startDate?: undefined;
    reason: string;
} */

看起來不錯;那是你之前使用的同一類型(所以你知道它可以用),但現在你可以添加任意多的聯合成員,它應該會自動維護。

程式碼的 Playground 連結

答案#2

我認為你的第一個程式碼完成了這個任務。

介面 BaseProps { name: string; age: number; }

type IsEligibleProps = {
  isEligible: true;
  startDate: string;
};

type NotEligibleProps = {
  isEligible: false;
  reason: string;
};

export type EligibleProps = BaseProps & (IsEligibleProps | NotEligibleProps);


const myArr: EligibleProps[] =  [{
        name:"Spongebob",
        age:42,
        isEligible: true,
        startDate:"1/1/21"
    },{
        name:"Spongebob",
        age:42,
        isEligible:false,
        reason:"Test"
    }]

更新:

我想提供最靈活的解決問題選項。我們真的需要一個具有所有屬性的介面。我決定將其值用作caption和val。這樣,我們就可以創建一個具有所需排序和標題設置的對象。

介面:

...

interface Mixed extends BaseProps {
  eligible: boolean;
  startDate: string;
  reason: string;
}

export type MyComponentMixProps = {
  [Property in keyof Mixed as string]: {
    caption: string,
    val: string
  }
}

組件:

import { MyComponentProps, MyComponentMixProps  } from "./interface";

const MyComponent: FC<MyComponentProps> = props => {

  const getOrdredProps = () => {
    // set an order and captions
    const order: MyComponentMixProps = {
      name: { caption: 'Name', val: ''},
      age: { caption: 'Age', val: ''},
      eligible: { caption: 'Eligable', val: ''},
      startDate: { caption: 'Start date', val: ''},
      reason:{ caption: 'Reason', val: ''},
    }
    // assign eligible=false (when it isn't passed)
    const eligibleProps = props.eligible 
      ? props 
      : Object.assign({eligible: false}, props)

    // set an order and stringify values 
    for(const [k, v] of Object.entries(eligibleProps)){
      order[k].val = v.toString()
    }
    // filter empty values
    const result = Object.fromEntries(Object.entries(order)
      .filter(([_, {val}]) => val !== ''))
    return  result
  } 

  return (
    <div>
      { Object.entries(getOrdredProps())
        .map(([key, {caption, val}]) => (
          <p key={key}>{caption}: {val}</p>
      ))
    }
    </div>
  );
};

連結到 codesandbox