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. Props 型定義の土台:type vs interface とオプショナル
- 2. children と React 各種型(ReactNode / ReactElement / PropsWithChildren)
- 3. HTML 属性継承:ComponentProps / ButtonHTMLAttributes / ComponentPropsWithRef
- 4. forwardRef・React 19 の ref as prop・ジェネリックコンポーネント
- 5. Discriminated Union Props・条件付き Props・排他的 Props
- 6. Polymorphic Components(as prop)と asChild・compound component
- 7. Variant Props:cva・tailwind-variants・styled-components
- 8. 実務向け Props 設計:Form・Modal・Card・Table・HOC・テスト
- 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 設計判断」を、実務とレビューを通じて鍛えるのが、最終的にはもっとも早道です。

コメント