T-CREATOR

jotai × TypeScript 型推論を極める実戦のための環境設定術

jotai × TypeScript 型推論を極める実戦のための環境設定術

React の状態管理ライブラリとして注目を集める Jotai ですが、TypeScript との組み合わせで真価を発揮します。型推論がしっかり効くことで、開発体験が劇的に向上し、バグを未然に防げるのです。

しかし、実際のプロジェクトでは「型が any になってしまう」「推論が効かない」といった問題に直面することも少なくありません。本記事では、Jotai と TypeScript の型推論を最大限に活用するための環境設定術を、実戦的な視点から徹底解説していきます。

背景

Jotai が選ばれる理由

Jotai は Recoil にインスパイアされた軽量な状態管理ライブラリで、atomic なアプローチを採用しています。Redux のような大規模なボイラープレートを必要とせず、シンプルな API で状態管理を実現できるのが特徴です。

特に TypeScript との相性が良く、型推論が自動的に効くように設計されているため、型定義を書く手間が大幅に削減されます。

TypeScript 型推論の重要性

TypeScript の型推論は、開発者が明示的に型を書かなくても、コンパイラが自動的に型を推測してくれる機能です。これにより以下のメリットが得られます。

  • コードの記述量が減り、可読性が向上する
  • IDE の補完機能が強力に働き、開発速度が上がる
  • 型の不整合によるバグを事前に検知できる

以下の図は、Jotai と TypeScript の型推論がどのように連携するかを示しています。

mermaidflowchart TB
  atom["atom 定義"] -->|型情報| infer["TypeScript 型推論"]
  infer -->|自動推論| hook["useAtom フック"]
  hook -->|型安全な値| comp["React コンポーネント"]
  comp -->|型チェック| ide["IDE 補完・エラー検出"]

  tsconfig["tsconfig.json"] -.->|コンパイラ設定| infer
  deps["依存関係バージョン"] -.->|型定義整合性| infer

上図のように、atom の定義から始まる型情報の流れが、適切な環境設定により IDE のサポートまで一貫して機能します。

Jotai の型システムの仕組み

Jotai は内部的にジェネリクスを活用して、atom の値の型を保持しています。基本的な atom の型定義は次のようになっています。

typescript// Jotai 内部の型定義(簡略版)
type Atom<Value> = {
  read: (get: Getter) => Value;
  write?: (get: Getter, set: Setter, update: any) => void;
};

このジェネリクスにより、atom を定義した時点で値の型が確定し、その型情報が useAtomuseAtomValue などのフックに自動的に伝播します。

課題

型推論が効かないケース

実際の開発現場では、以下のような問題が頻発します。

問題 1: 初期値が nullundefined の場合

typescriptimport { atom } from 'jotai';

// ❌ 型が null のまま推論されてしまう
const userAtom = atom(null);

// ❌ 後から値を設定しても型エラー
const handleLogin = () => {
  // Type 'User' is not assignable to type 'null'
  set(userAtom, { id: 1, name: 'John' });
};

この場合、TypeScript は初期値から型を推論するため、userAtom の型が Atom<null> と判断されてしまいます。

問題 2: 複雑な派生 atom での型喪失

typescript// ❌ 型推論が効かず any になってしまう
const derivedAtom = atom((get) => {
  const data = get(someAtom);
  // 複雑な変換処理
  return someComplexTransform(data);
});

複雑な変換処理を含む派生 atom では、戻り値の型が自動推論されず、any 型になってしまうことがあります。

問題 3: 非同期 atom の型エラー

typescript// ❌ Promise の型が正しく推論されない
const fetchUserAtom = atom(async (get) => {
  const response = await fetch('/api/user');
  return response.json(); // any 型になる
});

非同期処理を含む atom では、Promise の解決値の型が失われがちです。

環境設定の不備による問題

TypeScript の設定が適切でない場合、Jotai の型推論が正しく機能しません。

以下の図は、環境設定の不備がどのように型推論の問題を引き起こすかを示しています。

mermaidflowchart TD
  config_issue["設定の不備"] --> strict["strict モード無効"]
  config_issue --> target["target が古い"]
  config_issue --> libs["lib 設定不足"]

  strict --> any_type["暗黙の any 許可"]
  target --> compat["型定義の不整合"]
  libs --> missing["型サポート欠如"]

  any_type --> error["型エラー未検出"]
  compat --> error
  missing --> error

  error --> bug["実行時エラー・バグ"]

図から分かるように、設定の不備は複数の経路を通じて最終的なバグにつながります。

主な問題点は以下の通りです。

  • strict モードが無効で、暗黙の any 型が許容される
  • target が古く、最新の型定義との互換性がない
  • moduleResolution が適切でなく、型定義ファイルが正しく解決されない

解決策

TypeScript コンパイラオプションの最適化

Jotai で型推論を最大限に活用するには、tsconfig.json の設定が極めて重要です。以下の設定を推奨します。

基本設定: compilerOptions

json{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

target は ES2020 以上に設定することで、最新の JavaScript 機能と型定義のサポートを得られます。

moduleResolutionbundler または node16 を指定すると、現代的なモジュール解決が可能になります。

Strict モードの有効化

型安全性を確保するため、以下の strict 系オプションをすべて有効化します。

json{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true
  }
}

strict: true だけでも大半が有効になりますが、明示的に記載することで設定の意図が明確になります。

型チェックの強化オプション

さらに厳密な型チェックを行うため、以下のオプションも追加しましょう。

json{
  "compilerOptions": {
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

特に noUncheckedIndexedAccess は、配列やオブジェクトのインデックスアクセス時に undefined の可能性を考慮させるため、Jotai での状態管理において有用です。

完全な tsconfig.json の例

上記をまとめた推奨設定は以下の通りです。

json{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "allowJs": false,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

この設定により、Jotai の型推論が正確に機能する土台が整います。

パッケージバージョンの整合性確保

Jotai と TypeScript、React のバージョンには相性があります。型定義の互換性を保つため、以下のバージョン組み合わせを推奨します。

#パッケージ推奨バージョン理由
1jotai^2.6.0 以上最新の型定義サポート
2typescript^5.3.0 以上最新の型推論機能
3react^18.2.0 以上React 18 の型定義
4@types/react^18.2.0 以上React の型定義

package.json の設定例

json{
  "dependencies": {
    "jotai": "^2.6.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0"
  }
}

これらのバージョンを使用することで、型定義の不整合を回避できます。

インストールコマンド

Yarn を使用する場合、以下のコマンドで依存関係をインストールします。

bashyarn add jotai react react-dom

開発用の依存関係も追加します。

bashyarn add -D typescript @types/react @types/react-dom

IDE 設定の最適化

VSCode を使用している場合、以下の設定で TypeScript の型チェック体験が向上します。

.vscode/settings.json

json{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "typescript.preferences.importModuleSpecifier": "relative",
  "typescript.preferences.quoteStyle": "single",
  "typescript.updateImportsOnFileMove.enabled": "always",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.organizeImports": true
  }
}

typescript.tsdk により、プロジェクトローカルの TypeScript を使用し、バージョン整合性が保たれます。

推奨拡張機能

以下の VSCode 拡張機能をインストールすると、開発効率がさらに向上します。

#拡張機能用途
1ESLintコード品質チェック
2Prettierコードフォーマット
3TypeScript Importer自動インポート補完

具体例

型安全な atom 定義パターン

ここからは、実際のコードで型推論を最大限に活用する方法を見ていきましょう。

パターン 1: プリミティブ型の atom

最もシンプルなケースです。初期値から型が自動推論されます。

typescriptimport { atom } from 'jotai';

// number 型として推論される
const countAtom = atom(0);

// string 型として推論される
const nameAtom = atom('John');

// boolean 型として推論される
const isLoadingAtom = atom(false);

このように、プリミティブ型の場合は明示的な型定義は不要です。

パターン 2: オブジェクト型の atom

オブジェクトの場合も、初期値から型が推論されます。

typescript// User 型が自動推論される
const userAtom = atom({
  id: 1,
  name: 'John',
  email: 'john@example.com',
});

// 型: Atom<{ id: number; name: string; email: string }>

ただし、より厳密な型定義が必要な場合は、インターフェースを定義します。

パターン 3: 型定義を明示する atom

null や undefined を初期値とする場合、ジェネリクスで型を明示します。

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

// ✅ User | null 型として定義
const userAtom = atom<User | null>(null);

これにより、後から User オブジェクトを設定できるようになります。

typescript// コンポーネント内での使用
const [user, setUser] = useAtom(userAtom);

const handleLogin = () => {
  // ✅ 型安全に設定可能
  setUser({
    id: 1,
    name: 'John',
    email: 'john@example.com',
  });
};

パターン 4: 配列型の atom

配列の場合も同様に、初期値から型が推論されます。

typescriptinterface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// ✅ Todo[] 型として推論される
const todosAtom = atom<Todo[]>([]);

空配列の場合は型が推論できないため、ジェネリクスで明示する必要があります。

派生 atom の型推論テクニック

派生 atom では、get で取得した値の型が自動的に推論されます。

シンプルな派生 atom

typescript// countAtom の値(number)から自動推論
const doubledCountAtom = atom((get) => {
  const count = get(countAtom);
  return count * 2; // number 型として推論
});

TypeScript は get(countAtom) が number を返すことを理解し、戻り値も number と推論します。

複数の atom を組み合わせる

typescriptconst fullNameAtom = atom((get) => {
  const firstName = get(firstNameAtom); // string
  const lastName = get(lastNameAtom); // string
  return `${firstName} ${lastName}`; // string として推論
});

複数の atom を参照する場合も、それぞれの型が正しく推論されます。

フィルタリングやマッピング

配列を扱う派生 atom でも、型推論が効きます。

typescript// 完了していない Todo のみを抽出
const incompleteTodosAtom = atom((get) => {
  const todos = get(todosAtom); // Todo[]
  return todos.filter((todo) => !todo.completed); // Todo[] として推論
});

// Todo の件数
const todoCountAtom = atom((get) => {
  const todos = get(todosAtom); // Todo[]
  return todos.length; // number として推論
});

filter や map などの配列メソッドを使用しても、型情報は保持されます。

明示的な戻り値の型指定

複雑な変換処理では、戻り値の型を明示することで可読性が向上します。

typescriptinterface Summary {
  total: number;
  completed: number;
  incomplete: number;
}

// ✅ 戻り値の型を明示
const todoSummaryAtom = atom<Summary>((get) => {
  const todos = get(todosAtom);
  return {
    total: todos.length,
    completed: todos.filter((t) => t.completed).length,
    incomplete: todos.filter((t) => !t.completed).length,
  };
});

型を明示することで、戻り値の構造が保証されます。

書き込み可能な atom の型安全性

読み取り専用でない atom では、write 関数の型も重要です。

基本的な書き込み可能 atom

typescriptconst userAtom = atom<User | null>(null);

// write 関数付きの atom
const loginAtom = atom(
  null, // 読み取り値は使用しない
  (get, set, newUser: User) => {
    set(userAtom, newUser);
  }
);

第 3 引数 newUser の型を明示することで、型安全な更新が可能になります。

複雑な更新ロジック

typescriptconst addTodoAtom = atom(
  null,
  (get, set, title: string) => {
    const todos = get(todosAtom);
    const newTodo: Todo = {
      id: Date.now(),
      title,
      completed: false,
    };
    set(todosAtom, [...todos, newTodo]);
  }
);

このパターンでは、title: string と型を指定することで、呼び出し側でも型チェックが機能します。

typescript// コンポーネント内
const [, addTodo] = useAtom(addTodoAtom);

const handleAdd = () => {
  addTodo('New Task'); // ✅ string を渡す必要がある
  // addTodo(123) // ❌ 型エラー
};

非同期 atom の型推論

非同期処理を含む atom では、Promise の型を正しく推論させる必要があります。

基本的な非同期 atom

typescriptinterface ApiUser {
  id: number;
  name: string;
  email: string;
}

// ✅ Promise<ApiUser> として推論される
const fetchUserAtom = atom(
  async (get): Promise<ApiUser> => {
    const response = await fetch('/api/user');
    const data: ApiUser = await response.json();
    return data;
  }
);

戻り値の型を Promise<ApiUser> と明示することで、型安全性が確保されます。

Suspense と組み合わせる

Jotai は React Suspense と統合されており、非同期 atom を自然に扱えます。

typescriptimport { Suspense } from 'react';
import { useAtomValue } from 'jotai';

function UserProfile() {
  // user の型は ApiUser として推論される
  const user = useAtomValue(fetchUserAtom);

  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Suspense により、Promise の解決を待つ間はフォールバック UI が表示されます。

エラーハンドリング付き非同期 atom

実践的には、エラーハンドリングも含めた実装が必要です。

typescriptconst fetchUserWithErrorAtom = atom(
  async (get): Promise<ApiUser> => {
    try {
      const response = await fetch('/api/user');

      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }

      const data: ApiUser = await response.json();
      return data;
    } catch (error) {
      console.error('Failed to fetch user:', error);
      throw error;
    }
  }
);

エラーが発生した場合、React の Error Boundary でキャッチできます。

以下の図は、非同期 atom のライフサイクルと型の流れを示しています。

mermaidsequenceDiagram
  participant Comp as コンポーネント
  participant Hook as useAtomValue
  participant Atom as 非同期 atom
  participant API as API サーバー

  Comp->>Hook: useAtomValue(fetchUserAtom)
  Hook->>Atom: 値を要求
  Atom->>API: fetch('/api/user')

  alt 成功時
    API-->>Atom: JSON レスポンス
    Atom-->>Hook: Promise<ApiUser> 解決
    Hook-->>Comp: ApiUser 型の値
  else エラー時
    API-->>Atom: エラーレスポンス
    Atom-->>Hook: Promise reject
    Hook-->>Comp: Error Boundary へ
  end

図で理解できる要点:

  • 非同期 atom は Promise を返し、Suspense が解決を待つ
  • 成功時は型安全な値が取得でき、エラー時は Error Boundary でキャッチされる
  • 型情報は API からコンポーネントまで一貫して保持される

atomWithStorage の型推論

Jotai の utilities には、localStorage と連携する atomWithStorage があります。

基本的な使い方

typescriptimport { atomWithStorage } from 'jotai/utils';

// ✅ string 型として推論される
const themeAtom = atomWithStorage('theme', 'light');

// ✅ User | null 型として明示
const storedUserAtom = atomWithStorage<User | null>(
  'user',
  null
);

初期値から型が推論されますが、複雑な型の場合はジェネリクスで明示します。

カスタムストレージの型定義

独自のストレージ実装を使用する場合、型定義が必要です。

typescriptimport { createJSONStorage } from 'jotai/utils';

interface CustomStorage<T> {
  getItem: (key: string) => T | null;
  setItem: (key: string, value: T) => void;
  removeItem: (key: string) => void;
}

const customStorage = createJSONStorage<User>(
  () => sessionStorage
);

const userAtom = atomWithStorage<User | null>(
  'user',
  null,
  customStorage
);

ジェネリクスを使用することで、ストレージの型安全性も確保されます。

Family パターンの型推論

パラメータ化された atom を作成する family パターンでも、型推論が重要です。

atomFamily の使用

typescriptimport { atomFamily } from 'jotai/utils';

interface TodoState {
  title: string;
  completed: boolean;
}

// ✅ パラメータ(id: number)と戻り値の型が推論される
const todoAtomFamily = atomFamily((id: number) =>
  atom<TodoState>({
    title: '',
    completed: false,
  })
);

パラメータの型を明示することで、呼び出し側でも型チェックが機能します。

コンポーネントでの使用

typescriptfunction TodoItem({ id }: { id: number }) {
  // todo の型は TodoState として推論される
  const [todo, setTodo] = useAtom(todoAtomFamily(id));

  return (
    <div>
      <input
        value={todo.title}
        onChange={(e) =>
          setTodo({ ...todo, title: e.target.value })
        }
      />
    </div>
  );
}

Family パターンにより、ID ごとに独立した状態を型安全に管理できます。

まとめ

Jotai と TypeScript を組み合わせた開発では、適切な環境設定が型推論の効果を最大化します。本記事で解説した内容を改めて整理しましょう。

環境設定のチェックリスト

以下の設定を確認することで、型推論が正しく機能する環境が整います。

#項目推奨設定
1TypeScript バージョン5.3.0 以上
2Jotai バージョン2.6.0 以上
3strict モード有効化
4noImplicitAny有効化
5strictNullChecks有効化
6moduleResolutionbundler または node16
7targetES2020 以上

型推論を活かすコーディングパターン

実装時に意識すべきポイントは以下の通りです。

  • 初期値から型が推論できない場合は、ジェネリクスで型を明示する
  • 派生 atom では、複雑な変換処理の戻り値に型を指定する
  • 非同期 atom では、Promise の解決値の型を明確にする
  • 書き込み可能な atom では、update パラメータの型を定義する
  • atomFamily などのユーティリティでも、パラメータの型を明示する

開発体験の向上

適切な環境設定により、以下の恩恵が得られます。

まず、IDE の補完機能が強力に働き、コーディング速度が向上します。次に、型エラーが開発時に検出されるため、実行時のバグが激減するでしょう。さらに、リファクタリング時の安全性が高まり、大規模な変更も安心して行えます。

Jotai の軽量さと TypeScript の型安全性を組み合わせることで、保守性の高い React アプリケーションを構築できるのです。

今後の学習ステップ

本記事で扱った環境設定をベースに、以下のステップで学習を深めていくことをお勧めします。

実際のプロジェクトに Jotai を導入し、状態管理を実装してみてください。次に、Jotai の公式ドキュメントを読み、より高度なパターンを学びましょう。また、TypeScript の型システムについても理解を深めると、さらに効果的な活用が可能になります。

型推論を極めることで、開発効率と品質の両立が実現できます。ぜひ本記事の設定を試し、快適な開発体験を手に入れてください。

関連リンク