React Props型定義完全ガイド〜children・generics・discriminated union・polymorphic・asChild【2026年版】〜

React + TypeScript で実装する上で、もっとも頻繁に書くのに「もっとも雑に書かれがちなコード」が Props 型定義です。動けばよい・とりあえず any で逃げる、という書き方を続けると、コンポーネントの再利用性は崩壊し、リファクタの度に壊れ、shadcn/ui のような近代的なコンポーネントライブラリに追従できなくなります。

本記事では、TypeScript 5.x + React 19 準拠で、コピペで動く 40+ のコードサンプルを通じて、React Props 型定義のすべて(基礎・children・HTML 属性継承・ComponentProps / ComponentPropsWithRef・ジェネリックコンポーネント・discriminated union・polymorphic as prop・asChild・compound component・controlled/uncontrolled 同時対応・variant props(cva / tailwind-variants)・条件付き props・shadcn/ui パターン・Form/Modal/Table Props・HOC 型・テスト用 Props 拡張)を網羅します。

ジェネリクス全般は TypeScript ジェネリクス完全ガイド、型ガード(discriminated union 含む)は TypeScript 型ガード完全ガイドtsconfig 設定は tsconfig.json 完全ガイドと併読すると、Props 型を「型レベルで動く UI 設計図」として扱えるようになります。

  1. 1. Props 型定義の土台:type vs interface とオプショナル
    1. 1.1 type vs interface(Props は type 推奨)
    2. 1.2 オプショナル props と必須 props
    3. 1.3 デフォルト値は分割代入で決める(defaultProps は非推奨)
    4. 1.4 Before/After:Props にすべき値を関数引数に散らしてしまう典型
  2. 2. children と React 各種型(ReactNode / ReactElement / PropsWithChildren)
    1. 2.1 children: ReactNode(もっとも柔軟・推奨デフォルト)
    2. 2.2 ReactElement と JSX.Element の使い分け
    3. 2.3 PropsWithChildren:children だけ足したいとき
    4. 2.4 関数 children(Render Props)
  3. 3. HTML 属性継承:ComponentProps / ButtonHTMLAttributes / ComponentPropsWithRef
    1. 3.1 ButtonHTMLAttributes で継承
    2. 3.2 ComponentProps<‘button’> を使う(より簡潔)
    3. 3.3 ComponentPropsWithoutRef / ComponentPropsWithRef
    4. 3.4 props spread と「自分が定義した props を漏らさない」テクニック
    5. 3.5 Before/After:onClick の型を手書きしてしまう典型
  4. 4. forwardRef・React 19 の ref as prop・ジェネリックコンポーネント
    1. 4.1 React 18 までの forwardRef + props
    2. 4.2 React 19:ref をただの prop として受ける
    3. 4.3 ジェネリックコンポーネント:List<T>
    4. 4.4 ジェネリック + forwardRef(as any なしで書く)
  5. 5. Discriminated Union Props・条件付き Props・排他的 Props
    1. 5.1 Discriminated Union Props の基本
    2. 5.2 Conditional Props:loading 時だけ別の props を要求
    3. 5.3 RequireAtLeastOne(どれか1つは必須)
    4. 5.4 ExclusiveProps(同時には1つしか渡せない)
    5. 5.5 Controlled / Uncontrolled 同時対応 Props
  6. 6. Polymorphic Components(as prop)と asChild・compound component
    1. 6.1 もっとも単純な polymorphic(as prop)
    2. 6.2 Polymorphic + ref(本格版)
    3. 6.3 asChild パターン(Radix UI 流)
    4. 6.4 Compound Component(複合コンポーネント)
  7. 7. Variant Props:cva・tailwind-variants・styled-components
    1. 7.1 cva の基本
    2. 7.2 tailwind-variants で複合バリアント
    3. 7.3 styled-components + TS
    4. 7.4 Before/After:variant を string で受け取って if 分岐
  8. 8. 実務向け Props 設計:Form・Modal・Card・Table・HOC・テスト
    1. 8.1 Form Components Props(react-hook-form 連携)
    2. 8.2 Modal Props(controlled + ポータル)
    3. 8.3 Card Props(slot 設計)
    4. 8.4 Table Props(ジェネリクス + 列定義)
    5. 8.5 Higher-Order Component 型(HOC)
    6. 8.6 shadcn/ui パターン(asChild + cva + forwardRef + ComponentProps)
    7. 8.7 テスト用 Props 拡張
    8. 8.8 Before/After:HOC で any 漏れ → ジェネリクスで保全
  9. 9. まとめと推奨学習パス

1. Props 型定義の土台:type vs interface とオプショナル

まずは「何を選ぶか」の判断軸と、もっとも初歩のオプショナル props・デフォルト値を整理します。実は判断基準は単純で、「Props は type」「ライブラリの公開型かつ宣言マージしたい場合のみ interface」が現実解です。

1.1 type vs interface(Props は type 推奨)

// 推奨: type(union・mapped・conditional に拡張しやすい)
type ButtonProps = {
  label: string;
  onClick: () => void;
};

// interface も書ける(宣言マージが必要な公開ライブラリ向け)
interface ButtonPropsI {
  label: string;
  onClick: () => void;
}

export function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

1.2 オプショナル props と必須 props

type AvatarProps = {
  src: string;                 // 必須
  alt?: string;                // オプショナル
  size?: "sm" | "md" | "lg";   // リテラル union
};

export function Avatar({ src, alt = "", size = "md" }: AvatarProps) {
  const px = { sm: 24, md: 40, lg: 64 }[size];
  return <img src={src} alt={alt} width={px} height={px} />;
}

1.3 デフォルト値は分割代入で決める(defaultProps は非推奨)

// ❌ React 19 で defaultProps は関数コンポーネントで非推奨
// Button.defaultProps = { variant: "primary" };

// ✅ 分割代入のデフォルト値で型と実装を一致させる
type Variant = "primary" | "secondary" | "ghost";
type BtnProps = { variant?: Variant; children: React.ReactNode };

export function Btn({ variant = "primary", children }: BtnProps) {
  return <button data-variant={variant}>{children}</button>;
}

1.4 Before/After:Props にすべき値を関数引数に散らしてしまう典型

// ❌ Before: 引数が多すぎて呼び出し側が壊れる
export function Card(title: string, body: string, footer?: string, highlight?: boolean) {
  // ...
  return null;
}

// ✅ After: Props 型にまとめる(named arg 化)
type CardProps = {
  title: string;
  body: string;
  footer?: string;
  highlight?: boolean;
};

export function CardOk({ title, body, footer, highlight = false }: CardProps) {
  return (
    <div data-highlight={highlight}>
      <h3>{title}</h3>
      <p>{body}</p>
      {footer && <footer>{footer}</footer>}
    </div>
  );
}

2. children と React 各種型(ReactNode / ReactElement / PropsWithChildren)

「children をどう型付けるか」は意外に深く、用途で使い分けます。基本は ReactNode、レンダープロップ的に「単一要素しか受け入れない」場合は ReactElement、ライブラリ的に書きたい場合は PropsWithChildren が有効です。

2.1 children: ReactNode(もっとも柔軟・推奨デフォルト)

import type { ReactNode } from "react";

type SectionProps = {
  title: string;
  children: ReactNode; // 文字列・要素・配列・null・false すべて OK
};

export function Section({ title, children }: SectionProps) {
  return (
    <section>
      <h2>{title}</h2>
      <div>{children}</div>
    </section>
  );
}

2.2 ReactElement と JSX.Element の使い分け

import type { ReactElement } from "react";

// 単一の React 要素だけ受け取りたい(配列やテキスト不可)
type IconWrapperProps = {
  icon: ReactElement;
};

export function IconWrapper({ icon }: IconWrapperProps) {
  return <span className="icon-wrapper">{icon}</span>;
}

// 補足: JSX.Element は ReactElement<any, any> のエイリアス。
// 自作コンポーネントの戻り値型としては JSX.Element をよく使うが、
// props 受け取り側では ReactElement<P> の方が型情報を狭く保てる。

2.3 PropsWithChildren:children だけ足したいとき

import type { PropsWithChildren } from "react";

type CalloutOwn = { tone: "info" | "warn" | "danger" };
type CalloutProps = PropsWithChildren<CalloutOwn>;
// 上は { tone: ...; children?: ReactNode } と等価

export function Callout({ tone, children }: CalloutProps) {
  return <div data-tone={tone}>{children}</div>;
}

2.4 関数 children(Render Props)

type MouseState = { x: number; y: number };

type MouseTrackerProps = {
  children: (state: MouseState) => ReactNode;
};

export function MouseTracker({ children }: MouseTrackerProps) {
  const [pos, setPos] = React.useState<MouseState>({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {children(pos)}
    </div>
  );
}

// 使用例
// <MouseTracker>{({ x, y }) => <p>{x},{y}</p>}</MouseTracker>

3. HTML 属性継承:ComponentProps / ButtonHTMLAttributes / ComponentPropsWithRef

button の見た目を変えただけのコンポーネント」を作るとき、HTML 標準属性をすべて手書きすると地獄です。ComponentProps 系を使うとそのまま継承できます。

3.1 ButtonHTMLAttributes で継承

import type { ButtonHTMLAttributes } from "react";

type MyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: "primary" | "secondary";
};

export function MyButton({ variant = "primary", className = "", ...rest }: MyButtonProps) {
  return <button data-variant={variant} className={`btn ${className}`} {...rest} />;
}

3.2 ComponentProps<‘button’> を使う(より簡潔)

import type { ComponentProps } from "react";

// JSX のタグ名から、その要素の props を引っ張ってくる
type BtnProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" };

export function Btn2({ variant = "primary", ...rest }: BtnProps) {
  return <button data-v={variant} {...rest} />;
}

3.3 ComponentPropsWithoutRef / ComponentPropsWithRef

import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";

// ref を明示的に持たない版(forwardRef 経由でないコンポーネント向け)
type InputBase = ComponentPropsWithoutRef<"input">;

// ref を含めて継承する版(forwardRef するコンポーネント向け)
type InputWithRef = ComponentPropsWithRef<"input">;

3.4 props spread と「自分が定義した props を漏らさない」テクニック

type MyInputProps = ComponentProps<"input"> & {
  label: string;     // 自前 props
  error?: string;    // 自前 props
};

export function MyInput({ label, error, ...inputProps }: MyInputProps) {
  // ✅ 自前 props を {...inputProps} に混ぜず、確実に input 要素へ渡す
  return (
    <label>
      <span>{label}</span>
      <input {...inputProps} aria-invalid={!!error} />
      {error && <em role="alert">{error}</em>}
    </label>
  );
}

3.5 Before/After:onClick の型を手書きしてしまう典型

// ❌ Before: onClick の型を雑に書き、e の型が any 化
type BadBtnProps = { onClick: (e: any) => void };

// ✅ After: ComponentProps から型を引いてくる
import type { ComponentProps, MouseEventHandler } from "react";

type GoodBtnProps = {
  onClick: MouseEventHandler<HTMLButtonElement>;
} & ComponentProps<"button">;

export function GoodBtn(p: GoodBtnProps) {
  return <button {...p} />;
}

4. forwardRef・React 19 の ref as prop・ジェネリックコンポーネント

React 19 では ref を「ただの prop」として受け取れるようになり、forwardRef の必要性は大きく下がりました。とはいえ、ライブラリでは引き続き forwardRef を見かけるため両方押さえます。

4.1 React 18 までの forwardRef + props

import React, { forwardRef } from "react";

type InputProps = React.ComponentPropsWithoutRef<"input"> & { label: string };

export const TextInput = forwardRef<HTMLInputElement, InputProps>(
  function TextInput({ label, ...rest }, ref) {
    return (
      <label>
        {label}: <input ref={ref} {...rest} />
      </label>
    );
  }
);

4.2 React 19:ref をただの prop として受ける

// React 19 では forwardRef 不要
type Input19Props = React.ComponentProps<"input"> & {
  label: string;
};

export function TextInput19({ label, ref, ...rest }: Input19Props) {
  return (
    <label>
      {label}: <input ref={ref} {...rest} />
    </label>
  );
}

4.3 ジェネリックコンポーネント:List<T>

type ListProps<T> = {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyFn?: (item: T, index: number) => React.Key;
};

export function List<T>({ items, renderItem, keyFn }: ListProps<T>) {
  return (
    <ul>
      {items.map((it, i) => (
        <li key={keyFn ? keyFn(it, i) : i}>{renderItem(it, i)}</li>
      ))}
    </ul>
  );
}

// 使用例(T が完全に推論される)
// <List items={users} renderItem={(u) => u.name} />

4.4 ジェネリック + forwardRef(as any なしで書く)

// 型ファクトリ的に forwardRef をラップして T を保つ常套句
function genericForwardRef<T, P>(
  render: (props: P, ref: React.Ref<T>) => React.ReactNode
) {
  return React.forwardRef(render as React.ForwardRefRenderFunction<T, P>) as unknown as <U extends T>(
    props: P & React.RefAttributes<U>
  ) => React.ReactNode;
}

type SelectProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
};

export const Select = genericForwardRef(function Select<T>(
  { items, onSelect }: SelectProps<T>,
  ref: React.Ref<HTMLDivElement>
) {
  return (
    <div ref={ref}>
      {items.map((it, i) => (
        <button key={i} onClick={() => onSelect(it)}>
          {String(it)}
        </button>
      ))}
    </div>
  );
});

5. Discriminated Union Props・条件付き Props・排他的 Props

「片方の prop を渡したらもう片方は禁止」「mode によって渡せる props が変わる」を any なしで成立させるパターンです。詳細な原理は型ガード完全ガイドを参照してください。

5.1 Discriminated Union Props の基本

// link としても button としても振る舞うコンポーネント
type ButtonOrLink =
  | { as: "button"; onClick: () => void; href?: never }
  | { as: "a"; href: string; onClick?: never };

type ButtonOrLinkProps = ButtonOrLink & { children: React.ReactNode };

export function ButtonOrLink(props: ButtonOrLinkProps) {
  if (props.as === "a") {
    return <a href={props.href}>{props.children}</a>;
  }
  return <button onClick={props.onClick}>{props.children}</button>;
}

// <ButtonOrLink as="a" href="/x">OK</ButtonOrLink>
// <ButtonOrLink as="button" onClick={() => {}}>OK</ButtonOrLink>
// <ButtonOrLink as="button" href="/x">❌型エラー</ButtonOrLink>

5.2 Conditional Props:loading 時だけ別の props を要求

type LoadingProps =
  | { loading: true; loadingText: string }
  | { loading?: false; loadingText?: never };

type SubmitBtnProps = LoadingProps & { children: React.ReactNode };

export function SubmitBtn(props: SubmitBtnProps) {
  if (props.loading) return <button disabled>{props.loadingText}</button>;
  return <button>{props.children}</button>;
}

5.3 RequireAtLeastOne(どれか1つは必須)

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Pick<T, Exclude<keyof T, Keys>> &
  {
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
  }[Keys];

type IconBtnAll = { iconLeft?: React.ReactNode; iconRight?: React.ReactNode; label?: string };
type IconBtnProps = RequireAtLeastOne<IconBtnAll, "iconLeft" | "iconRight" | "label">;

export function IconBtn(props: IconBtnProps) {
  return <button>{props.iconLeft}{props.label}{props.iconRight}</button>;
}

5.4 ExclusiveProps(同時には1つしか渡せない)

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T);

type ImageOrIcon = XOR<{ src: string }, { icon: React.ReactNode }>;

export function Visual(props: ImageOrIcon) {
  if ("src" in props) return <img src={props.src} />;
  return <span>{props.icon}</span>;
}

5.5 Controlled / Uncontrolled 同時対応 Props

type ControlledProps = {
  value: string;
  onChange: (next: string) => void;
  defaultValue?: never;
};
type UncontrolledProps = {
  value?: never;
  onChange?: (next: string) => void;
  defaultValue?: string;
};
type TextFieldProps = (ControlledProps | UncontrolledProps) & { label: string };

export function TextField(p: TextFieldProps) {
  const [inner, setInner] = React.useState(p.defaultValue ?? "");
  const isControlled = "value" in p && p.value !== undefined;
  const v = isControlled ? p.value! : inner;
  return (
    <label>
      {p.label}
      <input
        value={v}
        onChange={(e) => {
          if (!isControlled) setInner(e.target.value);
          p.onChange?.(e.target.value);
        }}
      />
    </label>
  );
}

6. Polymorphic Components(as prop)と asChild・compound component

shadcn/ui や Radix UI、Chakra UI などで頻出する「同じコンポーネントを div にしたり a にしたりできる」やつです。型は重ためですが、型ファクトリとして1度書けば使い回せます。

6.1 もっとも単純な polymorphic(as prop)

import type { ElementType, ComponentPropsWithoutRef } from "react";

type BoxOwnProps<E extends ElementType> = {
  as?: E;
  children?: React.ReactNode;
};

type BoxProps<E extends ElementType> = BoxOwnProps<E> &
  Omit<ComponentPropsWithoutRef<E>, keyof BoxOwnProps<E>>;

export function Box<E extends ElementType = "div">({
  as,
  children,
  ...rest
}: BoxProps<E>) {
  const Tag = as ?? "div";
  return <Tag {...(rest as any)}>{children}</Tag>;
}

// <Box as="a" href="/x">link</Box>     // a の属性が型補完
// <Box as="button" type="submit">...</Box> // button の属性が型補完

6.2 Polymorphic + ref(本格版)

import type {
  ElementType,
  ComponentPropsWithRef,
  ComponentPropsWithoutRef,
  ReactElement,
} from "react";

type PolymorphicRef<E extends ElementType> =
  ComponentPropsWithRef<E>["ref"];

type PolymorphicProps<E extends ElementType, P> = P & {
  as?: E;
} & Omit<ComponentPropsWithoutRef<E>, keyof P | "as">;

type TextOwn = { weight?: "normal" | "bold" };
type TextProps<E extends ElementType> = PolymorphicProps<E, TextOwn> & {
  ref?: PolymorphicRef<E>;
};

export const Text = React.forwardRef(function Text<E extends ElementType = "span">(
  { as, weight = "normal", ...rest }: TextProps<E>,
  ref: PolymorphicRef<E>
) {
  const Tag = as ?? "span";
  return <Tag ref={ref} data-weight={weight} {...(rest as any)} />;
}) as <E extends ElementType = "span">(p: TextProps<E>) => ReactElement | null;

6.3 asChild パターン(Radix UI 流)

import { Children, cloneElement, isValidElement } from "react";

type SlotProps = { children: React.ReactNode };
function Slot({ children, ...rest }: SlotProps & Record<string, any>) {
  const child = Children.only(children);
  if (!isValidElement(child)) return null;
  return cloneElement(child, { ...rest, ...child.props });
}

type BtnAsChildProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  asChild?: boolean;
};

export function PrimaryBtn({ asChild, ...rest }: BtnAsChildProps) {
  const Cmp: any = asChild ? Slot : "button";
  return <Cmp data-variant="primary" {...rest} />;
}

// 使用例: 子要素にスタイル/属性を流し込む
// <PrimaryBtn asChild><a href="/x">Link</a></PrimaryBtn>

6.4 Compound Component(複合コンポーネント)

type TabsContextValue = { value: string; setValue: (v: string) => void };
const TabsCtx = React.createContext<TabsContextValue | null>(null);

type TabsProps = {
  value: string;
  onValueChange: (v: string) => void;
  children: React.ReactNode;
};

export function Tabs({ value, onValueChange, children }: TabsProps) {
  return (
    <TabsCtx.Provider value={{ value, setValue: onValueChange }}>
      <div>{children}</div>
    </TabsCtx.Provider>
  );
}

Tabs.List = function TabsList({ children }: { children: React.ReactNode }) {
  return <div role="tablist">{children}</div>;
};

Tabs.Trigger = function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
  const ctx = React.useContext(TabsCtx)!;
  return (
    <button role="tab" aria-selected={ctx.value === value} onClick={() => ctx.setValue(value)}>
      {children}
    </button>
  );
};

Tabs.Content = function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
  const ctx = React.useContext(TabsCtx)!;
  return ctx.value === value ? <div role="tabpanel">{children}</div> : null;
};

7. Variant Props:cva・tailwind-variants・styled-components

「primary / secondary / ghost × sm / md / lg」のような組み合わせ Props を、型と実装の両方で破綻させない方法です。cva(class-variance-authority)と tailwind-variants が事実上の標準です。

7.1 cva の基本

import { cva, type VariantProps } from "class-variance-authority";

const button = cva("btn", {
  variants: {
    intent: { primary: "btn-primary", secondary: "btn-secondary", ghost: "btn-ghost" },
    size: { sm: "btn-sm", md: "btn-md", lg: "btn-lg" },
  },
  defaultVariants: { intent: "primary", size: "md" },
});

type BtnProps = React.ComponentProps<"button"> & VariantProps<typeof button>;

export function CvaBtn({ intent, size, className, ...rest }: BtnProps) {
  return <button className={button({ intent, size, className })} {...rest} />;
}

7.2 tailwind-variants で複合バリアント

import { tv, type VariantProps } from "tailwind-variants";

const card = tv({
  slots: {
    base: "rounded-xl border p-4",
    title: "text-lg font-bold",
    body: "text-sm text-gray-600",
  },
  variants: {
    tone: {
      neutral: { base: "border-gray-200" },
      success: { base: "border-green-300 bg-green-50", title: "text-green-700" },
      danger: { base: "border-red-300 bg-red-50", title: "text-red-700" },
    },
  },
  defaultVariants: { tone: "neutral" },
});

type CardVProps = VariantProps<typeof card> & { title: string; children: React.ReactNode };

export function TVCard({ tone, title, children }: CardVProps) {
  const { base, title: t, body } = card({ tone });
  return (
    <div className={base()}>
      <h3 className={t()}>{title}</h3>
      <div className={body()}>{children}</div>
    </div>
  );
}

7.3 styled-components + TS

import styled, { css } from "styled-components";

type StyledBtnProps = { $variant?: "primary" | "secondary"; $size?: "sm" | "md" };

export const StyledBtn = styled.button<StyledBtnProps>`
  padding: ${(p) => (p.$size === "sm" ? "4px 8px" : "8px 16px")};
  background: ${(p) => (p.$variant === "secondary" ? "#eee" : "#0070f3")};
  color: ${(p) => (p.$variant === "secondary" ? "#111" : "white")};
  border-radius: 8px;
  ${(p) =>
    p.$variant === "primary" &&
    css`
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
    `}
`;

7.4 Before/After:variant を string で受け取って if 分岐

// ❌ Before: string で受けて if-else、誤った値が来ても気付けない
type BadProps = { variant: string };
function BadBtn({ variant }: BadProps) {
  if (variant === "primary") return <button className="primary" />;
  if (variant === "secondary") return <button className="secondary" />;
  return <button />; // typo "primry" を渡しても気付かない
}

// ✅ After: cva にまとめれば、未知の variant はコンパイルエラー
import { cva, type VariantProps } from "class-variance-authority";
const cls = cva("btn", { variants: { variant: { primary: "p", secondary: "s" } } });
type GoodProps = VariantProps<typeof cls>;
function GoodBtn({ variant }: GoodProps) {
  return <button className={cls({ variant })} />;
}

8. 実務向け Props 設計:Form・Modal・Card・Table・HOC・テスト

最後に、実務で頻出する 6 種の Props 設計を一気に押さえます。これらの組み合わせで、ほぼすべての UI コンポーネントを型安全に書けるようになります。

8.1 Form Components Props(react-hook-form 連携)

import type { FieldValues, UseFormRegister, Path, FieldError } from "react-hook-form";

type FormInputProps<T extends FieldValues> = {
  name: Path<T>;                       // T のキー候補だけが許可される
  label: string;
  register: UseFormRegister<T>;
  error?: FieldError;
} & Omit<React.ComponentProps<"input">, "name">;

export function FormInput<T extends FieldValues>({
  name, label, register, error, ...rest
}: FormInputProps<T>) {
  return (
    <label>
      <span>{label}</span>
      <input {...register(name)} aria-invalid={!!error} {...rest} />
      {error && <em>{error.message}</em>}
    </label>
  );
}

8.2 Modal Props(controlled + ポータル)

type ModalProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  footer?: React.ReactNode;
};

export function Modal({ open, onClose, title, children, footer }: ModalProps) {
  if (!open) return null;
  return (
    <div role="dialog" aria-modal="true" onClick={onClose} className="modal-backdrop">
      <div onClick={(e) => e.stopPropagation()} className="modal-content">
        <header>{title}</header>
        <section>{children}</section>
        {footer && <footer>{footer}</footer>}
      </div>
    </div>
  );
}

8.3 Card Props(slot 設計)

type CardSlotsProps = {
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children: React.ReactNode;
  variant?: "outlined" | "filled";
} & React.ComponentProps<"section">;

export function Card({ header, footer, children, variant = "outlined", ...rest }: CardSlotsProps) {
  return (
    <section data-variant={variant} {...rest}>
      {header && <header>{header}</header>}
      <div>{children}</div>
      {footer && <footer>{footer}</footer>}
    </section>
  );
}

8.4 Table Props(ジェネリクス + 列定義)

type Column<T> = {
  key: keyof T & string;
  header: string;
  render?: (row: T) => React.ReactNode;
  width?: number | string;
};

type TableProps<T extends Record<string, unknown>> = {
  rows: T[];
  columns: Column<T>[];
  rowKey: (row: T, i: number) => React.Key;
};

export function Table<T extends Record<string, unknown>>({
  rows, columns, rowKey,
}: TableProps<T>) {
  return (
    <table>
      <thead>
        <tr>{columns.map((c) => <th key={c.key} style={{ width: c.width }}>{c.header}</th>)}</tr>
      </thead>
      <tbody>
        {rows.map((row, i) => (
          <tr key={rowKey(row, i)}>
            {columns.map((c) => (
              <td key={c.key}>{c.render ? c.render(row) : String(row[c.key])}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

8.5 Higher-Order Component 型(HOC)

// 元のコンポーネントから一部の prop を「外部から見えないように」消す HOC
type Subtract<T, K> = Omit<T, keyof K>;

type WithUserInjected = { user: { id: string; name: string } };

export function withUser<P extends WithUserInjected>(
  Inner: React.ComponentType<P>
) {
  const Wrapped: React.FC<Subtract<P, WithUserInjected>> = (props) => {
    const user = { id: "u1", name: "Tanaka" }; // 実際は Context などから取得
    return <Inner {...(props as any)} user={user} />;
  };
  Wrapped.displayName = `withUser(${Inner.displayName ?? Inner.name})`;
  return Wrapped;
}

// 利用側: user は HOC が注入するので外から渡さなくてよい
// const Page = withUser(MyInner);
// <Page someOtherProp="x" />

8.6 shadcn/ui パターン(asChild + cva + forwardRef + ComponentProps)

import { cva, type VariantProps } from "class-variance-authority";

const btn = cva("inline-flex items-center", {
  variants: {
    variant: { default: "bg-black text-white", outline: "border" },
    size: { sm: "h-8 px-3", md: "h-10 px-4" },
  },
  defaultVariants: { variant: "default", size: "md" },
});

type ShButtonProps =
  React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof btn> &
  { asChild?: boolean };

export const ShButton = React.forwardRef<HTMLButtonElement, ShButtonProps>(
  function ShButton({ className, variant, size, asChild, ...rest }, ref) {
    const Cmp: any = asChild ? "span" : "button";
    return <Cmp ref={ref} className={btn({ variant, size, className })} {...rest} />;
  }
);

8.7 テスト用 Props 拡張

// 内部用に testid だけ受け取れる「テスト用 props」を分離
type TestProps = { "data-testid"?: string };

type UserCardProps = {
  name: string;
  role: string;
} & TestProps;

export function UserCard({ name, role, "data-testid": tid }: UserCardProps) {
  return (
    <div data-testid={tid}>
      <strong>{name}</strong>
      <small>{role}</small>
    </div>
  );
}

// テスト側
// render(<UserCard name="Yamada" role="admin" data-testid="card-1" />);
// expect(screen.getByTestId("card-1")).toBeInTheDocument();

8.8 Before/After:HOC で any 漏れ → ジェネリクスで保全

// ❌ Before: any でかわすと「withUser を通った瞬間に型が崩壊」
function withUserBad(Inner: any) {
  return (props: any) => <Inner {...props} user={{ id: "u", name: "n" }} />;
}

// ✅ After: ジェネリクスで P を保ったまま、user だけを引き算する
// (上記 8.5 と同じ書き方が正しい)

9. まとめと推奨学習パス

Props 型定義は React + TS の中心的スキルですが、つきつめると以下の 6 軸の組合せでほぼすべて表現できます。

1. 構造系: type / interface / オプショナル / デフォルト値 / extends HTML 属性
2. children 系: ReactNode / ReactElement / PropsWithChildren / render props
3. ref / 推論系: forwardRef / React 19 ref-as-prop / ジェネリック / ComponentProps
4. 排他系: discriminated union / conditional / RequireAtLeastOne / XOR / controlled-uncontrolled
5. 拡張系: polymorphic (as) / polymorphic + ref / asChild / compound component
6. 実務系: cva / tailwind-variants / Form / Modal / Card / Table / HOC / テスト

本記事を理解できたら、次は ジェネリクス完全ガイドで型関数としての設計力を、Conditional Types 完全ガイドで「条件付き Props」をより精密に組み立てる力を、Zod 完全実践ガイドで「Props のランタイム検証」までを身につけるとよいでしょう。tsconfig.json 完全ガイドstrict を有効化しておくと、本記事の各パターンが本領を発揮します。

独学に手応えがない場合は、TypeScript + React を業務水準まで体系的に習得できる TechAcademy のフロントエンドコース、現場直結のメンタリングで型設計まで指導してくれる 侍エンジニア、転職前提で React/Next.js を集中的に学べる DMM WEBCAMP エンジニアコース、現役のフリーランス案件で TypeScript 実務経験を積める レバテックフリーランスを併用するのも有力です。書籍・記事だけでは身につきにくい「巨大コンポーネントの Props 設計判断」を、実務とレビューを通じて鍛えるのが、最終的にはもっとも早道です。

コメント

タイトルとURLをコピーしました