複数の責務を負うReactコンポーネントの型定義

この記事は、 KENTEM TechBlog アドベントカレンダー2023 1日目、12月1日の記事です。

筆者はKENTEMでWEBフロントエンドエンジニアをしています。

TypeScriptは型を全く使わずに全てanyにすることもできる一方で、複雑な型関数を構築することもできるため、どこまで型を厳密にするべきか悩むことが多いです。

そこで今回は少し丁寧なReact × TypeScriptのコンポーネントのプロパティ型定義についてご紹介します。

ご紹介する型定義はinterfaceを使った代替表現がない点にご注意ください。

複数パターンの型を許容するプロパティを受けるコンポーネント

やりたいこと

あるとき、コンポーネントに複数の型を持つプロパティを渡したいときがありました。 例を出すと下のような型を持つものです。

type Props1 = {
  value: string
  color: 'black' | 'red'
}

type Props2 = {
  value: string
  onClick: () => void
}

当時は下記二つの対応方法しか思いつきませんでした。

  1. 各型(Props1, Props2)を受けるコンポーネントをそれぞれ用意する
  2. 各型(Props1, Props2)をまとめて一つの型にしてしまう

解決手段?

2の方法のように複数の型を持つプロパティをコンポーネントに渡す記述は、責任範囲を広くする記述でありコンポーネントを複雑化してしまいます。このような複雑化を避けるために1の方法が最適な場合も多いと思います。 ただこのとき作りたかったコンポーネントは共通部分が多く、意味を考えても若干の使い方の違いがあるだけだったので、1の方法は採用しませんでした。

2の方法は下記のような型を作るという方法です。

type ConventionalProps = {
  value: string
  color?: 'black' | 'red'
  onClick?: () => void
}

このように記述するとcoloronClickのどちらもundefinedである場合と、どちらもundefinedではない場合の両方を許容してしまうので、これもベストではありませんでした。

解決手段

そんなとき見つけたのが、下記の書き方です。

type ProposedProps = {
  value: string
  color: 'black' | 'red'
} | {
  value: string
  onClick: () => void
}

こうすることで、複数の型を許容するプロパティを一つのコンポーネントで受けることができますし、その型の組み合わせも必要十分になります。

この方法は少し抽象化するとorを使って二つの型を一つの型にまとめたということであり、”二つの型を一つにまとめる = andを使う” というこれまでの固定観念にとらわれない方法でした。

orを活用したプロパティ型定義の注意点

このorを活用したコンポーネントのプロパティ型定義の書き方には注意が必要です。 このコンポーネントはどんなプロパティを受けるか使う直前までわからないため、分割代入出来ません。 コードの例は下記のとおりです。

type ProposedProps = {
  value: string
  color: 'black' | 'red'
} | {
  value: string
  onClick: () => void
}

// NG
function ProposedComponent ({ value, color, onClick }: Props) {
  // 略
}

// OK
function ProposedComponent (props: Props) { 
  // 略
}

また、orを使って定義した型を使う場合、例えばPickやOmitなどのユーティリティ型を使うと共通部分の型しか抽出できなくなってしまうので、特に親コンポーネントからこのPropsを少し変えて使いたいときに注意が必要です。

orを活用したプロパティ型定義コンポーネント

コード例

コンポーネントの定義も含めて例をご紹介します。

type ProposedProps = {
  value: string
  color: 'black' | 'red'
} | {
  value: string
  onClick: () => void
}

function ProposedComponent(props: ProposedProps) {
  if ('color' in props) // propsの型を絞り込む
    return <span style={{ color: props.color }}>{props.value}</span>

  return <button onClick={props.onClick}>{props.value}</button>
}

上記のようにpropsに存在するプロパティから型ガードが可能となっています。 (直接styleにスタイルを記述しているのは説明の簡単化のためなので目を瞑っていただきたいです。)

タグ付きユニオン型を活用したコード例

また今回の例のようにcolorとonClickが同列にも関わらず、ifで分類する条件とそれ以外という記述を避けたいのであれば、下記のようにコンポーネントの識別用プロパティを追加した記述も可能です。

type ProposedProps2 = {
  type: 'type1'
  value: string
  color: 'black' | 'red'
} | {
  type: 'type2'
  value: string
  onClick: () => void
}

function ProposedComponent2(props: ProposedProps2) {
  switch (props.type) {
    case 'type1':
      return <span style={{ color: props.color }}>{props.value}</span>;
    case 'type2':
      return <button onClick={props.onClick}>{props.value}</button>;
  }
}

こうすることで、コードを見たときに同列であることが少しわかりやすくなると思います。この型定義を”タグ付きユニオン(共用型)”や”判別可能なユニオン”と呼びます。

型定義を厳密にすることの良し悪し

andを使った方法でも同じ機能のコンポーネントを実装できますが、orを使って型を厳密に定義することの良し悪しをまとめてみます。

  • 良い点
    • 間違った型を入れると早い段階でエラーに気付ける
    • エディタの自動補完が効きやすくなる
    • コメントの代わりになる
  • 悪い点
    • 記述量が増えてレビューコストやバグの発生リスクが増える
    • 型の定義に脳のリソースを割く必要がある

型定義は作るときのコストを上げて、使うときのコストを下げるものになっています。 簡単なコンポーネントを作るだけであれば、マイナスの面が目立ってしまう型定義ですが、複雑なコンポーネントを作るときにプラスの面が際立つということですね。

内部実装を細かく覚えておくのは難しいので、私が既存コンポーネントを使うときは、エディタの自動補完に頼り切りです。

また弊社では基本的にコードにコメントを残さずにコメント以外の方法でコードの主旨を残すことがほとんどなので、型定義もそこに一役を買っています。

まとめ

方向性として、コンポーネントが複雑であればあるほどこのようにより凝った型定義をする方が良いと思います。

コードの複雑度合いをLinter等は理解できないので、私の所属するプロジェクトでは型定義について最低限のルールは決めつつも型定義のルールは一定ではありません。 そのため、orを活用した型定義をするか、andを活用した型定義をするかなど型定義の厳密さに関しては基本的に書き手の裁量に任せレビューチェックの対象にしています。

開発チームの状況やプロダクトに合わせて、ぜひこのorを活用したプロパティ型定義を使ってみてください。

KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
hrmos.co hrmos.co hrmos.co