[typescript] React Component Discriminated Unions (可辨識複合型別)
介紹
今天要介紹如何使用 typescript 的 discriminated unions 來建立更彈性化的 component。
問題點
直接看範例程式碼,在 App 內我們根據 goals 的長度來去決定 InfoBox 這個 component 要帶入的參數(props)是什麼。
type CourseGoalListProps = {
goals: CGoal[];
onDeleteGoal: (id: number) => void;
};
const App = ({ goals, onDeleteGoal }: CourseGoalListProps) => {
if (goals.length === 0) {
return <InfoBox mode="hint">You have no course goals yet!</InfoBox>;
}
let warningBox: ReactNode;
if (goals.length >= 4) {
warningBox = (
<InfoBox mode="warning" severity="high">
You're collection a lot of goals!
</InfoBox>
);
}
return <>...</>;
};
而 InfoBox 的程式碼如下,我們可以看到 InfoBox 必須傳入 mode、severity、children 這三個 props,只要少一個就會出現錯誤。
所以現在 App 的第 8 行程式碼會出現 Property 'severity' is missing in type '{ children: string; mode: "hint"; }' but required in type 'InfoBoxProps'. 這個錯誤,因為我們少傳入了 severity 這個 prop。
但我們希望在 goals.length === 0 也就是 mode === "hint" 的時候,可以不用傳入 severity。
type InfoBoxProps = {
mode: "warning" | "hint";
severity: "low" | "medium" | "high";
children: ReactNode;
};
const InfoBox = (props: InfoBoxProps) => {
const { children, mode } = props;
if (mode === "hint") {
return (
<aside className="infobox infobox-hint">
<p>{children}</p>
</aside>
);
}
const { severity } = props;
return (
<aside className={`infobox infobox-warning warning--${severity}`}>
<h2>Warning</h2>
<p>{children}</p>
</aside>
);
};
解決方法 (1) Optional
這時候你可能會想說,把 severity 變成 optional(?) 的就好了吧?
type InfoBoxProps = {
mode: "warning" | "hint";
severity?: "low" | "medium" | "high";
children: ReactNode;
};
const InfoBox = (props: InfoBoxProps) => {
const { children, mode } = props;
if (mode === "hint") {
return (
<aside className="infobox infobox-hint">
<p>{children}</p>
</aside>
);
}
const { severity } = props;
return (
<aside className={`infobox infobox-warning warning--${severity}`}>
<h2>Warning</h2>
<p>{children}</p>
</aside>
);
};
這樣做的確可行,但現在在 goals.length >= 4 也就是 mode==="warning" 的條件下去渲染 InfoBox 會有一個問題發生,就是即使我們不傳入 severity 也不會出現任何錯誤,這顯然不是我們想要的。
...
const App = ({ goals, onDeleteGoal }: CourseGoalListProps) => {
if (goals.length === 0) {
return <InfoBox mode="hint">You have no course goals yet!</InfoBox>;
}
let warningBox: ReactNode;
if (goals.length >= 4) {
warningBox = (
// 把 severity="high" 拿掉也不會出現錯誤
<InfoBox mode="warning">
You're collection a lot of goals!
</InfoBox>
);
}
return <>...</>;
};
解決方法 (2) Discriminated Unions
較佳的解決方式是使用 Discriminated Unions,程式碼如下,可以看到我們將 mode 為 hint 及 mode 為 warning 的 type 拆開來了,並且宣告 InfoBoxProps 為 Discriminated Unions。
type HintBoxProps = {
mode: "hint";
children: ReactNode;
};
type WarningInfoBoxProps = {
mode: "warning";
severity: "low" | "medium" | "high";
children: ReactNode;
};
type InfoBoxProps = HintBoxProps | WarningInfoBoxProps;
const InfoBox = (props: InfoBoxProps) => {
const { children, mode } = props;
if (mode === "hint") {
return (
<aside className="infobox infobox-hint">
<p>{children}</p>
</aside>
);
}
const { severity } = props;
return (
<aside className={`infobox infobox-warning warning--${severity}`}>
<h2>Warning</h2>
<p>{children}</p>
</aside>
);
};
這時候如果我們在 mode = "warning" 的時候,將 severity prop 拿掉,typescript 會出現 Property 'severity' is missing in type '{ children: string; mode: "warning"; }' but required in type 'WarningInfoBoxProps'. 錯誤,我們就知道必須傳入 severity prop。
const App = ({ goals, onDeleteGoal }: CourseGoalListProps) => {
if (goals.length === 0) {
return <InfoBox mode="hint">You have no course goals yet!</InfoBox>;
}
let warningBox: ReactNode;
if (goals.length >= 4) {
warningBox = (
// 把 severity="high" 拿掉會出現錯誤
<InfoBox mode="warning">You're collection a lot of goals!</InfoBox>
);
}
return <>...</>;
};
而使用 Discriminated Unions 的好處還有一點,就是當我們在 mode === "hint" 的情況下,如果去解構 severity,則會出現 Property 'severity' does not exist on type 'HintBoxProps'. 錯誤。
因為 typescript 知道 severity 並不存在 HintBoxProps 內。


如果改成 warning 的話,就不會出現這個錯誤,因為 severity 有存在 WarningInfoBoxProps 內。


參考資料
udemy 課程 React & TypeScript - The Practical Guide Academind by Maximilian Schwarzmüller, Maximilian Schwarzmüller