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 の基礎を習得した後は、以下のステップでさらなる発展を目指しましょう:
-
アニメーションシステムの構築
- Framer Motion や React Spring との組み合わせ
- 型安全なアニメーション API の設計
-
デザインシステムの拡張
- Storybook との連携
- デザイントークンの管理
-
パフォーマンス最適化
- CSS-in-JS の最適化手法
- バンドルサイズの削減
-
テストの充実
- スタイルのユニットテスト
- ビジュアルリグレッションテスト
Emotion と TypeScript の組み合わせは、単なるスタイリング手法を超えて、フロントエンド開発における新しい可能性を切り拓いています。型安全性という強力な武器を手に入れることで、より自信を持って美しいユーザーインターフェースを構築できるようになるでしょう。
あなたの次のプロジェクトでも、ぜひこの技術の恩恵を感じてみてください。コードの品質向上と開発体験の向上を同時に実現できる、まさに理想的なスタイリング手法といえるでしょう。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来