🙀

props での交差型(&)の扱いを勘違いしていた

2022/05/07に公開

はじめに

汎用的なコンポーネントを作る際に、交差型の使い方を間違っていたことに最近気づいたので記事にしました。

結論

あるコンポーネントをもとに 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 について

汎用的なコンポーネントは pa のようなプリミティブな要素と同等に扱えるべきです。
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 さんの記事で大変分かりやすくまとめられているので、詳しく知りたい方はこちらを御覧ください。
https://zenn.dev/takepepe/articles/atoms-type-definitions

「交差型 = マージ」という勘違い

先程の例で、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) & stringstring
// 例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 の交差型はマージではないので、あるコンポーネントをもとに型を拡張する場合は注意が必要という記事でした。

認識が間違ってる箇所や、よりよい方法がありましたらご指摘いただけると幸いです 🙇🏻‍♂️

参考

Discussion