TypeScriptの型エラーに泣かされた話【実体験集】



## はじめに

TypeScriptを使い始めて１年経ちますが、今でも型エラーに悩まされることがあります。特に最初の頃は「なんでこんなエラーが出るの？」「型合ってるはずなのに...」と画面とにらめっこする日々でした。

今日は、実際に遭遇してハマった型エラーの事例と、その解決方法をシェアしたいと思います。同じような問題で困っている方の参考になれば嬉しいです。

## 第1話：async/awaitとPromiseの型地獄

### 事件の発端

API呼び出しのユーティリティ関数を作っていた時のことです。こんなコードを書いていました：

```typescript
// 最初に書いたコード（エラーまみれ）
async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user;
}

// これを使おうとすると...
const handleClick = async () => {
  const user = await fetchUser("123");
  console.log(user.name); // Property 'name' does not exist on type 'any'.
};
```

「あれ？`user.name`でエラーが出る...」となって、最初は「TypeScriptってよくわからない」と思っていました。

### 問題の正体

`response.json()`の戻り値が`Promise<any>`だったんですね。型情報が完全に失われていました。

```typescript
// response.json()の型定義
json(): Promise<any>
```

### 解決への道のり

最初はanyで諦めようかと思いましたが、それじゃTypeScriptを使う意味がない...ということで、段階的に改善していきました。

**Step 1: 戻り値の型を明示**

```typescript
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user; // まだanyだけど、戻り値の型は明示
}
```

**Step 2: レスポンスの検証を追加**

```typescript
import { z } from 'zod';

// Zodスキーマで型安全性を確保
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.statusText}`);
  }
  
  const rawData = await response.json();
  
  // ランタイムでの型検証
  const user = UserSchema.parse(rawData);
  return user;
}
```

**Step 3: エラーハンドリングも型安全に**

```typescript
// Result型パターンで失敗も型で表現
type ApiResult<T> = {
  success: true;
  data: T;
} | {
  success: false;
  error: string;
};

async function fetchUser(id: string): Promise<ApiResult<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    
    if (!response.ok) {
      return {
        success: false,
        error: `HTTP ${response.status}: ${response.statusText}`
      };
    }
    
    const rawData = await response.json();
    const user = UserSchema.parse(rawData);
    
    return {
      success: true,
      data: user
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : 'Unknown error'
    };
  }
}

// 使用側
const handleClick = async () => {
  const result = await fetchUser("123");
  
  if (result.success) {
    console.log(result.data.name); // 型安全！
  } else {
    console.error(result.error);
  }
};
```

### 学んだこと

- `any`は型安全性を破壊する諸悪の根源
- APIレスポンスは信頼できない。必ず検証する
- エラーハンドリングも型で表現できる

## 第2話：オブジェクトのプロパティアクセス地獄

### 事件の発端

フォームのバリデーションエラーを表示する機能を作っている時でした：

```typescript
interface FormErrors {
  email?: string;
  password?: string;
  confirmPassword?: string;
}

const errors: FormErrors = {
  email: "メールアドレスが不正です",
  password: "パスワードが短すぎます"
};

// エラーメッセージを動的に取得したい
const getErrorMessage = (field: string) => {
  return errors[field]; // Element implicitly has an 'any' type...
};
```

「なんで`errors[field]`でエラーが出るの？」と本気で困りました。

### 問題の正体

TypeScriptは`field`が`FormErrors`のどのプロパティを指すか分からないため、型安全性を保てないのでエラーを出していました。

```typescript
// field は string型だが、FormErrorsのキーは限定的
// TypeScriptは「存在しないプロパティにアクセスするかも」と判断
```

### 解決への道のり

**間違った解決法（最初にやってしまったこと）**

```typescript
// これはダメ！型安全性を失う
const getErrorMessage = (field: string) => {
  return (errors as any)[field];
};
```

**正しい解決法 1: キーの型を制限**

```typescript
const getErrorMessage = (field: keyof FormErrors) => {
  return errors[field]; // 型安全！
};

// 使用時
getErrorMessage('email'); // OK
getErrorMessage('invalid'); // Argument of type '"invalid"' is not assignable...
```

**正しい解決法 2: in演算子で存在チェック**

```typescript
const getErrorMessage = (field: string): string | undefined => {
  if (field in errors) {
    return errors[field as keyof FormErrors];
  }
  return undefined;
};
```

**正しい解決法 3: 型ガードを使用**

```typescript
const isValidErrorField = (field: string): field is keyof FormErrors => {
  return field in errors;
};

const getErrorMessage = (field: string): string | undefined => {
  if (isValidErrorField(field)) {
    return errors[field]; // 型が絞り込まれる
  }
  return undefined;
};
```

**実用的な解決法: フォームライブラリと組み合わせ**

```typescript
import { useForm } from 'react-hook-form';

interface FormData {
  email: string;
  password: string;
  confirmPassword: string;
}

const MyForm = () => {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
  
  // react-hook-formの型はちゃんと推論される
  const getErrorMessage = (field: keyof FormData) => {
    const error = errors[field];
    return error?.message;
  };
  
  return (
    <form>
      <input {...register('email')} />
      {getErrorMessage('email') && (
        <span className="error">{getErrorMessage('email')}</span>
      )}
    </form>
  );
};
```

### 学んだこと

- 動的なプロパティアクセスは型安全性の敵
- `keyof`演算子は本当に便利
- ライブラリの型定義をちゃんと活用する

## 第3話：配列とオブジェクトの変換で発狂した件

### 事件の発端

APIから取得したユーザー配列を、IDをキーとするオブジェクトに変換したかったんです：

```typescript
interface User {
  id: string;
  name: string;
  email: string;
}

const users: User[] = [
  { id: '1', name: '田中', email: 'tanaka@example.com' },
  { id: '2', name: '佐藤', email: 'sato@example.com' },
];

// こういうオブジェクトを作りたい
const userMap = {
  '1': { id: '1', name: '田中', email: 'tanaka@example.com' },
  '2': { id: '2', name: '佐藤', email: 'sato@example.com' },
};

// 最初に書いたコード
const createUserMap = (users: User[]) => {
  return users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
  }, {}); // Error: Element implicitly has an 'any' type...
};
```

また型エラーです...😭

### 問題の正体

`reduce`の初期値が`{}`（空オブジェクト）で、TypeScriptはその型を推論できませんでした。

### 解決への道のり

**解決法 1: 型アサーションを使用**

```typescript
const createUserMap = (users: User[]) => {
  return users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
  }, {} as { [key: string]: User });
};
```

**解決法 2: Record型を使用**

```typescript
const createUserMap = (users: User[]): Record<string, User> => {
  return users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
  }, {} as Record<string, User>);
};
```

**解決法 3: Object.fromEntriesを使用（個人的にお気に入り）**

```typescript
const createUserMap = (users: User[]): Record<string, User> => {
  return Object.fromEntries(
    users.map(user => [user.id, user])
  );
};
```

**解決法 4: Mapを使用（実はこっちが良い場合も）**

```typescript
const createUserMap = (users: User[]): Map<string, User> => {
  return new Map(users.map(user => [user.id, user]));
};

// 使用時
userMap.get('1')?.name; // Optional chaining で安全
```

**実用的な解決法: カスタムフックとして**

```typescript
import { useMemo } from 'react';

const useUserMap = (users: User[]) => {
  return useMemo(() => {
    return Object.fromEntries(
      users.map(user => [user.id, user])
    );
  }, [users]);
};

// コンポーネントで使用
const UserList = ({ users }: { users: User[] }) => {
  const userMap = useUserMap(users);
  
  const getUserById = (id: string) => userMap[id]; // 型安全
  
  // ...
};
```

### 学んだこと

- `reduce`の初期値の型は明示的に指定する
- `Object.fromEntries`は配列→オブジェクト変換に便利
- 場合によっては`Map`の方が適している

## 第4話：Unionの型ガードで混乱した話

### 事件の発端

APIのレスポンスが成功時と失敗時で異なる形になっていました：

```typescript
interface SuccessResponse {
  success: true;
  data: User[];
}

interface ErrorResponse {
  success: false;
  error: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

const handleResponse = (response: ApiResponse) => {
  if (response.success) {
    console.log(response.data.length); // OK
  } else {
    console.log(response.error); // OK
  }
  
  // でもこれができない...
  console.log(response.data); // Property 'data' does not exist on type 'ApiResponse'
};
```

「Union型の仕組みが理解できない...」と頭を抱えました。

### 問題の正体

TypeScript は Union型において、すべての型に共通して存在するプロパティしか直接アクセスできません。

### 解決への道のり

**型ガードの基本**

```typescript
const isSuccessResponse = (response: ApiResponse): response is SuccessResponse => {
  return response.success === true;
};

const handleResponse = (response: ApiResponse) => {
  if (isSuccessResponse(response)) {
    console.log(response.data.length); // 型が絞り込まれる
    // response は SuccessResponse として扱われる
  } else {
    console.log(response.error);
    // response は ErrorResponse として扱われる
  }
};
```

**Discriminated Union（判別可能なUnion）**

```typescript
// success プロパティが判別子として機能
type ApiResponse = SuccessResponse | ErrorResponse;

const handleResponse = (response: ApiResponse) => {
  switch (response.success) {
    case true:
      console.log(response.data.length); // SuccessResponse
      break;
    case false:
      console.log(response.error); // ErrorResponse
      break;
  }
};
```

**より複雑な例：複数の判別子**

```typescript
interface LoadingState {
  status: 'loading';
}

interface SuccessState {
  status: 'success';
  data: User[];
}

interface ErrorState {
  status: 'error';
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

const renderComponent = (state: AppState) => {
  switch (state.status) {
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <UserList users={state.data} />; // 型安全
    case 'error':
      return <div>Error: {state.error}</div>; // 型安全
    default:
      // 網羅性チェック
      const _exhaustive: never = state;
      return _exhaustive;
  }
};
```

**実用的な例：React + TypeScript**

```typescript
import { useState, useEffect } from 'react';

type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

const useAsyncData = <T>(fetchFn: () => Promise<T>) => {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
  
  useEffect(() => {
    setState({ status: 'loading' });
    
    fetchFn()
      .then(data => setState({ status: 'success', data }))
      .catch(error => setState({ 
        status: 'error', 
        error: error.message 
      }));
  }, [fetchFn]);
  
  return state;
};

// 使用例
const UserComponent = () => {
  const userState = useAsyncData(() => fetchUsers());
  
  switch (userState.status) {
    case 'idle':
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <UserList users={userState.data} />;
    case 'error':
      return <div>Error: {userState.error}</div>;
  }
};
```

### 学んだこと

- Union型では共通プロパティしか直接アクセスできない
- 判別可能なUnionは型ガードが楽になる
- `never`型で網羅性チェックができる

## 第5話：ジェネリクスに頭を悩ませた日々

### 事件の発端

汎用的なAPIクライアントを作ろうとして、ジェネリクスに初挑戦しました：

```typescript
// 最初に書いたコード
class ApiClient {
  async get(url: string) {
    const response = await fetch(url);
    return response.json(); // any型...
  }
  
  async post(url: string, data: any) {
    const response = await fetch(url, {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return response.json(); // any型...
  }
}

// 使用時
const client = new ApiClient();
const user = await client.get<User>('/users/1'); // これができない
```

「ジェネリクスってどう使うの？」状態でした。

### 問題の正体

ジェネリクスの基本的な使い方を理解していませんでした。

### 解決への道のり

**Step 1: 基本的なジェネリクス**

```typescript
class ApiClient {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json();
  }
  
  async post<T>(url: string, data: unknown): Promise<T> {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }
}

// 使用時
const client = new ApiClient();
const user = await client.get<User>('/users/1'); // 型安全！
```

**Step 2: より厳密な型制約**

```typescript
interface ApiClient {
  get<T>(url: string): Promise<T>;
  post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse>;
  put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse>;
  delete<T>(url: string): Promise<T>;
}

class HttpClient implements ApiClient {
  private baseURL: string;
  
  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }
  
  async get<T>(url: string): Promise<T> {
    const response = await fetch(`${this.baseURL}${url}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return response.json();
  }
  
  async post<TRequest, TResponse>(
    url: string, 
    data: TRequest
  ): Promise<TResponse> {
    const response = await fetch(`${this.baseURL}${url}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    return response.json();
  }
  
  // put, delete も同様...
}
```

**Step 3: 制約付きジェネリクス**

```typescript
// 特定の形を強制する
interface Identifiable {
  id: string | number;
}

class Repository<T extends Identifiable> {
  private items: T[] = [];
  
  add(item: T): void {
    this.items.push(item);
  }
  
  findById(id: T['id']): T | undefined {
    return this.items.find(item => item.id === id);
  }
  
  update(id: T['id'], updates: Partial<T>): T | undefined {
    const index = this.items.findIndex(item => item.id === id);
    if (index !== -1) {
      this.items[index] = { ...this.items[index], ...updates };
      return this.items[index];
    }
    return undefined;
  }
  
  delete(id: T['id']): boolean {
    const index = this.items.findIndex(item => item.id === id);
    if (index !== -1) {
      this.items.splice(index, 1);
      return true;
    }
    return false;
  }
}

// 使用例
interface User extends Identifiable {
  name: string;
  email: string;
}

const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: '田中', email: 'tanaka@example.com' });
const user = userRepo.findById(1); // User | undefined
```

**実用例: React hooks with generics**

```typescript
import { useState, useCallback } from 'react';

// ジェネリックなCRUD操作フック
const useCrudOperations = <T extends Identifiable>() => {
  const [items, setItems] = useState<T[]>([]);
  const [loading, setLoading] = useState(false);
  
  const addItem = useCallback((item: T) => {
    setItems(prev => [...prev, item]);
  }, []);
  
  const updateItem = useCallback((id: T['id'], updates: Partial<T>) => {
    setItems(prev => 
      prev.map(item => 
        item.id === id ? { ...item, ...updates } : item
      )
    );
  }, []);
  
  const deleteItem = useCallback((id: T['id']) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);
  
  const findItem = useCallback((id: T['id']) => {
    return items.find(item => item.id === id);
  }, [items]);
  
  return {
    items,
    loading,
    setLoading,
    addItem,
    updateItem,
    deleteItem,
    findItem,
  };
};

// 使用例
const UserManagement = () => {
  const {
    items: users,
    addItem: addUser,
    updateItem: updateUser,
    deleteItem: deleteUser,
  } = useCrudOperations<User>();
  
  // 型安全に操作できる
  const handleAddUser = () => {
    addUser({ id: Date.now(), name: '新規ユーザー', email: 'new@example.com' });
  };
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          {user.name} - {user.email}
          <button onClick={() => deleteUser(user.id)}>削除</button>
        </div>
      ))}
      <button onClick={handleAddUser}>ユーザー追加</button>
    </div>
  );
};
```

### 学んだこと

- ジェネリクスは「型の引数」
- 制約（`extends`）で型の範囲を限定できる
- Reactとの組み合わせでも威力を発揮する

## 今振り返って思うこと

TypeScriptの型エラーって、最初はとっつきにくいんですが、慣れてくると「コンパイル時にバグを見つけてくれる親切な機能」だと思えるようになりました。

特に大きなプロジェクトや、チーム開発では型の恩恵を強く感じます。「あ、この関数の引数変わってたのか」とか「nullチェック忘れてた」みたいなことをコンパイラが教えてくれるので。

### おすすめの学習方法

1. **公式ドキュメントのハンドブック**を読む（意外と分かりやすい）
2. **小さなプロジェクトから始める**（大きいプロジェクトでいきなりはキツい）
3. **エラーメッセージをちゃんと読む**（意外と親切に教えてくれる）
4. **型定義ファイル（.d.ts）を眺める**（ライブラリの使い方が分かる）

### 最後に

型エラーとの格闘は続きますが、以前より「なぜこのエラーが出るのか」が分かるようになってきました。まだまだ学ぶことはたくさんありますが、TypeScriptは確実に開発体験を向上させてくれています。

同じような問題で悩んでいる方、一緒に頑張りましょう！何か質問や、「こんなエラーでハマった」という話があれば、コメントで教えてください。

また、もっと良い解決方法をご存知の方がいらっしゃれば、ぜひ教えていただけると嬉しいです！🙏