props での交差型(&)の扱いを勘違いしていた
はじめに
汎用的なコンポーネントを作る際に、交差型の使い方を間違っていたことに最近気づいたので記事にしました。
結論
あるコンポーネントをもとに props の型を拡張する際は
type InputProps = ComponentProps<typeof Input>
type Props = InputProps & {
rounded: boolean
}
export const MyInput: FC<Props> = ({ rounded, ...attrs }) => {
︙
}
こうではなく
type Merge<T, U> = Omit<T, keyof U> & U
type InputProps = ComponentProps<typeof Input>
type Props = Merge<InputProps, {
rounded: boolean;
}>
export const MyInput: FC<Props> = ({ rounded, ...attrs }) => {
︙
}
こうしましょうという記事です。
React というより TypeScript の交差型に関わる話なので、交差型を正しく理解している方には読む必要のない記事かと思います。
(私のように)交差型が型をマージするものだと勘違いしていた方の参考になればと思います。
余談: React.ComponentProps について
汎用的なコンポーネントは p
や a
のようなプリミティブな要素と同等に扱えるべきです。
React.ComponentProps
を使うことで、プリミティブな要素に渡せる props の型を定義することが可能です。
import type { ComponentProps, FC } from "react";
const style = {
︙
};
type Props = ComponentProps<"input">; // <input /> に渡せる props の型
export const Input: FC<Props> = ({ ...attrs }) => {
return <input {...attrs} style={style} />;
};
// 使う側で <input /> と同等の props を渡せる
︙
return (
<Input
type="text"
placeholder="username"
value="username"
/>
)
これについては Takepepe さんの記事で大変分かりやすくまとめられているので、詳しく知りたい方はこちらを御覧ください。
「交差型 = マージ」という勘違い
先程の例で、input 要素にはない独自の rounded
props を追加したい場合を考えます。
// 例1
import { useMemo } from "react";
import type { ComponentProps, FC } from "react";
type InputProps = ComponentProps<"input">;
type Props = InputProps & {
rounded?: boolean;
};
const baseStyle = {
︙
};
const roundedStyle = {
︙
};
export const Input: FC<Props> = ({ rounded = false, style, ...attrs }) => {
const styles = useMemo(
() => ({
...baseStyle,
...(rounded ? roundedStyle : {}),
...style,
}),
[rounded, style]
);
return <input {...attrs} style={styles} />;
};
︙
return (
<Input
type="text"
placeholder="username"
rounded={true} // boolean | undefined
/>
)
交差型を使うことで独自の props である rounded
を拡張することが出来ています。
input 要素が持っている全ての props と自分で追加した rounded
の両方に正しく型がついていそうです。
input 要素が元々持っている props の型を上書きしたい場合はどうでしょうか?
試しに value
, onChange
を必須の型にしてみましょう。
// 例2
import type { ChangeEvent, ComponentProps, FC } from "react";
type InputProps = ComponentProps<"input">;
type Props = InputProps & {
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
};
export const Input: FC<Props> = ({ value, onChange, ...attrs }) => {
return <input {...attrs} value={value} onChange={onChange} />;
};
︙
return (
// value, onChange を渡さないと型エラー
<Input
type="text"
placeholder="username"
/>
);
input
要素では元々必須でなかった value
, onChange
ですが、交差型を使うことで必須の props として定義することが出来ました。
これらの結果から
type Props = ComponentProps<typeof Component> & {
// 拡張したい型;
};
とすれば、「型をマージ出来る」と考えてしまったのが、冒頭にお話した私の勘違いです。
例2 のように、キーが競合する場合は後に書いた型が優先されマージされるという認識でした。
交差型はマージではない
この認識ではなぜダメなのでしょうか。
次の例は、元々 必須である src
をオプショナルな props に変えようとしている例です。
// 例3
import Image from "next/image";
import type { ComponentProps, FC } from "react";
type ImageProps = ComponentProps<typeof Image>;
type Props = ImageProps & {
src?: string;
};
export const MyImage: FC<Props> = ({ src, ...attrs }) => {
if (src === undefined) {
return <Image {...attrs} src={"placeholder.png"} />;
}
return <Image {...attrs} src={src} />;
};
先程の 例2 では、元々 オプショナルだった props (value
, onChange
) を必須項目に上書き出来たので、この例でも同様に上書き出来そうです。
︙
return (
// src を渡さないと型エラー
<MyImage />
);
しかし、実際は src
は必須のままで、渡さなければ型エラーになってしまいます。
TypeScript の交差型について
TypeScript の交差型はマージするものではありません。交差型はあくまで積です。
最初の例でマージされているように見えていたのは、たまたま積の結果がマージした場合の結果と一致していただけということでした。
// 例2
type InputProps = ComponentProps<"input">;
type Props = InputProps & {
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
};
// value の型
InputProps["value"] & string
↓
(string | number | readonly string[] | undefined) & string
↓
string
// 例3
type ImageProps = ComponentProps<typeof Image>;
type Props = ImageProps & {
src?: string;
};
// src の型
ImageProps["src"] & (string | undefined)
↓
(string | StaticImport) & (string | undefined)
↓
string
その為、元々存在する型を上書きしたい場合は一度 Omit
で除外してから交差型をとる必要があります。
type BaseProps = {
a: string;
};
type Props = Omit<BaseProps, "a"> & {
a?: string;
};
// => { a?: string }
これをより汎用的にするとこうなります。
type Merge<T, U> = Omit<T, keyof U> & U; // 第2型引数に含まれるキーを全て除外してから交差をとる
type Props = Merge<
BaseProps,
{
a?: string;
}
>;
個人的には、ベースとなる Props の型定義を読みに行って競合するキーがあるか確認するくらいなら、競合している前提でこの書き方に統一する方がいいと考えています。
type Base1 = { a: any };
type T = Merge<Base, { a: number }>; // { a: number }
// キーが競合していなければ Base2 & { b: number } と同じ
type Base2 = { b: string };
type U = Merge<Base, { a: number }>; // { a: number, b: string }
上の例では BaseProps に a
が存在するかすぐに確認できますが、例えば UI ライブラリのコンポーネントの型をReact.ComponentProps
で取得するような例では、a
が存在しているかは実際に型定義を読みに行かないと分からないからです。(もちろん型定義を読むことは大事ですが)
コードによって Omit
を使う場合とそうじゃない場合があるくらいなら両方 Omit
すればいいと考えていますがどうなんですかね...??
これに関しては私自身の開発経験が浅いので、ぜひ皆さんのご意見をお聞かせいただければ幸いです。
まとめ
この記事で言う「マージ」とは、キーが競合する際に上書きしながら統合することを指します
(再掲)
TypeScript の交差型はマージではないので、あるコンポーネントをもとに型を拡張する場合は注意が必要という記事でした。
認識が間違ってる箇所や、よりよい方法がありましたらご指摘いただけると幸いです 🙇🏻♂️
参考
Author And Source
この問題について(props での交差型(&)の扱いを勘違いしていた), 我々は、より多くの情報をここで見つけました https://zenn.dev/yamo/articles/8461e430933d0d著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol