T-CREATOR

<div />

SvelteとTypeScriptのユースケース 型安全な開発スタイルを整理する

2025年12月29日
SvelteとTypeScriptのユースケース 型安全な開発スタイルを整理する

Svelte で TypeScript を使った開発スタイルを実務で採用する際、「どこに型を付ければ安全になるのか」「どのパターンが保守しやすいのか」を判断する必要があります。本記事では、Props や状態管理、イベント、API 連携といった具体的なユースケースごとに、型安全な開発パターンを整理します。

実際の業務では、「TypeScript を導入したものの、どこまで型を付けるべきかわからない」「any で逃げてしまい、結局型安全にならない」といった課題に直面することがあります。検証と実装を重ねた結果、ユースケースごとに適切な型付けパターンが存在することがわかりました。

本記事が、Svelte プロジェクトで TypeScript を活用し、型安全と開発効率を両立させたいエンジニアの判断材料になれば幸いです。

ユースケース別の型安全パターン早見表

静的型付けを活用する場面を、開発の実務フローに沿って整理しました。

#ユースケース型なし開発の問題TypeScript による解決実務での重要度
1Props の受け渡しコンポーネント間で予期しない値が渡されるインターフェースで Props を明示
2状態管理 (Store)Store の値の構造が不明確Writable, Readable の型パラメータで保証
3イベント送信イベントペイロードの型が不明createEventDispatcher のジェネリクスで型安全
4API レスポンスレスポンス構造の変更に気づけないAPI 型定義でコンパイル時チェック
5リアクティブ宣言計算結果の型推論が曖昧明示的な型注釈で予測可能に

この表は即答用の概要です。詳細な判断基準と実装パターンは後段で解説します。

検証環境

  • OS: macOS 15.2 (Sequoia)
  • Node.js: 22.12.0 (LTS)
  • TypeScript: 5.7.2
  • 主要パッケージ:
    • svelte: 5.2.3
    • @sveltejs/kit: 2.10.0
    • vite: 6.0.3
  • 検証日: 2025 年 12 月 29 日

Svelte + TypeScript で型安全が求められる背景

フロントエンド開発における静的型付けの普及

2025 年現在、フロントエンド開発において TypeScript の採用率は 80% を超えています。単なるトレンドではなく、チーム開発やプロダクトの長期運用において、静的型付けが実質的な標準となっています。

Svelte はコンパイラベースのフレームワークとして、ビルド時に最適化された JavaScript を生成する特性を持ちます。この特性と TypeScript を組み合わせることで、以下の二重の安全網が構築できます。

mermaidflowchart LR
  code["TypeScript<br/>コード"] --> tsc["TypeScript<br/>コンパイラ"]
  tsc --> check1["型チェック"]
  check1 --> svelte["Svelte<br/>コンパイラ"]
  svelte --> check2["構文最適化"]
  check2 --> output["最適化された<br/>JavaScript"]

TypeScript による型チェックで論理エラーを防ぎ、Svelte のコンパイラでランタイムの無駄を削減する、という二段階の品質担保が可能になります。

Svelte 特有のリアクティビティと型の関係

Svelte の大きな特徴は、$: 構文によるリアクティブ宣言です。変数の変更を自動検知して再計算・再レンダリングが走る仕組みですが、この便利さの裏には「どの変数がどの型を持つか」が不明確になるリスクがあります。

実際に試したところ、以下のような問題が発生しました。

typescript// リアクティブ宣言での型推論の曖昧さ
let count = 0; // number と推論される

$: doubled = count * 2; // number と推論
$: message = `Count is ${count}`; // string と推論

// しかし、以下のような変更があると...
count = "5"; // ❌ コンパイルエラーが出ないと事故の元

TypeScript を導入することで、変数の型が変わるタイミングでエディタが警告を出し、バグを未然に防げます。

UI/UX に影響を与える型安全性

型安全性は、単なる開発者体験の向上だけでなく、エンドユーザーの UI/UX にも直結します。

業務で問題になったケースとして、フォーム送信時に API のレスポンス構造が変更されていたことに気づかず、エラーメッセージが表示されない状態でリリースしてしまった経験があります。TypeScript で API レスポンスの型を定義していれば、ビルド時にエラーが検出され、未然に防げた問題でした。

mermaidsequenceDiagram
  participant User as ユーザー
  participant UI as UI コンポーネント
  participant API as API
  participant Type as 型チェック

  User->>UI: フォーム送信
  UI->>API: リクエスト送信
  API->>UI: レスポンス(構造変更)
  UI->>Type: 型チェック
  Type-->>UI: ❌ 型不一致検出
  UI->>User: エラー表示

型チェックにより、ユーザーに不具合が届く前にエラーを検出できるため、UI/UX の品質が向上します。

この章でわかること

  • TypeScript と Svelte の組み合わせで二重の品質担保が可能
  • リアクティブ宣言における型の重要性
  • 型安全性が UI/UX に与える影響

つまずきポイント

  • Svelte のリアクティブ宣言 $: で型推論が曖昧になりやすい
  • Props や Store の型を後から追加するのは手間がかかる

型なし開発で実務的に発生する課題

Props の型不一致によるランタイムエラー

JavaScript のみで Svelte コンポーネントを開発すると、親コンポーネントから子コンポーネントへ渡す Props の型が保証されません。

検証の結果、以下のようなエラーが本番環境で発生するリスクがあることがわかりました。

javascript<!-- UserCard.svelte (型なし) -->
<script>
  export let user; // 何が渡されるか不明
  export let onDelete; // 関数のはずだが保証なし
</script>

<div>
  <h3>{user.name}</h3>
  <button on:click={() => onDelete(user.id)}>削除</button>
</div>
javascript<!-- App.svelte (親コンポーネント) -->
<script>
  import UserCard from './UserCard.svelte';

  let user = { id: 1, username: 'taro' }; // ❌ name ではなく username

  function handleDelete(id) {
    console.log('Delete user:', id);
  }
</script>

<UserCard {user} onDelete={handleDelete} />

この場合、user.nameundefined となり、画面に何も表示されません。さらに悪いことに、エディタでは何も警告が出ず、実行して初めて気づくことになります。

Store の型不明による予期しない状態更新

Svelte の Store は、writable や readable といった仕組みでグローバルな状態管理を実現します。しかし、型定義がないと Store に何を格納できるかが不明確になり、意図しないデータ構造の変更が発生します。

実際に遭遇した問題として、以下のようなケースがありました。

javascript// stores.js (型なし)
import { writable } from "svelte/store";

export const userStore = writable({
  id: null,
  name: "",
  isLoggedIn: false,
});
javascript// ComponentA.svelte
<script>
  import { userStore } from './stores.js';

  userStore.set({
    id: 123,
    name: 'Taro',
    isLoggedIn: true,
    role: 'admin' // ❌ 後から追加したプロパティ
  });
</script>
javascript// ComponentB.svelte
<script>
  import { userStore } from './stores.js';

  $: if ($userStore.permissions) { // ❌ permissions は存在しない
    console.log('Permissions:', $userStore.permissions);
  }
</script>

Store の構造が統一されておらず、各コンポーネントで異なるプロパティを期待してしまう問題が発生しました。放置すると、「ログイン状態なのに権限チェックが動かない」といった、デバッグが困難なバグにつながります。

API レスポンスの構造変更への対応遅れ

API 連携では、バックエンドのレスポンス構造が変更されることがあります。型定義がないと、この変更に気づくのが遅れ、本番環境でエラーが発生するリスクがあります。

業務での実例として、以下のような変更がありました。

変更前のレスポンス

json{
  "user": {
    "id": 1,
    "name": "Taro",
    "email": "taro@example.com"
  }
}

変更後のレスポンス

json{
  "data": {
    "user": {
      "id": 1,
      "fullName": "Taro Yamada",
      "email": "taro@example.com"
    }
  }
}

この変更により、以下のコードが動かなくなりました。

javascript// 型なしのコード
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  return data.user; // ❌ data.data.user に変更されている
}

TypeScript で型定義していれば、ビルド時に data.user が存在しないことが検出され、修正が促されます。しかし型なしでは、ユーザーがアクセスして初めてエラーが発覚するという最悪のシナリオになります。

mermaidflowchart TD
  api["API 変更"] --> deploy["バックエンド<br/>デプロイ"]
  deploy --> frontend["フロントエンド<br/>アクセス"]
  frontend --> check{"型チェック<br/>あり?"}
  check -->|なし| runtime["ランタイム<br/>エラー"]
  check -->|あり| build["ビルドエラー<br/>で検出"]
  runtime --> user["ユーザーに<br/>影響"]
  build --> fix["修正後<br/>デプロイ"]

この章でわかること

  • Props の型不一致がランタイムエラーを引き起こす
  • Store の型がないと状態管理が破綻しやすい
  • API 変更への対応が遅れるリスク

つまずきポイント

  • 型なしでは「動いているように見えて実は壊れている」状態が発生しやすい
  • 小規模プロジェクトでは問題が顕在化しにくく、規模が大きくなってから気づく

TypeScript による型安全な開発スタイルの選択

SvelteKit プロジェクトでの TypeScript 導入判断

実際に TypeScript を導入する際、「新規プロジェクトから始める」か「既存プロジェクトに途中から導入する」かで戦略が変わります。

業務で検証した結果、以下の判断基準を採用しました。

#プロジェクト状況推奨アプローチ理由
1新規プロジェクト最初から TypeScript後から追加するコストが高い
2小規模既存 (10 ファイル未満)一気に TypeScript 化影響範囲が小さく短期間で完了
3中規模既存 (50 ファイル未満)段階的に TypeScript 化重要な部分から順に型を追加
4大規模既存 (100 ファイル以上)新規コードのみ TypeScript全体移行はコスト高、新規から徐々に

新規プロジェクトでの TypeScript 有効化

SvelteKit では、プロジェクト作成時に TypeScript を選択するだけで環境が整います。

bash# SvelteKit プロジェクトの作成
npm create svelte@latest my-app
cd my-app
npm install

対話形式で以下を選択します。

  • Which Svelte app template?: SvelteKit demo app (推奨)
  • Add type checking with TypeScript?: Yes, using TypeScript syntax
  • Select additional options: Prettier, ESLint, Vitest (お好みで)

既存プロジェクトへの TypeScript 追加

既存の JavaScript プロジェクトに TypeScript を追加する場合、公式の移行ツールを使用します。

bashnpx sv migrate typescript
npm install

このコマンドにより、以下が自動生成されます。

  • tsconfig.json: TypeScript 設定
  • src​/​app.d.ts: SvelteKit 用の型定義
  • vite.config.ts: Vite 設定の TypeScript 化

実際に試したところ、このコマンド一発で環境構築が完了し、既存の .svelte ファイルに <script lang="ts"> を追加するだけで TypeScript が有効になりました。

tsconfig.json の推奨設定

自動生成される tsconfig.json は以下のようになります。

json{
  "extends": "./.svelte-kit/tsconfig.json",
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "strict": true,
    "moduleResolution": "bundler"
  }
}

重要な設定項目の説明

strict: true(最重要)

厳格な型チェックを有効化します。以下のオプションがまとめて有効になります。

  • noImplicitAny: 型推論できない場合に any を禁止
  • strictNullChecks: nullundefined を厳密にチェック
  • strictFunctionTypes: 関数の引数と戻り値を厳密にチェック

業務では strict: true を必須としています。これがないと TypeScript を導入した意味が薄れます。

allowJs / checkJs

JavaScript ファイルと TypeScript ファイルの混在を許可します。段階的に移行する際に有用です。

  • allowJs: true: .js ファイルのインポートを許可
  • checkJs: true: .js ファイルも型チェック対象にする

採用しなかった選択肢として、checkJs: false にすることも可能ですが、これだと JavaScript ファイルの型エラーに気づけません。段階的移行であっても checkJs: true を推奨します。

この章でわかること

  • プロジェクト規模に応じた TypeScript 導入戦略
  • 公式ツールで簡単に環境構築できる
  • strict モードは必須

つまずきポイント

  • strict: true にすると既存コードでエラーが大量に出る場合がある
  • 段階的移行では allowJscheckJs の設定を理解しないと混乱する

ユースケース別の型安全実装パターン

Props の型付けパターン

Svelte コンポーネントで Props を受け取る際、TypeScript でインターフェースを定義することで、親コンポーネントから渡される値の型を保証できます。

基本的な Props の型定義

シンプルなプリミティブ型の Props は、以下のように定義します。

typescript<!-- UserCard.svelte -->
<script lang="ts">
  export let name: string;
  export let age: number;
  export let email: string | undefined = undefined; // オプショナル
  export let isActive: boolean = true; // デフォルト値付き
</script>

<div class="user-card">
  <h3>{name}</h3>
  <p>年齢: {age}</p>
  {#if email}
    <p>メール: {email}</p>
  {/if}
  <span class:active={isActive}>
    {isActive ? 'アクティブ' : '非アクティブ'}
  </span>
</div>

この定義により、親コンポーネントで以下のように使用する際、型チェックが働きます。

typescript<!-- App.svelte -->
<script lang="ts">
  import UserCard from './UserCard.svelte';
</script>

<UserCard name="太郎" age={25} email="taro@example.com" />
<UserCard name="花子" age={30} /> <!-- email は省略可能 -->
<UserCard name="次郎" age="25" /> <!-- ❌ age は number 型が必要 -->
インターフェースを使った複雑な Props

オブジェクト型の Props は、インターフェースとして定義すると再利用性と可読性が向上します。

typescript// src/lib/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string; // オプショナルプロパティ
  createdAt: Date;
}

export interface UserRole {
  role: "admin" | "editor" | "viewer"; // ユニオン型で限定
  permissions: string[];
}
typescript<!-- UserProfile.svelte -->
<script lang="ts">
  import type { User, UserRole } from '$lib/types/user';

  export let user: User;
  export let role: UserRole;
  export let onEdit: (user: User) => void; // 関数の型も定義
</script>

<div class="profile">
  <img src={user.avatar ?? '/default-avatar.png'} alt={user.name} />
  <h2>{user.name}</h2>
  <p>{user.email}</p>
  <p>役割: {role.role}</p>
  <button on:click={() => onEdit(user)}>編集</button>
</div>

実際に試したところ、インターフェースを分離することで、以下のメリットがありました。

  • 複数のコンポーネントで同じ型を再利用できる
  • 型定義ファイルを見るだけでデータ構造が理解できる
  • API レスポンスの型と統一できる
ジェネリクスを使った汎用コンポーネント

Svelte 5 では、ジェネリクスを使った型安全な汎用コンポーネントが作成できます。

typescript<!-- DataList.svelte -->
<script lang="ts" generics="T">
  export let items: T[];
  export let getId: (item: T) => string | number;
  export let renderItem: (item: T) => string;
  export let onSelect: ((item: T) => void) | undefined = undefined;
</script>

<ul class="data-list">
  {#each items as item (getId(item))}
    <li>
      <button on:click={() => onSelect?.(item)}>
        {renderItem(item)}
      </button>
    </li>
  {/each}
</ul>

使用例:

typescript<script lang="ts">
  import DataList from './DataList.svelte';

  interface Product {
    id: number;
    name: string;
    price: number;
  }

  let products: Product[] = [
    { id: 1, name: 'ノートPC', price: 89800 },
    { id: 2, name: 'マウス', price: 2980 }
  ];

  function handleSelect(product: Product) {
    console.log('Selected:', product.name);
  }
</script>

<DataList
  items={products}
  getId={(p) => p.id}
  renderItem={(p) => `${p.name} - ¥${p.price.toLocaleString()}`}
  onSelect={handleSelect}
/>

ジェネリクスを使うことで、任意の型の配列を扱える汎用的なコンポーネントが作成でき、型安全性も保たれます。

状態管理 (Store) の型安全パターン

Svelte の Store を使ったグローバル状態管理では、Store の型を明示的に定義することで、状態の一貫性を保証できます。

Writable Store の型定義

基本的な Writable Store の型定義方法です。

typescript// src/lib/stores/counter.ts
import { writable, type Writable } from "svelte/store";

export const count: Writable<number> = writable(0);

// 型安全なヘルパー関数
export function increment() {
  count.update((n) => n + 1);
}

export function decrement() {
  count.update((n) => n - 1);
}

export function reset() {
  count.set(0);
}

コンポーネントでの使用:

typescript<script lang="ts">
  import { count, increment, decrement, reset } from '$lib/stores/counter';
</script>

<div>
  <p>カウント: {$count}</p>
  <button on:click={increment}>+1</button>
  <button on:click={decrement}>-1</button>
  <button on:click={reset}>リセット</button>
</div>
複雑な状態の型定義

実務では、Store に複雑なオブジェクトを格納することが多いです。

typescript// src/lib/stores/auth.ts
import { writable, derived, type Readable } from "svelte/store";

export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
}

export interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
}

const initialState: AuthState = {
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,
};

export const authStore = writable<AuthState>(initialState);

// 派生 Store の型定義
export const currentUser: Readable<User | null> = derived(
  authStore,
  ($auth) => $auth.user,
);

export const isAdmin: Readable<boolean> = derived(
  authStore,
  ($auth) => $auth.user?.role === "admin",
);

// 型安全な認証アクション
export async function login(email: string, password: string): Promise<void> {
  authStore.update((state) => ({ ...state, isLoading: true, error: null }));

  try {
    const response = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) throw new Error("ログインに失敗しました");

    const user: User = await response.json();

    authStore.set({
      user,
      isAuthenticated: true,
      isLoading: false,
      error: null,
    });
  } catch (error) {
    authStore.update((state) => ({
      ...state,
      isLoading: false,
      error: error instanceof Error ? error.message : "不明なエラー",
    }));
  }
}

export function logout(): void {
  authStore.set(initialState);
}

業務で実際に使用した結果、以下の点が重要だとわかりました。

  • Store の初期値を initialState として明示的に定義する
  • 派生 Store を使って、必要な値だけを公開する
  • Store を直接操作せず、関数経由でアクセスさせる
mermaidstateDiagram-v2
  [*] --> NotAuthenticated
  NotAuthenticated --> Loading : login() 実行
  Loading --> Authenticated : ログイン成功
  Loading --> Error : ログイン失敗
  Error --> NotAuthenticated : リトライ
  Authenticated --> NotAuthenticated : logout() 実行

  state Authenticated {
    [*] --> UserRole
    UserRole --> Admin : role = admin
    UserRole --> User : role = user
  }
Custom Store パターン

Store をカプセル化して、外部から直接操作できないようにするパターンです。

typescript// src/lib/stores/todos.ts
import { writable } from "svelte/store";

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: Date;
}

function createTodoStore() {
  const { subscribe, set, update } = writable<Todo[]>([]);

  return {
    subscribe, // 読み取りのみ公開
    add: (text: string) => {
      const newTodo: Todo = {
        id: Date.now(),
        text,
        completed: false,
        createdAt: new Date(),
      };
      update((todos) => [...todos, newTodo]);
    },
    toggle: (id: number) => {
      update((todos) =>
        todos.map((todo) =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo,
        ),
      );
    },
    remove: (id: number) => {
      update((todos) => todos.filter((todo) => todo.id !== id));
    },
    clear: () => set([]),
  };
}

export const todos = createTodoStore();

このパターンを採用した理由:

  • Store の操作を関数に限定し、予期しない更新を防ぐ
  • インターフェースが明確で、使い方がわかりやすい
  • 型安全性を保ちながら、カプセル化も実現

この章でわかること

  • Props はインターフェースで型定義すると再利用性が高まる
  • Store は型パラメータで状態の一貫性を保証する
  • Custom Store パターンで操作を制限できる

つまずきポイント

  • ジェネリクスは便利だが、初学者には理解が難しい
  • Store の型定義を後から変更すると、全体への影響が大きい

イベントの型安全パターン

Svelte のコンポーネント間通信では、createEventDispatcher を使ってカスタムイベントを発火します。TypeScript でイベントペイロードの型を定義することで、型安全なイベント送受信が可能になります。

createEventDispatcher の型定義

イベントの型を定義するには、イベント名とペイロードの型をマッピングしたインターフェースを作成します。

typescript<!-- FormComponent.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  // イベントペイロードの型定義
  interface FormEvents {
    submit: { name: string; email: string };
    cancel: { reason: string };
    validate: { field: string; isValid: boolean };
  }

  const dispatch = createEventDispatcher<FormEvents>();

  let name = '';
  let email = '';

  function handleSubmit() {
    // 型安全なイベント発火
    dispatch('submit', { name, email });
  }

  function handleCancel() {
    dispatch('cancel', { reason: 'user-action' });
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <input bind:value={name} placeholder="名前" />
  <input bind:value={email} type="email" placeholder="メール" />
  <button type="submit">送信</button>
  <button type="button" on:click={handleCancel}>キャンセル</button>
</form>

親コンポーネントでのイベント受信:

typescript<!-- App.svelte -->
<script lang="ts">
  import FormComponent from './FormComponent.svelte';

  function handleSubmit(event: CustomEvent<{ name: string; email: string }>) {
    const { name, email } = event.detail;
    console.log('送信:', name, email);
  }

  function handleCancel(event: CustomEvent<{ reason: string }>) {
    console.log('キャンセル理由:', event.detail.reason);
  }
</script>

<FormComponent on:submit={handleSubmit} on:cancel={handleCancel} />

実際に試したところ、イベントペイロードの型が明確になることで、以下のメリットがありました。

  • エディタの自動補完が効く
  • ペイロードの構造が変わったときにコンパイルエラーで気づける
  • ドキュメントを見なくてもイベントの仕様がわかる
イベント型の共通化

複数のコンポーネントで同じイベント型を使う場合、型定義ファイルに切り出します。

typescript// src/lib/types/events.ts
export interface UserActionEvents {
  create: { user: { name: string; email: string } };
  update: { userId: number; changes: Partial<{ name: string; email: string }> };
  delete: { userId: number; confirmed: boolean };
}

export interface NotificationEvents {
  show: { message: string; type: "info" | "warning" | "error" };
  hide: { notificationId: string };
}

使用例:

typescript<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import type { UserActionEvents } from '$lib/types/events';

  const dispatch = createEventDispatcher<UserActionEvents>();

  function deleteUser(userId: number) {
    if (confirm('本当に削除しますか?')) {
      dispatch('delete', { userId, confirmed: true });
    }
  }
</script>

API データの型管理パターン

API 連携では、レスポンスの型を定義することで、データ構造の変更に強い実装が可能になります。

API レスポンスの型定義

まず、API から返されるデータの型を定義します。

typescript// src/lib/types/api.ts
export interface ApiResponse<T> {
  data: T;
  status: "success" | "error";
  message?: string;
}

export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  role: "admin" | "user";
  createdAt: string; // ISO 8601 文字列
}

export interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
}
型安全な fetch ラッパー関数

API クライアントを作成し、型安全な通信を実現します。

typescript// src/lib/api/client.ts
import type { ApiResponse, ApiError } from "$lib/types/api";

export class ApiClient {
  private baseUrl: string;

  constructor(baseUrl = "/api") {
    this.baseUrl = baseUrl;
  }

  private async request<T>(
    endpoint: string,
    options?: RequestInit,
  ): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, options);

    if (!response.ok) {
      const error: ApiError = await response.json();
      throw new Error(error.error.message);
    }

    return response.json();
  }

  async get<T>(endpoint: string): Promise<ApiResponse<T>> {
    return this.request<ApiResponse<T>>(endpoint);
  }

  async post<T, U = unknown>(
    endpoint: string,
    data: U,
  ): Promise<ApiResponse<T>> {
    return this.request<ApiResponse<T>>(endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
  }

  async put<T, U = unknown>(
    endpoint: string,
    data: U,
  ): Promise<ApiResponse<T>> {
    return this.request<ApiResponse<T>>(endpoint, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
  }

  async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
    return this.request<ApiResponse<T>>(endpoint, { method: "DELETE" });
  }
}

export const apiClient = new ApiClient();
SvelteKit の load 関数での型安全なデータフェッチ

SvelteKit の load 関数内で API データを取得する際も、型安全性を保ちます。

typescript// src/routes/users/+page.ts
import type { PageLoad } from "./$types";
import type { User } from "$lib/types/api";
import { apiClient } from "$lib/api/client";

export const load: PageLoad = async () => {
  try {
    const response = await apiClient.get<User[]>("/users");

    return {
      users: response.data,
      status: response.status,
    };
  } catch (error) {
    return {
      users: [],
      error: error instanceof Error ? error.message : "不明なエラー",
    };
  }
};

コンポーネントでのデータ使用:

typescript<!-- src/routes/users/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  export let data: PageData;

  $: users = data.users ?? [];
  $: error = data.error;
</script>

{#if error}
  <p class="error">{error}</p>
{:else}
  <ul>
    {#each users as user (user.id)}
      <li>
        <strong>{user.name}</strong> ({user.email})
        <span class="role">{user.role}</span>
      </li>
    {/each}
  </ul>
{/if}

業務で実際に使用した結果、API の型定義により以下の効果がありました。

  • バックエンドの API 仕様変更に即座に気づける
  • レスポンスの構造が明確で、データの取り扱いに迷わない
  • エラーハンドリングが統一され、保守しやすい
mermaidsequenceDiagram
  participant Page as +page.svelte
  participant Load as load 関数
  participant Client as ApiClient
  participant API as バックエンド API
  participant Type as TypeScript<br/>コンパイラ

  Page->>Load: ページ読み込み
  Load->>Client: get<User[]>('/users')
  Client->>API: fetch リクエスト
  API->>Client: JSON レスポンス
  Client->>Type: 型チェック
  Type-->>Client: ApiResponse<User[]>
  Client->>Load: 型安全なデータ
  Load->>Page: PageData として渡す
  Page->>Page: 型安全に表示
Zod による実行時型検証

TypeScript のコンパイル時チェックに加え、実行時にも型を検証する場合、Zod ライブラリを使用します。

typescript// src/lib/types/api.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  avatar: z.string().url().optional(),
  role: z.enum(["admin", "user"]),
  createdAt: z.string().datetime(),
});

export type User = z.infer<typeof UserSchema>;

export const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    data: dataSchema,
    status: z.enum(["success", "error"]),
    message: z.string().optional(),
  });
typescript// src/lib/api/client.ts
import { UserSchema, ApiResponseSchema } from "$lib/types/api";

export async function fetchUsers(): Promise<User[]> {
  const response = await fetch("/api/users");
  const json = await response.json();

  // 実行時型検証
  const parsed = ApiResponseSchema(z.array(UserSchema)).parse(json);

  return parsed.data;
}

Zod を採用した理由:

  • API レスポンスが予期しない構造だった場合に、実行時にエラーを検出できる
  • TypeScript の型定義とスキーマを統一できる

採用しなかった選択肢として、any にキャストして型検証をスキップすることも可能ですが、実行時のバグを見逃すリスクが高まるため推奨しません。

この章でわかること

  • イベントペイロードの型定義で、コンポーネント間通信が安全になる
  • API レスポンスの型定義で、データ構造の変更に強くなる
  • Zod で実行時型検証を追加できる

つまずきポイント

  • CustomEvent の型定義が冗長になりやすい
  • API の型定義とバックエンドの仕様が乖離すると、実行時エラーになる

型安全開発スタイルの実務判断まとめ

ここまで解説したユースケース別の型安全パターンを、実務での判断基準と合わせて整理します。

ユースケース別の型付け優先度

実際のプロジェクトでは、すべてのコードに厳密な型を付けるのは現実的ではありません。以下の優先度で段階的に型安全化を進めることを推奨します。

優先度ユースケース型付けの範囲理由
最優先API レスポンスすべてのエンドポイントデータ構造の変更に気づかないと致命的
最優先Store (グローバル状態)すべての Store状態管理の破綻を防ぐ
コンポーネント Props公開 API となるコンポーネント再利用時の誤用を防ぐ
イベントペイロード複雑なデータを送信するイベントシンプルなイベントは省略可
ローカル変数型推論で不十分な箇所のみTypeScript の型推論に任せる

型安全と開発速度のトレードオフ

TypeScript を導入すると、初期の開発速度は若干低下します。しかし、中長期的には以下の理由で開発効率が向上します。

mermaidflowchart LR
  start["プロジェクト<br/>開始"] --> phase1["初期開発<br/>フェーズ"]
  phase1 --> without["型なし開発"]
  phase1 --> with["型あり開発"]

  without --> fast1["初期は速い"]
  fast1 --> slow1["バグ対応で<br/>速度低下"]

  with --> slow2["初期は遅い"]
  slow2 --> fast2["バグ少なく<br/>速度維持"]

  slow1 --> result["長期的には<br/>型ありが効率的"]
  fast2 --> result

業務での検証結果:

  • 1〜2 週間のプロトタイプ: 型なしの方が速い
  • 1〜3 ヶ月の小規模プロジェクト: 型ありの方が安定する
  • 6 ヶ月以上の中長期プロジェクト: 型ありが圧倒的に有利

採用した設計判断と採用しなかった選択肢

実務で TypeScript を導入する際、以下の判断を行いました。

strict モードは必須

採用: tsconfig.jsonstrict: true を設定

理由:

  • 型安全性を最大限に活用するため
  • 後から strict にすると修正コストが高い

採用しなかった選択肢: strict: false でゆるい型チェック

理由: TypeScript を導入する意味が薄れる

any は原則禁止、unknown を使用

採用: 型が不明な場合は unknown を使用し、型ガードで絞り込む

typescriptfunction processData(data: unknown) {
  if (typeof data === "string") {
    console.log(data.toUpperCase()); // string と推論される
  } else if (Array.isArray(data)) {
    console.log(data.length); // array と推論される
  }
}

採用しなかった選択肢: any で型チェックをスキップ

理由: any は型安全性を完全に失うため

コンポーネントの Props は必ず型定義

採用: すべての公開コンポーネントで Props の型を定義

理由:

  • コンポーネントの再利用時に誤用を防ぐ
  • ドキュメントとしても機能する

採用しなかった選択肢: 内部コンポーネントは型定義を省略

理由: 後から公開コンポーネントに変わることがあり、後付けが面倒

API の型定義とスキーマ検証を併用

採用: TypeScript の型定義 + Zod での実行時検証

理由:

  • コンパイル時と実行時の二重チェックで安全性が高まる
  • バックエンドの仕様変更に気づきやすい

採用しなかった選択肢: TypeScript の型定義のみ

理由: 実行時のデータが型定義と一致する保証がない

初学者と実務者への推奨アプローチ

初学者向けの学習順序
  1. 基本的な型から始める: string, number, boolean, array
  2. Props の型定義を練習: 単純なコンポーネントから
  3. Store の型定義: グローバル状態管理の基礎
  4. API の型定義: 実践的なアプリケーション開発

つまずきやすいポイント:

  • ジェネリクスは最初は飛ばしてもよい
  • unknownany の違いは後回しでもよい
  • まずは「型を付ける習慣」を身につける
実務者向けの導入戦略
  1. 新規コンポーネントから TypeScript 化: 既存は後回し
  2. API レスポンスの型定義を優先: 最も効果が高い
  3. Store の型定義: 状態管理の安全性を確保
  4. 既存コードの段階的移行: 優先度の高い部分から

業務で問題になったケース:

  • すべてのコードを一気に TypeScript 化しようとして挫折
  • any で逃げすぎて、型安全性が保たれない

解決策:

  • 優先度を決めて段階的に移行
  • any を使う場合は、コメントで理由を残す

この章でわかること

  • 型付けの優先度を決めて段階的に導入すると効率的
  • strict モードは必須、any は原則禁止
  • 初学者は基本から、実務者は新規コードから TypeScript 化

つまずきポイント

  • 完璧主義に陥ると開発が進まない
  • チーム全員の TypeScript スキルレベルに差があると混乱する

まとめ

Svelte と TypeScript を組み合わせた型安全な開発スタイルは、Props、状態管理、イベント、API 連携といったユースケースごとに適切な型付けパターンが存在します。重要なのは、すべてに厳密な型を付けるのではなく、優先度を決めて段階的に導入することです。

本記事で解説した内容を振り返ります。

型安全開発で得られる効果

  • API レスポンスの型定義: データ構造の変更に即座に気づき、本番環境でのエラーを防ぐ
  • Store の型定義: グローバル状態の一貫性を保ち、予期しない状態更新を防ぐ
  • Props の型定義: コンポーネントの再利用時に誤用を防ぎ、保守性を向上させる
  • イベントの型定義: コンポーネント間通信を明確にし、ペイロードの構造を保証する

実務での判断基準

静的型付けを導入する際は、以下の基準で優先度を決めることを推奨します。

判断基準推奨アプローチ条件
プロジェクト規模新規は TypeScript 必須中長期運用が前提
既存プロジェクトAPI と Store から型定義段階的移行が現実的
チームスキル基本的な型から始めるジェネリクスは後回し可
開発速度strict モードは必須初期は遅くても長期では効率的

型安全と UI/UX の関係

型安全性は開発者体験だけでなく、エンドユーザーの UI/UX にも影響します。API の型定義により、データ構造の変更をビルド時に検出できれば、ユーザーがエラー画面を見る確率が大幅に減少します。

段階的な導入が成功の鍵

業務で検証した結果、一気にすべてを TypeScript 化するのではなく、以下の順序で進めることが効果的でした。

  1. 新規コンポーネントから TypeScript を採用
  2. API レスポンスと Store の型定義を優先
  3. 既存コードは影響範囲を見ながら段階的に移行
  4. any を使う場合はコメントで理由を残す

TypeScript の学習コストは存在しますが、中長期的な開発効率と品質向上を考慮すると、投資価値は非常に高いものになります。Svelte の軽量性と TypeScript の安全性を組み合わせることで、保守しやすく、スケーラブルなインターフェース設計が可能になるでしょう。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;