最終更新:

TypeScriptパターンマッチング実践: ts-patternで型安全な条件分岐


TypeScriptパターンマッチング実践: ts-patternで型安全な条件分岐

関数型プログラミング言語では標準機能であるパターンマッチングを、TypeScriptでもts-patternライブラリを使って実現できます。複雑な条件分岐を型安全に、かつ読みやすく記述する方法を完全解説します。

ts-patternとは

ts-patternは、TypeScriptに包括的なパターンマッチング機能を提供するライブラリです。switchステートメントやif-elseの連鎖よりも、はるかに表現力豊かで型安全なコードを書けます。

インストール

# npm
npm install ts-pattern

# pnpm
pnpm add ts-pattern

# bun
bun add ts-pattern

基本的な使い方

値によるパターンマッチング

import { match } from 'ts-pattern';

type Status = 'idle' | 'loading' | 'success' | 'error';

function getStatusMessage(status: Status) {
  return match(status)
    .with('idle', () => 'Waiting to start...')
    .with('loading', () => 'Loading data...')
    .with('success', () => 'Data loaded successfully!')
    .with('error', () => 'Failed to load data')
    .exhaustive(); // すべてのケースをカバーしていることを保証
}

// 使用例
console.log(getStatusMessage('loading')); // "Loading data..."

オブジェクトパターン

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function calculateArea(shape: Shape): number {
  return match(shape)
    .with({ kind: 'circle' }, ({ radius }) => Math.PI * radius ** 2)
    .with({ kind: 'rectangle' }, ({ width, height }) => width * height)
    .with({ kind: 'triangle' }, ({ base, height }) => (base * height) / 2)
    .exhaustive();
}

const circle: Shape = { kind: 'circle', radius: 5 };
console.log(calculateArea(circle)); // 78.54...

const rectangle: Shape = { kind: 'rectangle', width: 10, height: 20 };
console.log(calculateArea(rectangle)); // 200

高度なパターンマッチング

ガード条件とP.when

import { match, P } from 'ts-pattern';

type User = {
  name: string;
  age: number;
  role: 'admin' | 'user' | 'guest';
};

function getUserPermissions(user: User): string[] {
  return match(user)
    .with(
      { role: 'admin' },
      () => ['read', 'write', 'delete', 'admin']
    )
    .with(
      { role: 'user', age: P.when(age => age >= 18) },
      () => ['read', 'write']
    )
    .with(
      { role: 'user' },
      () => ['read']
    )
    .with(
      { role: 'guest' },
      () => ['read']
    )
    .exhaustive();
}

const user: User = { name: 'Alice', age: 25, role: 'user' };
console.log(getUserPermissions(user)); // ['read', 'write']

const minor: User = { name: 'Bob', age: 16, role: 'user' };
console.log(getUserPermissions(minor)); // ['read']

配列とタプルのパターンマッチング

import { match, P } from 'ts-pattern';

type Command = [string, ...string[]];

function executeCommand(cmd: Command): string {
  return match(cmd)
    .with(['help'], () => 'Showing help...')
    .with(['ls', ...P.array()], ([, ...args]) =>
      `Listing files with args: ${args.join(', ')}`
    )
    .with(['cd', P.string], ([, dir]) =>
      `Changing directory to: ${dir}`
    )
    .with(['git', 'commit', '-m', P.string], ([, , , , msg]) =>
      `Committing with message: ${msg}`
    )
    .with(['git', 'push', ...P.array()], ([, , ...args]) =>
      `Pushing to remote with args: ${args.join(', ')}`
    )
    .otherwise(() => 'Unknown command');
}

console.log(executeCommand(['ls', '-la']));
// "Listing files with args: -la"

console.log(executeCommand(['git', 'commit', '-m', 'Initial commit']));
// "Committing with message: Initial commit"

深いネスト構造のマッチング

import { match, P } from 'ts-pattern';

type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'error'; error: { code: number; message: string } }
  | { status: 'success'; data: T };

interface User {
  id: number;
  name: string;
  email?: string;
}

function handleUserResponse(response: ApiResponse<User>): string {
  return match(response)
    .with(
      { status: 'loading' },
      () => 'Loading user data...'
    )
    .with(
      { status: 'error', error: { code: 404 } },
      () => 'User not found'
    )
    .with(
      { status: 'error', error: { code: 500 } },
      () => 'Server error occurred'
    )
    .with(
      { status: 'error' },
      ({ error }) => `Error ${error.code}: ${error.message}`
    )
    .with(
      { status: 'success', data: { email: P.string } },
      ({ data }) => `User: ${data.name} (${data.email})`
    )
    .with(
      { status: 'success' },
      ({ data }) => `User: ${data.name} (no email)`
    )
    .exhaustive();
}

const response: ApiResponse<User> = {
  status: 'success',
  data: { id: 1, name: 'Alice', email: 'alice@example.com' }
};

console.log(handleUserResponse(response));
// "User: Alice (alice@example.com)"

実践的なユースケース

1. Redux-likeな状態管理

import { match, P } from 'ts-pattern';

type State = {
  count: number;
  user: { name: string; loggedIn: boolean } | null;
  todos: Array<{ id: number; text: string; completed: boolean }>;
};

type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_COUNT'; payload: number }
  | { type: 'LOGIN'; payload: { name: string } }
  | { type: 'LOGOUT' }
  | { type: 'ADD_TODO'; payload: { text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: number } }
  | { type: 'DELETE_TODO'; payload: { id: number } };

function reducer(state: State, action: Action): State {
  return match(action)
    .with(
      { type: 'INCREMENT' },
      () => ({ ...state, count: state.count + 1 })
    )
    .with(
      { type: 'DECREMENT' },
      () => ({ ...state, count: state.count - 1 })
    )
    .with(
      { type: 'SET_COUNT' },
      ({ payload }) => ({ ...state, count: payload })
    )
    .with(
      { type: 'LOGIN' },
      ({ payload }) => ({
        ...state,
        user: { name: payload.name, loggedIn: true }
      })
    )
    .with(
      { type: 'LOGOUT' },
      () => ({ ...state, user: null })
    )
    .with(
      { type: 'ADD_TODO' },
      ({ payload }) => ({
        ...state,
        todos: [
          ...state.todos,
          { id: Date.now(), text: payload.text, completed: false }
        ]
      })
    )
    .with(
      { type: 'TOGGLE_TODO' },
      ({ payload }) => ({
        ...state,
        todos: state.todos.map(todo =>
          todo.id === payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      })
    )
    .with(
      { type: 'DELETE_TODO' },
      ({ payload }) => ({
        ...state,
        todos: state.todos.filter(todo => todo.id !== payload.id)
      })
    )
    .exhaustive();
}

2. バリデーションとエラーハンドリング

import { match, P } from 'ts-pattern';

type ValidationError =
  | { type: 'required'; field: string }
  | { type: 'minLength'; field: string; minLength: number }
  | { type: 'maxLength'; field: string; maxLength: number }
  | { type: 'pattern'; field: string; pattern: string }
  | { type: 'custom'; field: string; message: string };

function formatValidationError(error: ValidationError): string {
  return match(error)
    .with(
      { type: 'required' },
      ({ field }) => `${field} is required`
    )
    .with(
      { type: 'minLength' },
      ({ field, minLength }) =>
        `${field} must be at least ${minLength} characters`
    )
    .with(
      { type: 'maxLength' },
      ({ field, maxLength }) =>
        `${field} must be at most ${maxLength} characters`
    )
    .with(
      { type: 'pattern' },
      ({ field, pattern }) =>
        `${field} must match pattern: ${pattern}`
    )
    .with(
      { type: 'custom' },
      ({ message }) => message
    )
    .exhaustive();
}

// バリデーション関数
type ValidationResult<T> =
  | { success: true; data: T }
  | { success: false; errors: ValidationError[] };

function validateUser(input: unknown): ValidationResult<{
  name: string;
  email: string;
  age: number;
}> {
  const errors: ValidationError[] = [];

  const data = input as any;

  if (!data.name) {
    errors.push({ type: 'required', field: 'name' });
  } else if (data.name.length < 2) {
    errors.push({ type: 'minLength', field: 'name', minLength: 2 });
  }

  if (!data.email) {
    errors.push({ type: 'required', field: 'email' });
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
    errors.push({
      type: 'pattern',
      field: 'email',
      pattern: 'email format'
    });
  }

  if (!data.age) {
    errors.push({ type: 'required', field: 'age' });
  } else if (data.age < 18) {
    errors.push({
      type: 'custom',
      field: 'age',
      message: 'You must be at least 18 years old'
    });
  }

  return errors.length > 0
    ? { success: false, errors }
    : { success: true, data: { name: data.name, email: data.email, age: data.age } };
}

// 使用例
const result = validateUser({ name: 'A', email: 'invalid', age: 16 });

match(result)
  .with(
    { success: true },
    ({ data }) => console.log('Valid user:', data)
  )
  .with(
    { success: false },
    ({ errors }) => {
      console.log('Validation failed:');
      errors.forEach(error => {
        console.log('- ' + formatValidationError(error));
      });
    }
  )
  .exhaustive();

3. ルーティングとナビゲーション

import { match, P } from 'ts-pattern';

type Route =
  | { path: '/'; params: {} }
  | { path: '/about'; params: {} }
  | { path: '/blog'; params: { page?: number } }
  | { path: '/blog/:slug'; params: { slug: string } }
  | { path: '/users/:id'; params: { id: string; tab?: string } }
  | { path: '/settings/:section'; params: { section: string } };

function renderRoute(route: Route): string {
  return match(route)
    .with(
      { path: '/' },
      () => '<HomePage />'
    )
    .with(
      { path: '/about' },
      () => '<AboutPage />'
    )
    .with(
      { path: '/blog', params: { page: P.number } },
      ({ params }) => `<BlogPage page={${params.page}} />`
    )
    .with(
      { path: '/blog', params: {} },
      () => '<BlogPage page={1} />'
    )
    .with(
      { path: '/blog/:slug' },
      ({ params }) => `<BlogPost slug="${params.slug}" />`
    )
    .with(
      { path: '/users/:id', params: { tab: P.string } },
      ({ params }) => `<UserProfile id="${params.id}" tab="${params.tab}" />`
    )
    .with(
      { path: '/users/:id' },
      ({ params }) => `<UserProfile id="${params.id}" tab="posts" />`
    )
    .with(
      { path: '/settings/:section' },
      ({ params }) => `<SettingsPage section="${params.section}" />`
    )
    .exhaustive();
}

const route: Route = {
  path: '/users/:id',
  params: { id: '123', tab: 'followers' }
};
console.log(renderRoute(route));
// <UserProfile id="123" tab="followers" />

パフォーマンスとベストプラクティス

1. P.selectでデータを効率的に抽出

import { match, P } from 'ts-pattern';

type Event =
  | { type: 'click'; x: number; y: number; button: number }
  | { type: 'keydown'; key: string; ctrlKey: boolean }
  | { type: 'scroll'; scrollX: number; scrollY: number };

function handleEvent(event: Event) {
  return match(event)
    .with(
      { type: 'click', button: P.select() },
      (button) => `Clicked with button ${button}`
    )
    .with(
      { type: 'keydown', key: P.select(), ctrlKey: true },
      (key) => `Ctrl+${key} pressed`
    )
    .with(
      { type: 'scroll', scrollY: P.select() },
      (scrollY) => `Scrolled to Y: ${scrollY}`
    )
    .otherwise(() => 'Unknown event');
}

2. exhaustive()で網羅性を保証

// exhaustive()を使うと、すべてのケースをカバーしていない場合に
// コンパイルエラーになります

type Status = 'pending' | 'approved' | 'rejected';

function getStatusColor(status: Status): string {
  return match(status)
    .with('pending', () => 'yellow')
    .with('approved', () => 'green')
    // 'rejected'のケースを忘れるとコンパイルエラー
    .exhaustive();
}

まとめ

ts-patternを使うことで、TypeScriptでも関数型言語並みの表現力豊かなパターンマッチングが実現できます。主な利点は以下の通りです。

  • 型安全性: すべてのケースをカバーしていることをコンパイル時にチェック
  • 可読性: 複雑な条件分岐を簡潔に表現
  • 保守性: パターンの追加・変更が容易
  • デバッグ性: exhaustive()により、漏れのないケース分岐を保証

複雑な条件分岐やデータ変換が必要な場面で、ts-patternは強力なツールとなります。