T-CREATOR

TypeScript で"型から考える"UI コンポーネント設計術

TypeScript で"型から考える"UI コンポーネント設計術

フロントエンド開発において、TypeScript の型システムを活用したコンポーネント設計は、開発効率と品質向上の鍵となります。従来の「実装してから型を付ける」アプローチではなく、「型から考える」設計手法によって、より堅牢で保守性の高い UI コンポーネントを作り上げることができるでしょう。

本記事では、TypeScript の強力な型システムを最大限活用し、型ファーストなアプローチでコンポーネントを設計する具体的な手法をご紹介します。

背景

現代のフロントエンド開発では、コンポーネントベースのアーキテクチャが主流となっています。React、Vue、Angular など、どのフレームワークを使用する場合でも、再利用可能で保守性の高いコンポーネントを作ることが重要な課題となっているでしょう。

TypeScript の型システムは、JavaScript の動的な性質に静的型チェックの恩恵をもたらします。しかし、多くの開発者が TypeScript の基本的な型注釈にとどまり、その真の力を活用しきれていないのが現状です。

特に UI コンポーネント設計においては、TypeScript の高度な型機能を活用することで、以下のような大きなメリットを得られます。

  • コンパイル時エラー検出: ランタイムエラーの大幅な削減
  • IntelliSense の強化: 開発時の自動補完とドキュメント化
  • リファクタリング支援: 型安全な一括変更の実現
  • API の明確化: コンポーネントの使用方法の自己文書化

これらの恩恵を最大化するためには、「型から考える」設計アプローチが不可欠となります。

課題

従来の Props ベースの設計では、いくつかの根本的な課題に直面することがあります。これらの課題を理解することで、型ファーストアプローチの価値がより明確になるでしょう。

型安全性の不足

一般的なコンポーネント設計では、Props の型定義が後回しになりがちです。以下のような問題が頻繁に発生します。

typescript// 問題のあるコンポーネント設計例
interface ButtonProps {
  variant?: string; // どんな値が有効?
  size?: string; // サイズの選択肢は?
  onClick?: () => void;
}

const Button: React.FC<ButtonProps> = ({
  variant,
  size,
  onClick,
}) => {
  // 実装時に「primary」「secondary」が想定されていることが判明
  const className =
    variant === 'primary' ? 'btn-primary' : 'btn-secondary';
  // しかし型定義からは分からない
  return <button className={className} onClick={onClick} />;
};

この設計では、variant に無効な値(例:"danger""outline")を渡してもコンパイル時にエラーが検出されません。

プロップの相互依存関係の管理困難

複雑なコンポーネントでは、特定のプロップの組み合わせでのみ有効な状態があります。

typescript// 問題のある相互依存の例
interface ModalProps {
  isOpen?: boolean;
  onClose?: () => void;
  title?: string;
  confirmButton?: boolean;
  onConfirm?: () => void; // confirmButton が true の時のみ必要
}

この設計では、confirmButtontrue なのに onConfirm が未定義の場合のエラーを型レベルで検出できません。

拡張性の限界

コンポーネントが成長するにつれて、新しい要件に対応するためのプロップ追加が困難になります。

typescript// 拡張が困難な設計例
interface CardProps {
  title: string;
  content: string;
  // 新しい要件: アイコン表示
  icon?: string;
  // さらに新しい要件: カスタムヘッダー
  customHeader?: React.ReactNode;
  // 要件が増えるたびにプロップが増加...
}

このような設計では、コンポーネントが肥大化し、保守が困難になってしまいます。

ランタイムエラーの頻発

型定義が不十分だと、開発時には問題なく見えても、実際の使用時にエラーが発生する可能性が高まります。

typescript// ランタイムエラーの原因となる例
const UserCard = ({ user }) => {
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      {/* user.profile が存在しない場合にエラー */}
      <img src={user.profile.avatar} alt='avatar' />
    </div>
  );
};

これらの課題を解決するために、型ファーストなアプローチが必要となるのです。

解決策

型ファーストなコンポーネント設計アプローチでは、まず型定義から始めて、その後に実装を行います。このアプローチにより、前述の課題を根本的に解決できるでしょう。

型ファースト設計の基本原則

型ファーストアプローチでは、以下の順序で設計を進めます。

  1. 要件の型による表現: コンポーネントが満たすべき仕様を型で表現
  2. 型制約の定義: 無効な組み合わせを型レベルで排除
  3. 実装の型適合: 型に従った実装の作成
  4. 型による検証: コンパイル時の安全性確認

設計フローの実践

具体的な設計フローを見てみましょう。

ステップ 1: 要件の分析と型表現

まず、コンポーネントの要件を分析し、型で表現します。

typescript// 要件: ボタンコンポーネント
// - 3つのバリアント: primary, secondary, danger
// - 3つのサイズ: small, medium, large
// - 無効状態の表現
// - クリックハンドラーの設定

// 型による要件の表現
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

interface ButtonProps {
  variant: ButtonVariant;
  size: ButtonSize;
  disabled?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}

ステップ 2: 型制約による安全性確保

次に、無効な組み合わせを型レベルで排除します。

typescript// より厳密な型制約の例
type ButtonState =
  | {
      variant: 'primary';
      size: ButtonSize;
      disabled?: false;
    }
  | {
      variant: 'secondary';
      size: ButtonSize;
      disabled?: boolean;
    }
  | {
      variant: 'danger';
      size: ButtonSize;
      disabled?: false;
      confirmRequired: true;
    };

// この型定義により、danger ボタンは確認が必要であることを強制

ステップ 3: 型ガイド実装

型定義に従って実装を進めることで、型安全性を保ちながら開発できます。

typescriptconst Button: React.FC<ButtonProps> = (props) => {
  // 型により props の内容が明確
  const {
    variant,
    size,
    disabled = false,
    onClick,
    children,
  } = props;

  // TypeScript が型チェックを行うため、安全な実装が可能
  const baseClasses = `btn btn-${variant} btn-${size}`;
  const className = disabled
    ? `${baseClasses} btn-disabled`
    : baseClasses;

  return (
    <button
      className={className}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

型ファーストアプローチのメリット

この手法により、以下のメリットを享受できます。

  • 早期エラー検出: コンパイル時に問題を発見
  • 自己文書化: 型定義が API ドキュメントの役割を果たす
  • リファクタリング支援: 型による影響範囲の把握
  • チーム開発の効率化: 型による仕様の共有

次の章では、これらの原則を具体的な例で実践していきましょう。

具体例

ここからは、TypeScript の各種型機能を活用した具体的なコンポーネント設計例をご紹介します。段階的に複雑さを増しながら、実際のプロジェクトで活用できる手法を学んでいきましょう。

基本的な型定義から始めるコンポーネント設計

まずは、基本的な型定義から始まるコンポーネント設計を見てみましょう。シンプルなカードコンポーネントを例に、型ファーストアプローチの基礎を学んでいきます。

要件の型による定義

カードコンポーネントの要件を型で表現することから始めます。

typescript// カードコンポーネントの基本的な型定義
interface CardProps {
  /** カードのタイトル */
  title: string;
  /** カードの説明文 */
  description: string;
  /** 画像のURL(オプション) */
  imageUrl?: string;
  /** カードがクリック可能かどうか */
  clickable?: boolean;
  /** クリック時のハンドラー */
  onCardClick?: () => void;
}

この基本的な型定義では、以下の点が明確になっています。

  • 必須項目と任意項目の区別
  • 各プロップの用途(JSDoc コメント)
  • 型安全なイベントハンドラー

型による実装ガイド

型定義に基づいて、実装を進めていきます。

typescriptconst Card: React.FC<CardProps> = ({
  title,
  description,
  imageUrl,
  clickable = false,
  onCardClick,
}) => {
  // 型により、props の構造が明確
  const handleClick = () => {
    if (clickable && onCardClick) {
      onCardClick();
    }
  };

  return (
    <div
      className={`card ${
        clickable ? 'card-clickable' : ''
      }`}
      onClick={handleClick}
      role={clickable ? 'button' : undefined}
      tabIndex={clickable ? 0 : undefined}
    >
      {imageUrl && (
        <div className='card-image'>
          <img src={imageUrl} alt={title} />
        </div>
      )}
      <div className='card-content'>
        <h3 className='card-title'>{title}</h3>
        <p className='card-description'>{description}</p>
      </div>
    </div>
  );
};

使用例と型の恩恵

型定義により、コンポーネントの使用方法が明確になります。

typescript// 型安全な使用例
const ExampleUsage = () => {
  return (
    <div>
      {/* 必須項目のみ指定 */}
      <Card
        title='基本カード'
        description='基本的なカードの例です'
      />

      {/* クリック可能なカード */}
      <Card
        title='クリック可能カード'
        description='クリックできるカードです'
        clickable={true}
        onCardClick={() =>
          console.log('カードがクリックされました')
        }
      />

      {/* TypeScript がエラーを検出する例 */}
      <Card
        title='エラー例'
        // description が不足コンパイルエラー
      />
    </div>
  );
};

このように基本的な型定義でも、大きな安全性向上が実現できます。

Union Types を活用したバリアント管理

Union Types は、コンポーネントの異なるバリエーションを型安全に管理するための強力な機能です。アラートコンポーネントを例に、バリアント管理の手法を学びましょう。

バリアントの型定義

まず、アラートコンポーネントのバリアントを Union Types で定義します。

typescript// アラートの種類を Union Type で定義
type AlertVariant =
  | 'success'
  | 'warning'
  | 'error'
  | 'info';

// 各バリアントに対応するアイコンの型
type AlertIcon = '✓' | '⚠' | '✕' | 'ℹ';

// バリアントとアイコンのマッピング
const ALERT_CONFIGS: Record<
  AlertVariant,
  { icon: AlertIcon; className: string }
> = {
  success: { icon: '✓', className: 'alert-success' },
  warning: { icon: '⚠', className: 'alert-warning' },
  error: { icon: '✕', className: 'alert-error' },
  info: { icon: 'ℹ', className: 'alert-info' },
};

バリアント対応のコンポーネント設計

Union Types により、各バリアントに対応した型安全なコンポーネントを作成できます。

typescriptinterface AlertProps {
  /** アラートの種類 */
  variant: AlertVariant;
  /** メインメッセージ */
  message: string;
  /** 詳細メッセージ(オプション) */
  details?: string;
  /** 閉じるボタンの表示 */
  dismissible?: boolean;
  /** 閉じる時のハンドラー */
  onDismiss?: () => void;
}

const Alert: React.FC<AlertProps> = ({
  variant,
  message,
  details,
  dismissible = false,
  onDismiss,
}) => {
  // Union Type により、config の取得が型安全
  const config = ALERT_CONFIGS[variant];

  return (
    <div
      className={`alert ${config.className}`}
      role='alert'
    >
      <div className='alert-content'>
        <span className='alert-icon' aria-hidden='true'>
          {config.icon}
        </span>
        <div className='alert-message'>
          <p className='alert-main-message'>{message}</p>
          {details && (
            <p className='alert-details'>{details}</p>
          )}
        </div>
      </div>
      {dismissible && (
        <button
          className='alert-close'
          onClick={onDismiss}
          aria-label='アラートを閉じる'
        >
          ×
        </button>
      )}
    </div>
  );
};

バリアント使用例

Union Types により、有効なバリアントのみが使用可能になります。

typescriptconst AlertExamples = () => {
  return (
    <div className='alert-examples'>
      {/* 成功アラート */}
      <Alert
        variant='success'
        message='操作が正常に完了しました'
        details='データが正常に保存されました。'
      />

      {/* 警告アラート */}
      <Alert
        variant='warning'
        message='注意が必要です'
        dismissible={true}
        onDismiss={() => console.log('Warning dismissed')}
      />

      {/* エラーアラート */}
      <Alert
        variant='error'
        message='エラーが発生しました'
        details='ネットワーク接続を確認してください。'
      />

      {/* TypeScript がエラーを検出 */}
      <Alert
        variant='danger' // ← コンパイルエラー:'danger'  AlertVariant にない
        message='無効なバリアント'
      />
    </div>
  );
};

拡張可能なバリアント設計

Union Types は簡単に拡張できるため、将来的な要件変更にも柔軟に対応できます。

typescript// バリアントの拡張例
type ExtendedAlertVariant =
  | AlertVariant
  | 'debug'
  | 'critical';

// 新しいバリアントの設定を追加
const EXTENDED_ALERT_CONFIGS: Record<
  ExtendedAlertVariant,
  { icon: string; className: string }
> = {
  ...ALERT_CONFIGS,
  debug: { icon: '🐛', className: 'alert-debug' },
  critical: { icon: '🚨', className: 'alert-critical' },
};

Union Types を活用することで、コンポーネントのバリエーション管理が格段に安全で保守しやすくなるでしょう。

Generic Types による再利用可能なコンポーネント

Generic Types を使用することで、型安全性を保ちながら高度に再利用可能なコンポーネントを作成できます。データテーブルコンポーネントを例に、Generic Types の活用方法を学びましょう。

ジェネリックなデータテーブルの設計

まず、任意の型のデータを表示できるテーブルコンポーネントを設計します。

typescript// テーブルカラムの定義
interface TableColumn<T> {
  /** カラムのキー(データのプロパティ名) */
  key: keyof T;
  /** カラムの表示名 */
  label: string;
  /** カスタムレンダラー(オプション) */
  render?: (value: T[keyof T], item: T) => React.ReactNode;
  /** ソート可能かどうか */
  sortable?: boolean;
  /** カラム幅 */
  width?: string;
}

ここで重要なのは、keyof T を使用することで、存在しないプロパティキーを型レベルで排除している点です。

ジェネリックテーブルコンポーネント

Generic Types を活用したテーブルコンポーネントの実装を見てみましょう。

typescriptinterface DataTableProps<T> {
  /** 表示するデータの配列 */
  data: T[];
  /** カラム定義の配列 */
  columns: TableColumn<T>[];
  /** 読み込み中の状態 */
  loading?: boolean;
  /** データが空の場合のメッセージ */
  emptyMessage?: string;
  /** 行クリック時のハンドラー */
  onRowClick?: (item: T) => void;
}

const DataTable = <T>({
  data,
  columns,
  loading = false,
  emptyMessage = 'データがありません',
  onRowClick,
}: DataTableProps<T>) => {
  if (loading) {
    return (
      <div className='table-loading'>読み込み中...</div>
    );
  }

  if (data.length === 0) {
    return (
      <div className='table-empty'>{emptyMessage}</div>
    );
  }

  return (
    <table className='data-table'>
      <thead>
        <tr>
          {columns.map((column) => (
            <th
              key={String(column.key)}
              style={{ width: column.width }}
            >
              {column.label}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, index) => (
          <tr
            key={index}
            onClick={() => onRowClick?.(item)}
            className={
              onRowClick ? 'table-row-clickable' : ''
            }
          >
            {columns.map((column) => (
              <td key={String(column.key)}>
                {column.render
                  ? column.render(item[column.key], item)
                  : String(item[column.key] ?? '')}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

型安全な使用例

Generic Types により、各データ型に対して型安全なテーブルが作成できます。

typescript// ユーザーデータの型定義
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  lastLogin: Date | null;
  isActive: boolean;
}

// 商品データの型定義
interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

const TableExamples = () => {
  const users: User[] = [
    {
      id: 1,
      name: '田中太郎',
      email: 'tanaka@example.com',
      role: 'admin',
      lastLogin: new Date(),
      isActive: true,
    },
    // ... 他のユーザーデータ
  ];

  const products: Product[] = [
    {
      id: 'PROD001',
      name: 'TypeScript入門書',
      price: 2980,
      category: '書籍',
      inStock: true,
    },
    // ... 他の商品データ
  ];

  return (
    <div>
      {/* ユーザーテーブル */}
      <DataTable<User>
        data={users}
        columns={[
          { key: 'name', label: '名前' },
          { key: 'email', label: 'メールアドレス' },
          {
            key: 'role',
            label: '権限',
            render: (value) => (
              <span className={`role-badge role-${value}`}>
                {value}
              </span>
            ),
          },
          {
            key: 'isActive',
            label: 'ステータス',
            render: (value) => (value ? '有効' : '無効'),
          },
        ]}
        onRowClick={(user) =>
          console.log('ユーザークリック:', user.name)
        }
      />

      {/* 商品テーブル */}
      <DataTable<Product>
        data={products}
        columns={[
          { key: 'name', label: '商品名' },
          {
            key: 'price',
            label: '価格',
            render: (value) => `¥${value.toLocaleString()}`,
          },
          { key: 'category', label: 'カテゴリ' },
          {
            key: 'inStock',
            label: '在庫状況',
            render: (value) => (
              <span
                className={
                  value ? 'in-stock' : 'out-of-stock'
                }
              >
                {value ? '在庫あり' : '売り切れ'}
              </span>
            ),
          },
        ]}
      />

      {/* TypeScript がエラーを検出する例 */}
      <DataTable<User>
        data={users}
        columns={[
          { key: 'name', label: '名前' },
          { key: 'invalidKey', label: '無効' }, // ← エラー:User に存在しないキー
        ]}
      />
    </div>
  );
};

高度なジェネリック設計パターン

より複雑な要求に対応するため、制約付きジェネリクスも活用できます。

typescript// ID を持つオブジェクトの制約
interface HasId {
  id: string | number;
}

// ID 制約付きのテーブルコンポーネント
interface SelectableTableProps<T extends HasId>
  extends DataTableProps<T> {
  /** 選択された項目のID一覧 */
  selectedIds?: (string | number)[];
  /** 選択変更時のハンドラー */
  onSelectionChange?: (
    selectedIds: (string | number)[]
  ) => void;
  /** 複数選択の可否 */
  multiSelect?: boolean;
}

const SelectableTable = <T extends HasId>(
  props: SelectableTableProps<T>
) => {
  // HasId 制約により、item.id が安全にアクセス可能
  const handleRowSelect = (item: T) => {
    const id = item.id;
    // 選択ロジックの実装...
  };

  // 実装の詳細...
};

Generic Types を活用することで、型安全性を保ちながら高度に再利用可能なコンポーネントを作成できることがお分かりいただけたでしょう。

Conditional Types でより柔軟な API 設計

Conditional Types は、型レベルでの条件分岐を可能にする強力な機能です。これを活用することで、プロップの組み合わせに応じて動的に型を変更する、より柔軟なコンポーネント API を設計できます。

条件付き型の基本概念

まず、入力コンポーネントを例に、条件付き型の活用方法を見てみましょう。

typescript// 入力タイプの定義
type InputType =
  | 'text'
  | 'email'
  | 'password'
  | 'number'
  | 'textarea';

// 入力タイプに応じて value の型を変更
type InputValue<T extends InputType> = T extends 'number'
  ? number
  : string;

// 入力タイプに応じて追加プロップスを決定
type AdditionalProps<T extends InputType> =
  T extends 'textarea'
    ? { rows?: number; cols?: number }
    : T extends 'number'
    ? { min?: number; max?: number; step?: number }
    : {};

条件付き型を活用したコンポーネント設計

Conditional Types を使用して、より柔軟なコンポーネント API を実現します。

typescriptinterface BaseInputProps<T extends InputType> {
  /** 入力欄のタイプ */
  type: T;
  /** ラベル */
  label: string;
  /** 現在の値 */
  value: InputValue<T>;
  /** 値変更時のハンドラー */
  onChange: (value: InputValue<T>) => void;
  /** プレースホルダーテキスト */
  placeholder?: string;
  /** 必須フィールドかどうか */
  required?: boolean;
  /** 無効状態 */
  disabled?: boolean;
  /** エラーメッセージ */
  error?: string;
}

// 条件付き型で完全な Props 型を構築
type InputProps<T extends InputType> = BaseInputProps<T> &
  AdditionalProps<T>;

const Input = <T extends InputType>({
  type,
  label,
  value,
  onChange,
  placeholder,
  required = false,
  disabled = false,
  error,
  ...additionalProps
}: InputProps<T>) => {
  const inputId = `input-${Math.random()
    .toString(36)
    .substr(2, 9)}`;

  return (
    <div className='input-group'>
      <label htmlFor={inputId} className='input-label'>
        {label}
        {required && (
          <span className='required-mark'>*</span>
        )}
      </label>

      {type === 'textarea' ? (
        <textarea
          id={inputId}
          value={value as string}
          onChange={(e) =>
            onChange(e.target.value as InputValue<T>)
          }
          placeholder={placeholder}
          disabled={disabled}
          className={`input-field ${
            error ? 'input-error' : ''
          }`}
          {...(additionalProps as AdditionalProps<'textarea'>)}
        />
      ) : (
        <input
          id={inputId}
          type={type}
          value={value}
          onChange={(e) => {
            const newValue =
              type === 'number'
                ? parseFloat(e.target.value) || 0
                : e.target.value;
            onChange(newValue as InputValue<T>);
          }}
          placeholder={placeholder}
          disabled={disabled}
          className={`input-field ${
            error ? 'input-error' : ''
          }`}
          {...(additionalProps as AdditionalProps<T>)}
        />
      )}

      {error && (
        <span className='input-error-message'>{error}</span>
      )}
    </div>
  );
};

型安全な使用例

Conditional Types により、各入力タイプに応じた型安全な使用が可能になります。

typescriptconst FormExample = () => {
  const [textValue, setTextValue] = useState<string>('');
  const [numberValue, setNumberValue] = useState<number>(0);
  const [textareaValue, setTextareaValue] =
    useState<string>('');

  return (
    <form className='form-example'>
      {/* テキスト入力 */}
      <Input
        type='text'
        label='名前'
        value={textValue}
        onChange={setTextValue} // string を期待
        placeholder='名前を入力してください'
        required
      />

      {/* 数値入力(条件付きプロップスが有効) */}
      <Input
        type='number'
        label='年齢'
        value={numberValue}
        onChange={setNumberValue} // number を期待
        min={0}
        max={120}
        step={1}
      />

      {/* テキストエリア(条件付きプロップスが有効) */}
      <Input
        type='textarea'
        label='自己紹介'
        value={textareaValue}
        onChange={setTextareaValue} // string を期待
        placeholder='自己紹介を入力してください'
        rows={4}
        cols={50}
      />

      {/* TypeScript がエラーを検出する例 */}
      <Input
        type='text'
        label='テキスト'
        value={123} // ← エラーstring を期待しているのに number
        onChange={setTextValue}
      />

      <Input
        type='number'
        label='数値'
        value={numberValue}
        onChange={setNumberValue}
        rows={4} // ← エラーnumber 型にはない props
      />
    </form>
  );
};

より複雑な条件付き型の応用

さらに複雑な条件分岐も可能です。モーダルコンポーネントを例に見てみましょう。

typescript// モーダルの種類
type ModalVariant = 'confirm' | 'alert' | 'custom';

// バリアントに応じた必須プロップス
type VariantSpecificProps<T extends ModalVariant> =
  T extends 'confirm'
    ? {
        onConfirm: () => void;
        onCancel: () => void;
        confirmText?: string;
        cancelText?: string;
      }
    : T extends 'alert'
    ? {
        onOk: () => void;
        okText?: string;
      }
    : {}; // custom の場合は追加プロップスなし

interface BaseModalProps<T extends ModalVariant> {
  variant: T;
  isOpen: boolean;
  title: string;
  children: React.ReactNode;
  onClose: () => void;
}

type ModalProps<T extends ModalVariant> =
  BaseModalProps<T> & VariantSpecificProps<T>;

const Modal = <T extends ModalVariant>({
  variant,
  isOpen,
  title,
  children,
  onClose,
  ...variantProps
}: ModalProps<T>) => {
  if (!isOpen) return null;

  const renderFooter = () => {
    switch (variant) {
      case 'confirm':
        const confirmProps =
          variantProps as VariantSpecificProps<'confirm'>;
        return (
          <div className='modal-footer'>
            <button onClick={confirmProps.onCancel}>
              {confirmProps.cancelText || 'キャンセル'}
            </button>
            <button onClick={confirmProps.onConfirm}>
              {confirmProps.confirmText || 'OK'}
            </button>
          </div>
        );
      case 'alert':
        const alertProps =
          variantProps as VariantSpecificProps<'alert'>;
        return (
          <div className='modal-footer'>
            <button onClick={alertProps.onOk}>
              {alertProps.okText || 'OK'}
            </button>
          </div>
        );
      default:
        return null;
    }
  };

  return (
    <div className='modal-overlay' onClick={onClose}>
      <div
        className='modal-content'
        onClick={(e) => e.stopPropagation()}
      >
        <div className='modal-header'>
          <h2>{title}</h2>
          <button onClick={onClose}>×</button>
        </div>
        <div className='modal-body'>{children}</div>
        {renderFooter()}
      </div>
    </div>
  );
};

条件付きモーダルの使用例

typescriptconst ModalExamples = () => {
  const [showConfirm, setShowConfirm] = useState(false);
  const [showAlert, setShowAlert] = useState(false);
  const [showCustom, setShowCustom] = useState(false);

  return (
    <div>
      {/* 確認モーダル(onConfirm と onCancel が必須) */}
      <Modal
        variant='confirm'
        isOpen={showConfirm}
        title='削除確認'
        onClose={() => setShowConfirm(false)}
        onConfirm={() => {
          console.log('削除実行');
          setShowConfirm(false);
        }}
        onCancel={() => setShowConfirm(false)}
        confirmText='削除する'
        cancelText='やめる'
      >
        <p>
          本当に削除しますか?この操作は取り消せません。
        </p>
      </Modal>

      {/* アラートモーダル(onOk が必須) */}
      <Modal
        variant='alert'
        isOpen={showAlert}
        title='処理完了'
        onClose={() => setShowAlert(false)}
        onOk={() => setShowAlert(false)}
        okText='了解'
      >
        <p>処理が正常に完了しました。</p>
      </Modal>

      {/* カスタムモーダル(追加プロップス不要) */}
      <Modal
        variant='custom'
        isOpen={showCustom}
        title='カスタムモーダル'
        onClose={() => setShowCustom(false)}
      >
        <p>カスタムコンテンツです。</p>
        <div className='custom-footer'>
          <button onClick={() => setShowCustom(false)}>
            カスタムボタン
          </button>
        </div>
      </Modal>
    </div>
  );
};

Conditional Types を活用することで、コンポーネントの使用方法に応じて動的に型が変化する、非常に柔軟で型安全な API 設計が実現できます。

Template Literal Types による厳密な文字列管理

Template Literal Types は、TypeScript 4.1 で導入された機能で、文字列リテラル型をテンプレート形式で組み合わせることができます。これを活用することで、CSS クラス名、API エンドポイント、設定キーなどの文字列を型安全に管理できるでしょう。

CSS クラス名の型安全な管理

まず、CSS クラス名を Template Literal Types で管理する方法を見てみましょう。

typescript// ベースクラスとモディファイアの定義
type BaseClass = 'btn' | 'card' | 'modal' | 'input';
type Modifier =
  | 'primary'
  | 'secondary'
  | 'large'
  | 'small'
  | 'disabled';
type State = 'hover' | 'focus' | 'active';

// Template Literal Types でクラス名を生成
type ClassName =
  | `${BaseClass}`
  | `${BaseClass}--${Modifier}`
  | `${BaseClass}--${State}`;

// より複雑な組み合わせも可能
type ComplexClassName =
  `${BaseClass}--${Modifier}--${State}`;

// BEM 記法に準拠したクラス名生成
type BemBlock = 'header' | 'sidebar' | 'content' | 'footer';
type BemElement = 'title' | 'text' | 'button' | 'icon';
type BemModifier = 'active' | 'disabled' | 'highlighted';

type BemClass =
  | `${BemBlock}`
  | `${BemBlock}__${BemElement}`
  | `${BemBlock}--${BemModifier}`
  | `${BemBlock}__${BemElement}--${BemModifier}`;

型安全なスタイルユーティリティ

Template Literal Types を使用して、型安全なスタイルユーティリティを作成します。

typescript// CSS プロパティの値型定義
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type Color =
  | 'primary'
  | 'secondary'
  | 'success'
  | 'warning'
  | 'error';
type Spacing = '0' | '1' | '2' | '3' | '4' | '5';

// Template Literal Types でユーティリティクラスを生成
type UtilityClass =
  | `text-${Color}`
  | `bg-${Color}`
  | `text-${Size}`
  | `p-${Spacing}`
  | `m-${Spacing}`
  | `px-${Spacing}`
  | `py-${Spacing}`
  | `mx-${Spacing}`
  | `my-${Spacing}`;

// クラス名生成ユーティリティ
function createClassName(
  ...classes: (
    | ClassName
    | UtilityClass
    | BemClass
    | string
  )[]
): string {
  return classes.filter(Boolean).join(' ');
}

Template Literal Types を活用したコンポーネント

実際のコンポーネントで Template Literal Types を活用してみましょう。

typescript// ボタンコンポーネントの型定義
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonVariant = 'solid' | 'outline' | 'ghost';
type ButtonColor = 'blue' | 'green' | 'red' | 'gray';

// Template Literal Types で動的なクラス名を生成
type ButtonClass =
  `btn-${ButtonVariant}-${ButtonColor}-${ButtonSize}`;

interface ButtonProps {
  size: ButtonSize;
  variant: ButtonVariant;
  color: ButtonColor;
  disabled?: boolean;
  loading?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

const Button: React.FC<ButtonProps> = ({
  size,
  variant,
  color,
  disabled = false,
  loading = false,
  children,
  onClick,
}) => {
  // Template Literal Types により、クラス名が型安全に生成される
  const baseClass: ButtonClass = `btn-${variant}-${color}-${size}`;

  const className = createClassName(
    'btn',
    baseClass,
    disabled && 'btn--disabled',
    loading && 'btn--loading'
  );

  return (
    <button
      className={className}
      disabled={disabled || loading}
      onClick={onClick}
    >
      {loading && <span className='btn__spinner' />}
      <span className='btn__text'>{children}</span>
    </button>
  );
};

API エンドポイントの型安全な管理

Template Literal Types は API エンドポイントの管理にも活用できます。

typescript// API リソースとアクションの定義
type ApiResource =
  | 'users'
  | 'posts'
  | 'comments'
  | 'categories';
type ApiAction =
  | 'list'
  | 'create'
  | 'update'
  | 'delete'
  | 'show';
type ApiVersion = 'v1' | 'v2';

// Template Literal Types で API エンドポイントを生成
type ApiEndpoint = `/api/${ApiVersion}/${ApiResource}`;
type ApiEndpointWithId =
  `/api/${ApiVersion}/${ApiResource}/${string}`;

// API クライアントの型安全な実装
class ApiClient {
  private baseUrl = 'https://api.example.com';

  // Template Literal Types により、エンドポイントが型安全
  async get<T>(
    endpoint: ApiEndpoint | ApiEndpointWithId
  ): Promise<T> {
    const response = await fetch(
      `${this.baseUrl}${endpoint}`
    );
    return response.json();
  }

  async post<T>(
    endpoint: ApiEndpoint,
    data: unknown
  ): Promise<T> {
    const response = await fetch(
      `${this.baseUrl}${endpoint}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      }
    );
    return response.json();
  }
}

const apiClient = new ApiClient();

// 使用例:型安全なエンドポイント指定
const fetchUsers = async () => {
  // 有効なエンドポイント
  const users = await apiClient.get('/api/v1/users');

  // TypeScript がエラーを検出
  // const invalid = await apiClient.get('/api/v3/users'); // v3 は存在しない
  // const invalid2 = await apiClient.get('/api/v1/invalid'); // invalid リソースは存在しない
};

設定管理での活用

設定キーの管理でも Template Literal Types が威力を発揮します。

typescript// 設定のカテゴリとキーを定義
type ConfigCategory = 'ui' | 'api' | 'auth' | 'feature';
type UiConfig = 'theme' | 'language' | 'fontSize';
type ApiConfig = 'baseUrl' | 'timeout' | 'retryCount';
type AuthConfig = 'tokenKey' | 'refreshKey' | 'expiry';
type FeatureConfig =
  | 'enableNotifications'
  | 'enableDarkMode'
  | 'enableBeta';

// Template Literal Types で設定キーを生成
type ConfigKey =
  | `ui.${UiConfig}`
  | `api.${ApiConfig}`
  | `auth.${AuthConfig}`
  | `feature.${FeatureConfig}`;

// 型安全な設定管理クラス
class ConfigManager {
  private config: Partial<Record<ConfigKey, unknown>> = {};

  set<T>(key: ConfigKey, value: T): void {
    this.config[key] = value;
  }

  get<T>(key: ConfigKey): T | undefined {
    return this.config[key] as T | undefined;
  }

  has(key: ConfigKey): boolean {
    return key in this.config;
  }
}

const config = new ConfigManager();

// 使用例:型安全な設定操作
config.set('ui.theme', 'dark');
config.set('api.timeout', 5000);
config.set('feature.enableNotifications', true);

// TypeScript がエラーを検出
// config.set('invalid.key', 'value'); // 存在しない設定キー
// config.set('ui.invalidOption', 'value'); // 存在しない ui 設定

多言語対応での活用

国際化(i18n)においても Template Literal Types は有用です。

typescript// 言語コードと翻訳キーの定義
type Language = 'ja' | 'en' | 'ko' | 'zh';
type Namespace =
  | 'common'
  | 'auth'
  | 'dashboard'
  | 'settings';
type TranslationKey =
  | 'welcome'
  | 'login'
  | 'logout'
  | 'save'
  | 'cancel'
  | 'delete'
  | 'confirm';

// Template Literal Types で翻訳キーを生成
type I18nKey = `${Namespace}:${TranslationKey}`;

// 型安全な翻訳関数
class I18nManager {
  private translations: Partial<
    Record<Language, Partial<Record<I18nKey, string>>>
  > = {};
  private currentLanguage: Language = 'ja';

  setLanguage(language: Language): void {
    this.currentLanguage = language;
  }

  translate(
    key: I18nKey,
    params?: Record<string, string | number>
  ): string {
    const translation =
      this.translations[this.currentLanguage]?.[key] ?? key;

    if (!params) return translation;

    return Object.entries(params).reduce(
      (text, [param, value]) =>
        text.replace(`{{${param}}}`, String(value)),
      translation
    );
  }

  setTranslations(
    language: Language,
    translations: Partial<Record<I18nKey, string>>
  ): void {
    if (!this.translations[language]) {
      this.translations[language] = {};
    }
    Object.assign(
      this.translations[language]!,
      translations
    );
  }
}

const i18n = new I18nManager();

// 翻訳データの設定
i18n.setTranslations('ja', {
  'common:welcome': 'ようこそ',
  'auth:login': 'ログイン',
  'auth:logout': 'ログアウト',
});

// 使用例:型安全な翻訳
const welcomeMessage = i18n.translate('common:welcome');
const loginText = i18n.translate('auth:login');

// TypeScript がエラーを検出
// const invalid = i18n.translate('invalid:key'); // 存在しない翻訳キー

Template Literal Types を活用することで、文字列ベースの様々な仕組みを型安全に管理できるようになります。これにより、タイポによるバグの防止や、IDE での自動補完の恩恵を受けることができるでしょう。

まとめ

本記事では、TypeScript の型システムを活用した「型から考える」UI コンポーネント設計術をご紹介いたしました。従来の実装主導のアプローチから、型ファーストな設計手法に転換することで、より安全で保守性の高いコンポーネントを作成できることをお分かりいただけたかと思います。

型ファースト設計がもたらす主な恩恵

開発効率の向上 型定義から始めることで、コンポーネントの仕様が明確になり、実装時の迷いが大幅に減少します。また、TypeScript の強力な IntelliSense により、開発中の自動補完やエラー検出が格段に向上するでしょう。

品質の確保 コンパイル時の型チェックにより、ランタイムエラーを事前に防ぐことができます。特に、プロップの型ミスマッチや存在しないプロパティへのアクセスなど、よくある問題を開発段階で解決できる点は大きなメリットです。

チーム開発の円滑化
型定義が自己文書化の役割を果たすため、チームメンバー間でのコンポーネント仕様の共有が容易になります。新しいメンバーがプロジェクトに参加した際も、型定義を見れば使用方法を理解できるでしょう。

保守性の向上 リファクタリング時に型システムが影響範囲を教えてくれるため、安全な改修が可能になります。また、要件変更に伴う型定義の更新により、関連する全ての箇所で必要な修正を漏れなく行えます。

実践で活用すべき型技術

Union Types により、コンポーネントのバリエーション管理が格段に安全になります。有効な選択肢のみを型レベルで制限することで、無効な組み合わせを防げるでしょう。

Generic Types を活用することで、型安全性を保ちながら高度に再利用可能なコンポーネントを作成できます。データテーブルやフォームコンポーネントなど、汎用性が求められる場面で威力を発揮します。

Conditional Types は、プロップの組み合わせに応じて動的に型を変更できる強力な機能です。より柔軟で直感的な API 設計が実現できるでしょう。

Template Literal Types により、CSS クラス名や API エンドポイントなど、文字列ベースの仕組みも型安全に管理できるようになります。

継続的な改善のために

型ファースト設計を成功させるためには、以下の点を継続的に意識することが重要です。

まず、型定義の継続的な見直しを行いましょう。プロジェクトの成長に伴い、より適切な型定義に改善していくことで、さらなる開発効率向上が期待できます。

次に、チーム全体での型設計パターンの共有が必要です。プロジェクト固有の型設計パターンを文書化し、チームメンバー間で共有することで、一貫性のある実装が可能になります。

最後に、TypeScript の新機能への対応も欠かせません。TypeScript は活発に開発が続いており、新しい型機能が定期的に追加されています。これらの機能を積極的に学習し、プロジェクトに適用することで、より高度な型安全性を実現できるでしょう。

型から考えるコンポーネント設計は、最初は学習コストがかかるかもしれません。しかし、一度習得すれば、開発体験の向上と品質の確保という大きなリターンを得られます。ぜひ、皆さんのプロジェクトでも型ファーストなアプローチを取り入れて、より堅牢で保守性の高い UI コンポーネントを作成してみてください。

関連リンク