這篇文章的撰寫緣由是我在React中某一元件要傳入兩種不同的物件陣列:

interface UserInfo {
  generalInfo: GeneralInfoTemplate;
  educationExps: Array<EducationExpInfoTemplate>;
  practicalExps: Array<PracticalExpInfoTemplate>;
}

type UserInfoKeys = Exclude<keyof UserInfo, "generalInfo">;

interface Props {
  block: UserInfoKeys;
  blockKey: string;
  setInputs: React.Dispatch<React.SetStateAction<UserInfo[UserInfoKeys]>>;
  setAppUserData: React.Dispatch<React.SetStateAction<UserInfo>>;
}

簡單來說,我想把const [state, setState] = React.useState()這類的setState用props傳給子元件,然後這個setState是設置UserInfo物件中的educationExps「或」praticalExps用的,結果當我使用setInputs把這兩種之一的值傳進時報錯了。

這個錯誤,也是簡單來說,他說我educationExps沒有praticalExps的屬性,praticalExps沒有educationExps屬性…(怎麼聯集變交互比對了?)

我把這個狀況另外寫個測試重現出來如下:

CodeSandbox連結(問題)

type Foo = { one: number; two: number };
type Bar = { three: number; four: number };

type Foobar = Foo | Bar;
interface Props {
  value: Foobar;
  setValue: React.Dispatch<React.SetStateAction<Foobar>>;
}
/** This will be used in App component below */
function Beep(props: Props) {
  const boop = Object.assign({}, props.value);

  return (
    <>
      <div>{"one" in boop ? boop.two : boop.four}</div>
    </>
  );
}

export default function App() {
  const [foo, setFoo] = React.useState({ one: 1, two: 2 });
  const [bar, setBar] = React.useState({ three: 3, four: 4 });
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      /** setValue will have an error */
      <Beep value={foo} setValue={setFoo} />
      <Beep value={bar} setValue={setBar} />
    </div>
  );
}
  • 在App的return中,Beep這個元件的屬性setValue會報錯。

原因

參考Stackoverflow上的這則回答;先是TypeScript(以下簡稱TS)官網在(舊頁面的)Type inference in conditional types裡提到:

“Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:”

然後回答者給出的整理:

It means, if you want to call such function, arguments of all functions in a union will merge (intersect), that why setValue expects React.SetStateAction<Animal> & React.SetStateAction<Cat>.

當在函式的引數中使用聯集(Union)會造成TS判斷為交集。

知道這點後,來試著修正該回覆中舉例會報錯的例子:

除錯

  1. 一種做法是用Generic type然後用Ternary operator判斷該值是否繼承自unknown,是的話要給攤開後foobar的形式;如果給的是foo | bar還是會報錯:

    (TS Playground)

declare const foo: (val: { name: string }) => void;
declare const bar: (val: { age: number }) => void;

type ToEither<T> = T extends unknown ? (val: { name: string } | { age: number }) => void : never;

declare const union: ToEither<typeof foo | typeof bar>;

union(foo)
union(bar)
  1. 或許還不夠簡潔,於是我們可以把ToEither的結構改為foobar的函式形式,然後把foobar提取出來改成參數:

    (TS Playground)

type ToEither<T> = (val: T) => void;

declare const foo: {name: string}
declare const bar: {phone: number}
declare const union: ToEither<typeof foo | typeof bar>;

當然實務上不見得抽得出來,這時第一種作法就派得上用場了。

至於一開始提到在React的錯誤,用剛才學到的辦法就可以除錯:

CodeSandbox連結(解答)

interface Props<T> {
  value: Foobar;
  setValue: React.Dispatch<React.SetStateAction<T>>;
}
function Beep<T extends Foobar>(props: Props<T>) {
  const boop = Object.assign({}, props.value);

  return (
    <>
      <div>{"one" in boop ? boop.two : boop.four}</div>
    </>
  );
}

網友提供的方法

針對一開始的 React 問題,這兩個方法比較單純,但在 inline type 的提示上就沒有上面的方法來的詳細 ,把滑鼠移到 valuesetValue 上可以看到在這三種方法上的差別:

<Beep value={foo} setValue={setFoo} />
<Beep value={bar} setValue={setBar} />

使用文章中提到的 extends 方法的會是:

(JSX attribute) Props<{ one: number; two: number; }>.setValue: React.Dispatch<React.SetStateAction<{
    one: number;
    two: number;
}>>

網友提供的一種方法是簡單給 useState type parameter:

const [foo, setFoo] = React.useState<Foobar>({ one: 1, two: 2 });
const [bar, setBar] = React.useState<Foobar>({ three: 3, four: 4 });

Inline type 會是:

(JSX attribute) Props.setValue: React.Dispatch<React.SetStateAction<Foobar>>

另一種方法是把 type FoobarsetValue property 攤開來:codesandbox link

interface Props {
    value: Foobar;
    setValue: ((value: Foo) => void) | ((value: Bar) => void);
}

setValue 的 inline type 會是:

(JSX attribute) Props.setValue: ((value: Foo) => void) | ((value: Bar) => void)

文章就到這邊,謝謝閱讀😄