56
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript + Reactで特定の要素だけを受け付けるコンポーネントを型安全に実装する

Last updated at Posted at 2025-07-01

概要

「コンポーネントのpropとして、できれば文字列だけを受け取るようにしたい。けど、現実的にリンクくらいは受け取れないといけない。」

そんな場面が時折あり、どういうアプローチが良いのか悩みがちです。

完璧ではありませんが、実用的な解決策にたどり着いたのでその内容を記事にしました。

対象読者

  • TypeScriptでReactの開発をしている方
  • コンポーネントの型安全性を重視したい方
  • 再利用可能で堅牢なコンポーネント設計に興味がある方

結論

childrenではなく通常のpropとして渡す。

具体的なシチュエーション

できるだけ小さく分かりやすい例として、チェックボックスのコンポーネントを挙げてみます。

input要素としてのチェックボックスがあり、すぐ側にラベルのテキストがあるようなコンポーネントです。

チェックボックスとラベルが並んでいるコンポーネントの例。

このときラベルのテキストはstringとして扱えたら楽ですが、なんだかんだリンクくらいは入れたくなります。

例えば以下のように、規約の確認系のチェックボックスに文字列とリンクが混ざっているのはよく見るのではないでしょうか。

利用規約とプライバシーポリシーへのリンクを含むチェックボックスの例。「利用規約とプライバシーポリシーに同意しました」というテキストで、「利用規約」と「プライバシーポリシー」がリンクになっている。

これを上手く扱いたいというのが今回の記事の主題です。

簡単に作成できるけど壊れやすいコンポーネント

childrenを受け取ってlabelに渡します。

components/Checkbox1.tsx
import type { ComponentProps, ReactNode } from "react";
import { useId } from "react";

type Props = {
  children: ReactNode;
} & ComponentProps<"input">;

export function Checkbox1({ children, ...props }: Props) {
  const id = useId();
  return (
    <div>
      <input type="checkbox" id={id} {...props} />
      <label htmlFor={id}>{children}</label>
    </div>
  );
}

この場合、使用するときはこのようになります。

想定使用シーン
<Checkbox1>
  <a href="/terms">利用規約</a><a href="/privacy-policy">プライバシーポリシー</a>に同意しました
</Checkbox1>

やりたいことは実現できているのですが、一方でこのようなコードも書けてしまいます。

想定していないけど使用できてしまうシーン
<Checkbox1>
  <a href="/terms">利用規約</a><a href="/privacy-policy">プライバシーポリシー</a><span className="foo">同意しました</span>
</Checkbox1>

どんなマークアップでもスタイルでも、好き放題につけられてしまいます。
そんな状態ではいつ壊れてしまうか分かりません。

やりたかったけどできなかったこと

はじめはchildrenの型を縛るだけで解消するのでは?と考えて、次のようなコードを書いてみました。

Checkbox2.tsx
  type Props = {
-   children: ReactNode;
+   children: (string | ReactElement<any, "a">)[];
  } & ComponentProps<"input">;

// 型以外は同じ

しかしこれは期待通りに動きませんでした。

ReactのJSX要素は実行時にReact.createElement()の呼び出しになり、その戻り値の型がReactElement<any, any>として推論されるため、純粋な型レベルでの制限はできないようです。

私が調べた限りの話なので、もしかしたらうまくできる方法もあるのかもしれませんが……。
一旦はできないものとして話を進めます。

実行時エラーになるコンポーネントを作る

型で縛れないなら実行時エラーにさせるか、と思って作りました。

Checkbox3.tsx
import type { ComponentProps, ReactNode } from "react";
import { Children, isValidElement, useId } from "react";

type Props = {
  children: ReactNode;
} & ComponentProps<"input">;

const isValidChild = (child: unknown) => {
  return (
    typeof child === "string" || (isValidElement(child) && child.type === "a")
  );
};

export function Checkbox3({ children, ...props }: Props) {
  const id = useId();

  // Children.forEachはchildrenが単一要素・配列・nullでも安全に処理できる
  Children.forEach(children, (child, index) => {
    if (!isValidChild(child)) {
      throw new Error(
        `Invalid element at index ${index}. Only strings and <a> elements allowed.`
      );
    }
  });

  return (
    <div>
      <input type="checkbox" id={id} {...props} />
      <label htmlFor={id}>{children}</label>
    </div>
  );
}

これで、先ほどの例のようにchildrenspanを渡すとエラーになります。

実行時エラーのスクリーンショット。error: Invalid element at index 4. Only strings and <a> elements allowed.というエラーメッセージが表示されている。

なりはするのですが、型としてのエラーは出ていないのでなんとなく気持ち悪いです。

実現したいことに対して若干の「やり過ぎ」感もあるので、他の方法も考えてみます。

いっそchildrenで渡さない

childrenで渡すからややこしくなってしまうのだと諦め、違う方法で渡すことにしました。

Checkbox4
import type { ComponentProps } from "react";
import { Fragment, useId } from "react";

// テキストのみの要素
type Text = {
  text: string;
};

// リンク要素(a要素の全プロパティを継承)
type Link = {
  text: string;
} & ComponentProps<"a">;

// ユニオン型で「文字列またはリンク」を表現
type Contents = Text | Link;

// 型ガード関数:hrefの有無でリンクかどうかを判定
function isLink(item: Contents) {
  return "href" in item;
}

interface Props {
  contents: Contents[];
}

export function Checkbox4({ contents, ...props }: Props) {
  const id = useId();
  return (
    <div>
      <input type="checkbox" id={id} {...props} />
      <label htmlFor={id}>
        {contents.map((content, index) => {
          if (isLink(content)) {
            const { text, ...restProps } = content;
            return (
              // リンクの場合:a要素として描画
              <a key={index} {...restProps}>
                {text}
              </a>
            );
          }
          // リンクでない = テキストの場合:Fragmentで単なるテキストとして描画
          return <Fragment key={index}>{content.text}</Fragment>;
        })}
      </label>
    </div>
  );
}

使用する場合は次のようになります。

<Checkbox4
  contents={[
    { text: "利用規約", href: "/terms" },
    { text: "" },
    { text: "プライバシーポリシー", href: "/privacy-policy" },
    { text: "に同意しました" },
  ]}
/>

hrefを持っていたらa要素、そうでなければ通常のテキストとして描画されます。

hrefを渡していない状態ではtext以外の要素を渡すと型エラーとなり、hrefを渡した後でもa要素に存在しない属性を渡すとエラーとなります。

ComponentPropsを使う利点

本筋からは少し逸れますがComponentProps<"a">を使用することでの利点を説明しておきます。

type Link = {
  text: string;
} & ComponentProps<"a">;
  1. targetrelonClickなど、a要素の全属性が利用可能(すべてを手動で書かなくて良い)
  2. 補完が機能する
{ 
  text: "リンク", 
  // 以下のような属性が漏れなく使える
  href: "/example",
  target: "_blank",
  rel: "noreferrer",
  onClick: (e) => console.log("clicked")
}

型ガード関数を使う利点

こちらも少し本筋から少し逸れますが、型ガード関数を使用する利点を説明しておきます。

function isLink(item: Contents): {
  return "href" in item;
}
  1. isLink(content)trueの場合、TypeScriptは自動的にcontentLink型として扱う
  2. 条件分岐の中でcontent.hrefcontent.targetなどのプロパティが正しく補完される
  3. 存在しないプロパティにアクセスしようとするとエラーになる
1,2番の例
isLink(content) ? (
  // 型ガードのおかげで、この分岐内ではcontentはLink型として扱われる
  // 今回は使っていないものの、content.hrefなどを個別に取り出したい場合、正しく補完される
  <a key={index} {...content}>
    {content.text}
  </a>
) : (
  // こちらではText型として扱われる
  <Fragment key={index}>{content.text}</Fragment>
)
3番の例
// このようなコードを書こうとすると型エラーになる
<Checkbox5
  contents={[
    {
      text: "利用規約",
      href: "/terms",
      className: "custom-style" // Error: Object literal may only specify known properties, and 'className' does not exist in type 'Contents'.
    },
    { text: "に同意しました" },
  ]}
/>

せっかくなのでa要素へ渡せる属性をより厳しくする

ただ、先ほどまでの状態ではa要素にclassNamestyleを渡せてしまいます。

せっかくなのでそれもできないようにしておきます。

Checkbox5.tsx
  type Link = {
    text: string;
- } & ComponentProps<"a">;
+ } & Omit<ComponentProps<"a">, "className" | "style">

これでa要素の見た目を勝手に上書きすることもできなくなりました。

まとめ

Reactコンポーネントで「文字列とリンクのみ」を受け付ける型安全な実装方法を考えました。

各アプローチの比較

アプローチ 型安全性 実装の簡単さ 使うときの分かりやすさ
childrenをそのまま使用 ⭐⭐⭐ ⭐⭐⭐
childrenの型制限(実現不可) - - -
実行時エラーにする ⭐⭐
children以外のpropを使用 ⭐⭐

最終的なchildren以外のpropを使用するアプローチは、実装は少し複雑にはなってしまいましたが、安全性とほどほどの分かりやすさから、トータルでは一番良いと考えています。

このパターンが活用できる場面

  • フォームなどのラベル
  • 注意書きや説明文
  • CMSなどから使う、見た目のスタイルだけを施したテキストコンポーネント

型安全性を保ちながら、必要最小限の柔軟性を持たせることで、メンテナンスしやすさと使い勝手を両立できるのではないでしょうか。

56
36
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?