T-CREATOR

Tailwind × TypeScript × React:型安全なデザインシステム構築入門

Tailwind × TypeScript × React:型安全なデザインシステム構築入門

モダンなウェブ開発において、見た目の美しさと開発効率を両立させることは常に重要な課題です。Tailwind CSS はユーティリティファーストの柔軟なスタイリングを提供し、TypeScript は型安全性による堅牢なコード構築を可能にし、React はコンポーネントベースの効率的な UI 開発をサポートします。

これら 3 つの強力なツールを組み合わせることで、保守性が高く、型安全で、視覚的に一貫性のあるデザインシステムを構築できます。今回は、これらのテクノロジーを組み合わせて型安全なデザインシステムを構築する方法について解説します。

背景:従来のデザインシステムの型安全性の課題

従来の CSS/SCSS ベースのデザインシステム

これまでのデザインシステムは、主に CSS、SCSS、CSS-in-JS などの技術を使用して構築されてきました。これらのアプローチには、それぞれのメリットがありますが、型安全性という点では多くの課題がありました。

scss// SCSSによるデザインシステムの例
$primary-color: #3b82f6;
$spacing-sm: 0.5rem;
$spacing-md: 1rem;
$spacing-lg: 1.5rem;

.btn {
  padding: $spacing-sm $spacing-md;
  background-color: $primary-color;
  border-radius: 0.25rem;

  &--large {
    padding: $spacing-md $spacing-lg;
  }
}

このようなアプローチでは:

  1. 変数の型安全性がない$primary-colorに誤って数値が設定されても、コンパイル時にエラーが検出されません。
  2. クラス名のタイプミスが検出されないbtn--largeと書くべきところをbtn--largと書いてもエラーになりません。
  3. プロパティと値の組み合わせの誤りを防げないpaddingに色を指定しても検出されません。

CSS-in-JS との比較

CSS-in-JS ライブラリ(styled-components や Emotion など)は、コンポーネントとスタイルの統合により改善を図りましたが、型安全性は限定的でした。

tsx// styled-componentsの例
const Button = styled.button<{
  size?: 'small' | 'medium' | 'large';
}>`
  padding: ${(props) =>
    props.size === 'large'
      ? '1rem 1.5rem'
      : props.size === 'small'
      ? '0.25rem 0.5rem'
      : '0.5rem 1rem'};
  background-color: #3b82f6;
  border-radius: 0.25rem;
`;

これらは型定義が可能ですが、スタイル自体の型安全性は保証されません。

Tailwind の登場とその課題

Tailwind CSS はユーティリティクラスベースのアプローチで革新をもたらしましたが、当初は TypeScript との相性があまり良くありませんでした。

html<!-- 初期のTailwindの例 -->
<button
  class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
  ボタン
</button>

このアプローチでは:

  1. クラス名の検証がないbg-blue-500bg-blue-5000と誤記しても検出されません。
  2. 無効な組み合わせの検出がない:矛盾するクラス(flex inline-blockなど)が混在しても警告されません。
  3. IDE 補完のサポートが限定的:TypeScript による補完がなければ、使用可能なクラスを覚えておく必要があります。

課題:クラス名のタイプミスや不正な値の混入問題

Tailwind CSS、TypeScript、React を組み合わせる際の主な課題を詳しく見ていきましょう。

1. 文字列としてのクラス名の脆弱性

React では、Tailwind のクラスはただの JSX 属性の文字列値として扱われます:

tsxfunction Button({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <button className='bg-blue-500 px-4 py-2 rounded text-white'>
      {children}
    </button>
  );
}

この方法では以下の問題があります:

  • クラス名のタイプミスbg-blue-500bg-blue-50bg-blu-500と誤記してもコンパイルエラーにはなりません。
  • 無効なクラスの使用bg-blurple-500のような存在しないクラスを使っても検出できません。
  • スタイルの競合text-black text-whiteのような矛盾したクラスの組み合わせも検出できません。

2. コンポーネントの props 経由のクラス制約の難しさ

コンポーネントにバリエーションを持たせたい場合、props でクラス名を切り替えることが多いですが、これも型安全ではありません:

tsxtype ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
};

function Button({
  variant = 'primary',
  size = 'md',
  children,
}: ButtonProps) {
  const variantClasses = {
    primary: 'bg-blue-500 hover:bg-blue-700',
    secondary: 'bg-gray-500 hover:bg-gray-700',
    danger: 'bg-red-500 hover:bg-red-700',
  };

  const sizeClasses = {
    sm: 'px-2 py-1 text-sm',
    md: 'px-4 py-2',
    lg: 'px-6 py-3 text-lg',
  };

  return (
    <button
      className={`${variantClasses[variant]} ${sizeClasses[size]} text-white rounded`}
    >
      {children}
    </button>
  );
}

この実装でも:

  • variantClasses や sizeClasses に誤った Tailwind クラスが入っていても検出できません。
  • 文字列連結によるクラス名生成のロジックミスが起きる可能性があります。

3. 一貫性の維持の難しさ

複数のコンポーネントにわたって一貫したスタイルを維持するのも課題です:

tsx// Buttonコンポーネント
<button className="bg-blue-500 text-white px-4 py-2 rounded">ボタン</button>

// 別の場所で似たボタンを作成
<button className="bg-blue-600 text-white px-3 py-2 rounded-md">別のボタン</button>

このようなわずかな違いがデザインの一貫性を損ない、保守を難しくします。

解決策:型定義による Tailwind クラスの制約方法

これらの課題を解決するために、TypeScript と Tailwind を組み合わせた型安全なアプローチを採用しましょう。

1. Tailwind クラス名の型定義

まず、Tailwind で使用可能なクラス名を型として定義します。これには複数のアプローチがあります:

a. 手動での型定義

最もシンプルなアプローチは、使用頻度の高い Tailwind クラスを手動で型定義することです:

tsx// types/tailwind.ts
export type TBackgroundColor =
  | 'bg-transparent'
  | 'bg-black'
  | 'bg-white'
  | 'bg-gray-50' | 'bg-gray-100' | 'bg-gray-200' | /* ... */ | 'bg-gray-900'
  | 'bg-blue-50' | 'bg-blue-100' | 'bg-blue-200' | /* ... */ | 'bg-blue-900'
  // 他の色...
;

export type TPadding =
  | 'p-0' | 'p-1' | 'p-2' | 'p-3' | 'p-4' | /* ... */ | 'p-12'
  | 'px-0' | 'px-1' | 'px-2' | 'px-3' | 'px-4' | /* ... */ | 'px-12'
  | 'py-0' | 'py-1' | 'py-2' | 'py-3' | 'py-4' | /* ... */ | 'py-12'
  // 他のパディング...
;

// その他のTailwindクラス型を定義...

export type TTailwindClass =
  | TBackgroundColor
  | TPadding
  // 他のTailwindクラス型...
;

b. 自動生成による型定義

手動での型定義は面倒なので、Tailwind の設定から TypeScript の型を自動生成するアプローチもあります:

bash# 例: tailwindcss-classnames というライブラリを使用
npm install tailwindcss-classnames
tsx// タイプセーフなTailwindクラス生成の例
import { classnames } from 'tailwindcss-classnames';

const buttonClasses = classnames(
  'bg-blue-500',
  'hover:bg-blue-700',
  'text-white',
  'font-bold',
  'py-2',
  'px-4',
  'rounded'
);

// この場合、無効なクラス名を指定するとコンパイルエラーになります

c. clsx や cn(class-variance-authority)との連携

型安全なクラス結合のために、以下のようなユーティリティを使用する方法も効果的です:

tsx// utils/cn.ts
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

これにより、複数のクラスを型安全に結合できます。

2. コンポーネントの props に型安全性を持たせる

コンポーネントの variant や size などの props に型安全性を持たせるには、以下のアプローチが有効です:

a. class-variance-authority(cva)の活用

class-variance-authority は、型安全なコンポーネントバリエーションを定義するのに役立ちます:

tsx// components/ui/button.tsx
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';
import { cn } from '@/utils/cn';

const buttonVariants = cva(
  // ベースとなるクラス
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline:
          'border border-input hover:bg-accent hover:text-accent-foreground',
        secondary:
          'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost:
          'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// Buttonコンポーネントの型を定義
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

// 型安全なButtonコンポーネント
export function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot : 'button';
  return (
    <Comp
      className={cn(
        buttonVariants({ variant, size }),
        className
      )}
      {...props}
    />
  );
}

このアプローチの利点:

  • variant と size の組み合わせが型安全
  • デフォルト値が型によって保証される
  • クラス名の衝突が twMerge によって解決される

3. テーマの型安全性を確保する

Tailwind のテーマ設定(colors, spacing, etc.)も型安全にアクセスできるようにします:

tsx// types/tailwind-theme.ts
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwindConfig from '../tailwind.config.js';

// 設定からテーマを抽出
const fullConfig = resolveConfig(tailwindConfig);
export const theme = fullConfig.theme;

// テーマの型を生成
export type TailwindTheme = typeof theme;
export type TailwindColor = keyof typeof theme.colors;
export type TailwindSpacing = keyof typeof theme.spacing;

// 型安全にテーマにアクセスする関数
export function getColor(
  color: TailwindColor,
  shade?: string | number
) {
  const colorObj = theme.colors[color];
  if (typeof colorObj === 'object' && shade) {
    return colorObj[shade as keyof typeof colorObj];
  }
  return typeof colorObj === 'string'
    ? colorObj
    : undefined;
}

具体例:型安全なコンポーネントライブラリの実装

ここからは、型安全なデザインシステムの基本コンポーネントを実装する具体例を見ていきます。

1. プロジェクトセットアップ

まず、Next.js + TypeScript + Tailwind CSS の環境を準備します:

bash# Next.jsプロジェクトを作成
npx create-next-app@latest my-design-system --typescript --tailwind --eslint

# 必要なパッケージをインストール
cd my-design-system
npm install class-variance-authority clsx tailwind-merge

2. 基本的なユーティリティの実装

tsx// lib/utils.ts
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

3. 型安全な Button コンポーネント

tsx// components/ui/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';

import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline:
          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary:
          'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost:
          'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    { className, variant, size, asChild = false, ...props },
    ref
  ) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(
          buttonVariants({ variant, size }),
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

4. 型安全な Input コンポーネント

tsx// components/ui/input.tsx
import * as React from 'react';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';

import { cn } from '@/lib/utils';

const inputVariants = cva(
  'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
  {
    variants: {
      variant: {
        default: '',
        ghost: 'border-none shadow-none',
      },
      size: {
        default: 'h-10',
        sm: 'h-8 text-xs',
        lg: 'h-12 text-base',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export interface InputProps
  extends Omit<
      React.InputHTMLAttributes<HTMLInputElement>,
      'size'
    >,
    VariantProps<typeof inputVariants> {}

const Input = React.forwardRef<
  HTMLInputElement,
  InputProps
>(({ className, variant, size, ...props }, ref) => {
  return (
    <input
      className={cn(
        inputVariants({ variant, size }),
        className
      )}
      ref={ref}
      {...props}
    />
  );
});

Input.displayName = 'Input';

export { Input, inputVariants };

5. 型安全な Card コンポーネント

tsx// components/ui/card.tsx
import * as React from 'react';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';

import { cn } from '@/lib/utils';

const cardVariants = cva(
  'rounded-lg border bg-card text-card-foreground shadow-sm',
  {
    variants: {
      variant: {
        default: '',
        destructive: 'border-destructive',
        success: 'border-green-500',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  }
);

export interface CardProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof cardVariants> {}

const Card = React.forwardRef<HTMLDivElement, CardProps>(
  ({ className, variant, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(cardVariants({ variant }), className)}
      {...props}
    />
  )
);
Card.displayName = 'Card';

const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      'flex flex-col space-y-1.5 p-6',
      className
    )}
    {...props}
  />
));
CardHeader.displayName = 'CardHeader';

const CardTitle = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn(
      'text-2xl font-semibold leading-none tracking-tight',
      className
    )}
    {...props}
  />
));
CardTitle.displayName = 'CardTitle';

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn(
      'text-sm text-muted-foreground',
      className
    )}
    {...props}
  />
));
CardDescription.displayName = 'CardDescription';

const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('p-6 pt-0', className)}
    {...props}
  />
));
CardContent.displayName = 'CardContent';

const CardFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('flex items-center p-6 pt-0', className)}
    {...props}
  />
));
CardFooter.displayName = 'CardFooter';

export {
  Card,
  CardHeader,
  CardFooter,
  CardTitle,
  CardDescription,
  CardContent,
};

6. 型安全なコンポーネントの使用例

tsx// app/page.tsx
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
} from '@/components/ui/card';

export default function Home() {
  return (
    <div className='container mx-auto p-4'>
      <h1 className='text-3xl font-bold mb-6'>
        型安全なデザインシステム
      </h1>

      <div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
        <div className='space-y-4'>
          <h2 className='text-xl font-semibold'>
            ボタンコンポーネント
          </h2>
          <div className='flex flex-wrap gap-2'>
            <Button>デフォルト</Button>
            <Button variant='destructive'>削除</Button>
            <Button variant='outline'>アウトライン</Button>
            <Button variant='secondary'>セカンダリ</Button>
            <Button variant='ghost'>ゴースト</Button>
            <Button variant='link'>リンク</Button>
          </div>

          <div className='flex flex-wrap gap-2'>
            <Button size='sm'></Button>
            <Button size='default'></Button>
            <Button size='lg'></Button>
          </div>

          {/* これはコンパイルエラーになる例 */}
          {/* <Button variant="purple">存在しないバリアント</Button> */}
        </div>

        <div className='space-y-4'>
          <h2 className='text-xl font-semibold'>
            入力コンポーネント
          </h2>
          <div className='space-y-2'>
            <Input placeholder='デフォルト入力' />
            <Input
              variant='ghost'
              placeholder='ゴースト入力'
            />
            <Input size='sm' placeholder='小さい入力' />
            <Input size='lg' placeholder='大きい入力' />
          </div>
        </div>
      </div>

      <div className='mt-6'>
        <h2 className='text-xl font-semibold mb-4'>
          カードコンポーネント
        </h2>

        <Card>
          <CardHeader>
            <CardTitle>型安全なカード</CardTitle>
            <CardDescription>
              TypeScript + Tailwind + React の組み合わせ
            </CardDescription>
          </CardHeader>
          <CardContent>
            <p>
              このカードコンポーネントは型安全に設計されています。
            </p>
          </CardContent>
          <CardFooter>
            <Button>アクション</Button>
          </CardFooter>
        </Card>
      </div>
    </div>
  );
}

7. テーマの型定義と利用

tsx// types/theme.ts
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwindConfig from '../tailwind.config';

const fullConfig = resolveConfig(tailwindConfig);

export type ThemeColors = typeof fullConfig.theme.colors;
export type ThemeColor = keyof ThemeColors;
export type ColorShade =
  | 50
  | 100
  | 200
  | 300
  | 400
  | 500
  | 600
  | 700
  | 800
  | 900;

// 型安全にカラーを取得する関数
export function getColorValue(
  color: ThemeColor,
  shade?: ColorShade
): string {
  const colorValue = fullConfig.theme.colors[color];

  if (typeof colorValue === 'string') {
    return colorValue;
  }

  if (shade && typeof colorValue === 'object') {
    return colorValue[shade] || '';
  }

  return '';
}

// 利用例
// const primaryColor = getColorValue('blue', 500); // '#3b82f6'

まとめ

Tailwind CSS、TypeScript、React を組み合わせることで、以下のような大きなメリットが得られます:

  1. 型安全性の向上

    • クラス名のタイプミスや無効なクラスの使用を検出
    • コンポーネントの props に型制約を適用
    • テーマの一貫性を型システムで保証
  2. 開発効率の向上

    • IDE の自動補完とエラーチェック
    • リファクタリングの安全性
    • コンポーネント API の明確化
  3. 保守性の向上

    • 一貫したデザインシステム
    • 変更の影響範囲の可視化
    • チーム間のコミュニケーション改善
  4. スケーラビリティ

    • 大規模プロジェクトへの対応
    • チーム開発における一貫性の確保
    • 将来の変更に対する堅牢性

型安全なデザインシステムの構築は、初期段階では少し手間がかかりますが、プロジェクトが成長するにつれて、その価値は大きく高まります。エラーの早期発見、開発速度の向上、コードの品質向上など、多くのメリットをもたらします。

関連リンク