T-CREATOR

Emotion で始める TypeScript 型安全スタイリング

Emotion で始める TypeScript 型安全スタイリング

フロントエンド開発において「スタイルが原因でバグが発生する」という経験をされたことはありませんか?プロダクションでクラス名の typo を発見したり、動的なスタイル変更でランタイムエラーが発生したりと、従来の CSS 管理には多くの課題がありました。

しかし、Emotion と TypeScript の組み合わせにより、これらの課題は劇的に改善されます。型安全性を保ちながら、美しく保守性の高いスタイリングを実現できるのです。

本記事では、TypeScript 環境での Emotion 導入から実践的な型安全スタイリング手法まで、段階的に解説いたします。実際のプロジェクトで遭遇するエラーとその解決法も交えながら、あなたのスタイリング体験を次のレベルへ押し上げる内容となっています。

背景

CSS-in-JS が求められる現代のフロントエンド開発

現代のフロントエンド開発では、コンポーネントベースのアーキテクチャが主流となっています。しかし、従来の CSS ではコンポーネントとスタイルの結合が弱く、以下のような問題が発生していました。

typescript// 従来の方法:クラス名の管理が困難
const Button = () => {
  return <button className="btn btn-primary">Click me</button>;
};

// CSS ファイル
.btn {
  padding: 10px 20px;
  border: none;
  cursor: pointer;
}

.btn-primary {
  background-color: blue; /* typo でもエラーにならない */
  color: white;
}

このアプローチでは、クラス名の typo やスタイルの依存関係を実行時まで検出できません。また、動的なスタイル変更も困難でした。

CSS-in-JS は、JavaScript の世界でスタイルを管理することで、これらの問題を解決します。コンポーネントとスタイルを密結合させ、より予測可能で保守しやすいコードを実現できるのです。

TypeScript による型安全性の重要性

TypeScript の導入により、JavaScript の世界に型安全性がもたらされました。しかし、スタイリングの領域では、まだ多くの開発者が型の恩恵を受けられていません。

typescript// 型安全でないスタイリング
const styles = {
  color: 'blues', // typo があってもエラーにならない
  fontSize: '16px',
  marginTop: 10, // 単位なしでも警告されない
};

型安全なスタイリングを実現することで、開発時にエラーを早期発見し、チーム全体の開発効率を向上させることができます。

Emotion が選ばれる理由

CSS-in-JS ライブラリの中でも、Emotion が選ばれる理由は以下の通りです:

特徴説明
優れた TypeScript サポート型定義が充実しており、IDE での補完も優秀
高いパフォーマンスランタイムオーバーヘッドが少なく、ビルド時最適化も可能
柔軟な記述方法Object Styles、Template Literals、CSS Prop など多様な書き方
豊富なエコシステムテーマ機能、アニメーション、デベロッパーツールなど充実
移行しやすさ既存の CSS を段階的に移行可能

課題

従来の CSS が抱える型安全性の問題

従来の CSS 管理では、以下のような型安全性の問題が頻繁に発生していました:

css/* CSS ファイル */
.button {
  backgroud-color: red; /* typo でもエラーにならない */
  colr: white; /* プロパティ名の typo */
  margin: 10 20px; /* 値の記述ミス */
}

これらのエラーは、ブラウザでの表示確認まで発見できず、プロダクションでの不具合につながることがありました。

動的スタイルの実装における課題

動的なスタイル変更では、さらに複雑な問題が発生します:

javascript// 動的スタイルの問題例
const getButtonStyle = (variant, size) => {
  let style = 'btn ';

  // variant の typo チェックができない
  if (variant === 'primery') {
    // 'primary' の typo
    style += 'btn-primary ';
  } else if (variant === 'secondary') {
    style += 'btn-secondary ';
  }

  // size の値チェックができない
  if (size === 'large') {
    style += 'btn-lg';
  } else if (size === 'small') {
    style += 'btn-sm';
  }

  return style;
};

このような実装では、引数の typo や予期しない値の混入を防ぐことができません。

チーム開発でのスタイル管理の難しさ

チーム開発では、スタイルの一貫性を保つことが特に困難です:

css/* 開発者Aが作成 */
.card {
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
}

/* 開発者Bが作成 */
.modal {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); /* 微妙に違う値 */
  border-radius: 10px; /* 統一されていない */
}

デザインシステムの統一性を保つためには、より構造化されたアプローチが必要です。

解決策

Emotion と TypeScript の組み合わせによる型安全スタイリング

Emotion と TypeScript を組み合わせることで、スタイリングの型安全性を大幅に向上させることができます。

typescript// 型安全なスタイリング
interface ButtonStyleProps {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'small' | 'medium' | 'large';
  disabled?: boolean;
}

const buttonStyles = ({
  variant,
  size,
  disabled,
}: ButtonStyleProps) => css`
  padding: ${size === 'large'
    ? '12px 24px'
    : size === 'small'
    ? '6px 12px'
    : '8px 16px'};

  background-color: ${variant === 'primary'
    ? '#007bff'
    : variant === 'danger'
    ? '#dc3545'
    : '#6c757d'};

  opacity: ${disabled ? 0.6 : 1};
  cursor: ${disabled ? 'not-allowed' : 'pointer'};
`;

この方法により、コンパイル時にスタイルの整合性をチェックできるようになります。

開発環境の構築手順

まず、TypeScript プロジェクトに Emotion を導入しましょう:

bash# 必要なパッケージをインストール
yarn add @emotion/react @emotion/styled
yarn add -D @emotion/babel-plugin @types/react

Next.js プロジェクトの場合、next.config.js で設定を追加します:

javascript/** @type {import('next').NextConfig} */
const nextConfig = {
  compiler: {
    emotion: true,
  },
};

module.exports = nextConfig;

Create React App の場合、craco.config.js を作成します:

javascriptmodule.exports = {
  babel: {
    plugins: ['@emotion/babel-plugin'],
  },
};

基本的な型定義の設定方法

TypeScript で Emotion を使用する際の基本的な型定義を設定します:

typescript// types/emotion.d.ts
import '@emotion/react';

declare module '@emotion/react' {
  export interface Theme {
    colors: {
      primary: string;
      secondary: string;
      danger: string;
      success: string;
      warning: string;
    };
    spacing: {
      xs: string;
      sm: string;
      md: string;
      lg: string;
      xl: string;
    };
    breakpoints: {
      mobile: string;
      tablet: string;
      desktop: string;
    };
  }
}

この型定義により、テーマオブジェクトの型安全性を確保できます。

具体例

プロジェクトセットアップと初期設定

実際のプロジェクトセットアップから始めましょう。以下のコマンドで新しい Next.js プロジェクトを作成します:

bash# Next.js プロジェクトを TypeScript で作成
npx create-next-app@latest my-emotion-app --typescript --tailwind=false

# プロジェクトディレクトリに移動
cd my-emotion-app

# Emotion をインストール
yarn add @emotion/react @emotion/styled @emotion/css

プロジェクトの基本構成を設定します:

typescript// lib/theme.ts
import { Theme } from '@emotion/react';

export const theme: Theme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    danger: '#dc3545',
    success: '#28a745',
    warning: '#ffc107',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
  },
  breakpoints: {
    mobile: '576px',
    tablet: '768px',
    desktop: '992px',
  },
};

基本的なスタイリング実装

基本的なコンポーネントのスタイリングを実装してみましょう:

typescript// components/Button.tsx
import { css } from '@emotion/react';
import styled from '@emotion/styled';

// Object Styles を使用した基本的なスタイリング
const Button = styled.button<{
  variant: 'primary' | 'secondary';
}>`
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease-in-out;

  background-color: ${({ variant, theme }) =>
    variant === 'primary'
      ? theme.colors.primary
      : theme.colors.secondary};

  color: white;

  &:hover {
    opacity: 0.9;
    transform: translateY(-1px);
  }

  &:active {
    transform: translateY(0);
  }
`;

export default Button;

CSS Prop を使用した動的スタイリングも実装できます:

typescript// components/Card.tsx
import { css, useTheme } from '@emotion/react';

interface CardProps {
  children: React.ReactNode;
  elevated?: boolean;
  padding?: 'sm' | 'md' | 'lg';
}

const Card: React.FC<CardProps> = ({
  children,
  elevated,
  padding = 'md',
}) => {
  const theme = useTheme();

  return (
    <div
      css={css`
        background-color: white;
        border-radius: 8px;
        padding: ${theme.spacing[padding]};
        box-shadow: ${elevated
          ? '0 4px 8px rgba(0, 0, 0, 0.1)'
          : '0 2px 4px rgba(0, 0, 0, 0.05)'};

        transition: box-shadow 0.2s ease-in-out;

        &:hover {
          box-shadow: ${elevated
            ? '0 8px 16px rgba(0, 0, 0, 0.15)'
            : '0 4px 8px rgba(0, 0, 0, 0.1)'};
        }
      `}
    >
      {children}
    </div>
  );
};

export default Card;

型安全なテーマシステムの構築

より堅牢なテーマシステムを構築しましょう:

typescript// lib/theme.ts の拡張
export interface ThemeColors {
  primary: string;
  secondary: string;
  success: string;
  warning: string;
  danger: string;
  light: string;
  dark: string;
}

export interface ThemeSpacing {
  xs: string;
  sm: string;
  md: string;
  lg: string;
  xl: string;
}

export interface ThemeBreakpoints {
  mobile: string;
  tablet: string;
  desktop: string;
}

export interface AppTheme {
  colors: ThemeColors;
  spacing: ThemeSpacing;
  breakpoints: ThemeBreakpoints;
  typography: {
    fontFamily: string;
    fontSize: {
      xs: string;
      sm: string;
      md: string;
      lg: string;
      xl: string;
    };
  };
}

型安全なテーマプロバイダーを作成します:

typescript// providers/ThemeProvider.tsx
import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
import { AppTheme } from '../lib/theme';

const theme: AppTheme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    warning: '#ffc107',
    danger: '#dc3545',
    light: '#f8f9fa',
    dark: '#343a40',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
  },
  breakpoints: {
    mobile: '576px',
    tablet: '768px',
    desktop: '992px',
  },
  typography: {
    fontFamily:
      '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
    fontSize: {
      xs: '12px',
      sm: '14px',
      md: '16px',
      lg: '18px',
      xl: '20px',
    },
  },
};

interface ThemeProviderProps {
  children: React.ReactNode;
}

export const ThemeProvider: React.FC<
  ThemeProviderProps
> = ({ children }) => {
  return (
    <EmotionThemeProvider theme={theme}>
      {children}
    </EmotionThemeProvider>
  );
};

プロパティベースのスタイル変更

プロパティに基づいた動的スタイル変更を実装します:

typescript// components/Alert.tsx
import styled from '@emotion/styled';

interface AlertProps {
  variant: keyof AppTheme['colors'];
  size?: 'sm' | 'md' | 'lg';
  dismissible?: boolean;
}

const Alert = styled.div<AlertProps>`
  padding: ${({ size, theme }) => {
    switch (size) {
      case 'sm':
        return `${theme.spacing.sm} ${theme.spacing.md}`;
      case 'lg':
        return `${theme.spacing.lg} ${theme.spacing.xl}`;
      default:
        return `${theme.spacing.md} ${theme.spacing.lg}`;
    }
  }};

  border-radius: 4px;
  border: 1px solid;
  margin-bottom: ${({ theme }) => theme.spacing.md};

  background-color: ${({ variant, theme }) => {
    const color = theme.colors[variant];
    return `${color}20`; // 20% 透明度
  }};

  border-color: ${({ variant, theme }) =>
    theme.colors[variant]};
  color: ${({ variant, theme }) => theme.colors[variant]};

  position: relative;

  ${({ dismissible }) =>
    dismissible &&
    `
    padding-right: 40px;
    
    &::after {
      content: '×';
      position: absolute;
      right: 12px;
      top: 50%;
      transform: translateY(-50%);
      cursor: pointer;
      font-size: 20px;
      line-height: 1;
    }
  `}
`;

export default Alert;

条件付きスタイリングの実装

より複雑な条件付きスタイリングを実装しましょう:

typescript// components/Input.tsx
import { css } from '@emotion/react';
import { AppTheme } from '../lib/theme';

interface InputProps {
  label?: string;
  error?: string;
  success?: boolean;
  disabled?: boolean;
  fullWidth?: boolean;
  size?: 'sm' | 'md' | 'lg';
}

const getInputStyles = (
  props: InputProps & { theme: AppTheme }
) => css`
  display: ${props.fullWidth ? 'block' : 'inline-block'};
  width: ${props.fullWidth ? '100%' : 'auto'};

  input {
    padding: ${props.size === 'lg'
      ? '12px 16px'
      : props.size === 'sm'
      ? '6px 12px'
      : '8px 12px'};

    border: 2px solid ${props.error
        ? props.theme.colors.danger
        : props.success
        ? props.theme.colors.success
        : '#e9ecef'};

    border-radius: 4px;
    font-size: ${props.theme.typography.fontSize[
      props.size || 'md'
    ]};

    background-color: ${props.disabled
      ? '#f8f9fa'
      : 'white'};
    cursor: ${props.disabled ? 'not-allowed' : 'text'};

    &:focus {
      outline: none;
      border-color: ${props.error
        ? props.theme.colors.danger
        : props.theme.colors.primary};

      box-shadow: 0 0 0 3px ${props.error
          ? `${props.theme.colors.danger}20`
          : `${props.theme.colors.primary}20`};
    }
  }

  label {
    display: block;
    margin-bottom: ${props.theme.spacing.xs};
    font-weight: 500;
    color: ${props.theme.colors.dark};
  }

  .error-message {
    color: ${props.theme.colors.danger};
    font-size: ${props.theme.typography.fontSize.sm};
    margin-top: ${props.theme.spacing.xs};
  }
`;

const Input: React.FC<InputProps> = (props) => {
  const theme = useTheme();

  return (
    <div css={getInputStyles({ ...props, theme })}>
      {props.label && <label>{props.label}</label>}
      <input disabled={props.disabled} />
      {props.error && (
        <div className='error-message'>{props.error}</div>
      )}
    </div>
  );
};

export default Input;

実際の開発でよく遭遇するエラーとその対処法もご紹介します:

typescript// よく発生するエラー例
// エラー: Property 'colors' does not exist on type 'Theme'
const BadComponent = styled.div`
  color: ${({ theme }) =>
    theme.colors.primary}; // エラー発生
`;

// 解決策: 型定義を適切に設定
declare module '@emotion/react' {
  export interface Theme {
    colors: {
      primary: string;
      // ... その他の色定義
    };
  }
}

レスポンシブデザインの実装も型安全に行えます:

typescript// utils/responsive.ts
export const mq = (
  breakpoint: keyof AppTheme['breakpoints']
) => `@media (min-width: ${breakpoint})`;

// 使用例
const ResponsiveComponent = styled.div`
  padding: ${({ theme }) => theme.spacing.sm};

  ${({ theme }) => mq(theme.breakpoints.tablet)} {
    padding: ${({ theme }) => theme.spacing.md};
  }

  ${({ theme }) => mq(theme.breakpoints.desktop)} {
    padding: ${({ theme }) => theme.spacing.lg};
  }
`;

まとめ

Emotion + TypeScript の効果と今後の展望

Emotion と TypeScript を組み合わせることで、スタイリングの世界に革命的な変化をもたらすことができました。型安全性の確保により、開発時のエラーを大幅に削減し、チーム開発での一貫性を保つことが可能になります。

主な効果:

効果説明
開発効率向上IDE での補完とエラー検出により、開発速度が向上
バグ削減コンパイル時にスタイルエラーを検出し、ランタイムエラーを防止
保守性向上型定義により、コードの意図が明確になり保守がしやすくなる
チーム開発の改善統一されたスタイルシステムによりチーム全体の品質が向上

次のステップへの道筋

Emotion + TypeScript の基礎を習得した後は、以下のステップでさらなる発展を目指しましょう:

  1. アニメーションシステムの構築

    • Framer Motion や React Spring との組み合わせ
    • 型安全なアニメーション API の設計
  2. デザインシステムの拡張

    • Storybook との連携
    • デザイントークンの管理
  3. パフォーマンス最適化

    • CSS-in-JS の最適化手法
    • バンドルサイズの削減
  4. テストの充実

    • スタイルのユニットテスト
    • ビジュアルリグレッションテスト

Emotion と TypeScript の組み合わせは、単なるスタイリング手法を超えて、フロントエンド開発における新しい可能性を切り拓いています。型安全性という強力な武器を手に入れることで、より自信を持って美しいユーザーインターフェースを構築できるようになるでしょう。

あなたの次のプロジェクトでも、ぜひこの技術の恩恵を感じてみてください。コードの品質向上と開発体験の向上を同時に実現できる、まさに理想的なスタイリング手法といえるでしょう。

関連リンク