💡

JS→TS移行で「型を書くだけ」じゃなかった話 - 型を書いて気づいた5つの設計の甘さ

に公開

はじめに

TypeScript初心者の状態から、既存のVue 3プロジェクト(JavaScript)をTypeScriptに移行する機会がありました。この記事では、実装過程で学んだTypeScriptの基礎知識と、「型を書く」という行為が設計改善につながった体験を、初心者目線で整理してまとめます。

前提条件:

  • TypeScriptは完全な初心者
  • JavaScriptは一定の学習経験あり(Vue 3, Piniaなど)
  • プログラミング言語経験あり(Python)

移行したファイル:

  • SignalR(リアルタイム通信)のラッパーモジュール(約90行)

技術スタック:

  • Vue 3 (Composition API with <script setup>)
  • TypeScript 5.7
  • Vite
  • SignalR (@microsoft/signalr)

最初の期待:
.js.tsにリネームして、型を書くだけでしょ?」

実際に起きたこと:
型を書く過程で元のコードの問題点が次々と見えてきて、TypeScriptの型定義だけでなく、実装そのものを見直すことになりました。


移行したコードの構造

SignalRラッパーモジュールの主要な関数:

// 接続管理
export async function connectSignalR(): Promise<HubConnection>
export async function disconnectSignalR(): Promise<void>

// イベントリスナー(各関数がクリーンアップ関数を返す)
export function onDeviceUpdated(callback: (data: DeviceUpdate) => void): () => void
export function onUserUpdated(callback: (data: UserUpdate) => void): () => void
export function onUserDeleted(callback: (data: UserDelete) => void): () => void

TypeScript移行で気づいた5つの問題

1. メモリリーク - イベントハンドラが削除できない

元のコード(JavaScript)

export function onDeviceUpdated(callback) {
  connection.on('deviceUpdated', callback)
}

何が問題?

VueコンポーネントでonDeviceUpdatedを使うと、アンマウント時にイベントハンドラを削除する方法がありません。
コンポーネントを開く→閉じる→開く...を繰り返すと、イベントハンドラが増え続けてメモリリークします。

TypeScript移行時の気づき

CodeRabbitのレビューで「メモリリーク対策としてクリーンアップ関数を返すべき」と指摘されました。
他のライブラリ(例: addEventListener)は削除関数を返すパターンがあることを思い出し、戻り値の型を見直しました。

修正後

export function onDeviceUpdated(
  callback: (data: DeviceUpdate) => void
): () => void {  // ← クリーンアップ関数を返す
  connection?.on('deviceUpdated', callback)
  return () => connection?.off('deviceUpdated', callback)
}

使い方(Vue Composition API)

onMounted(() => {
  const cleanup = onDeviceUpdated((data) => { ... })
  onUnmounted(cleanup) // アンマウント時に削除
})

学んだこと: 型を明示的に書くことで、「この関数は何を返すべきか?」を考えるきっかけになった。


2. レスポンス検証なし - 不明瞭なエラーメッセージ

元のコード(JavaScript)

const response = await fetch(NEGOTIATE_URL)
const { url, accessToken } = await response.json()

何が問題?

APIレスポンスが不正な形式の場合、urlaccessTokenundefinedになります。
でも、JavaScriptではエラーにならず、後続処理で「Cannot read property 'xxx' of undefined」という分かりにくいエラーが出ます。

TypeScript移行時の気づき

CodeRabbitのレビューで「negotiate レスポンスの検証がない」と指摘されました。

interface NegotiateResponse {
  url: string
  accessToken: string
}

const data = await response.json() // ← これは any 型!

TypeScriptはコンパイル時の型チェックなので、ランタイムで受け取るAPIレスポンスの型は保証されていないことに気づきました。

修正後

const data = await response.json()

// ランタイムで構造を検証
if (!data || typeof data !== 'object') {
  throw new Error('negotiate response is not an object')
}
if (typeof data.url !== 'string' || !data.url) {
  throw new Error('negotiate response missing or invalid "url"')
}
if (typeof data.accessToken !== 'string' || !data.accessToken) {
  throw new Error('negotiate response missing or invalid "accessToken"')
}

// 型アサーション(検証済みなので安全)
const { url, accessToken } = data as NegotiateResponse

学んだこと:

  • TypeScriptの型 = コンパイル時のみ(開発中のチェック)
  • ランタイムの検証 = 実行時の安全性(本番環境で必要)
  • as(型アサーション)は検証後に使うのが安全

3. タイムアウトなし - fetchが無限に待つ

元のコード(JavaScript)

const response = await fetch(NEGOTIATE_URL)

何が問題?

ネットワークトラブルや、サーバーが応答しない場合、fetchが永遠に待ち続ける可能性があります。
ユーザーは「読み込み中...」のまま待たされ続けます。

TypeScript移行時の気づき

CodeRabbitのレビューで「fetch にタイムアウトがない」と指摘されました。
Promiseの型を書いている時、成功パターンしか考えていなかったことに気づきました。

修正後

const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)

try {
  const response = await fetch(NEGOTIATE_URL, {
    signal: controller.signal  // タイムアウトシグナル
  })
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    throw new Error('negotiate request timeout (5s)')
  }
  throw error
} finally {
  clearTimeout(timeoutId)  // 必ずクリーンアップ
}

学んだこと:

  • AbortController = fetchをキャンセルする仕組み(ブラウザ標準API)
  • finally = 成功/失敗に関わらず必ず実行される
  • Promiseの型を書く時は、reject(失敗)のケースも考える

4. ハードコードされたURL - 環境切り替え不可

元のコード(JavaScript)

const NEGOTIATE_URL = 'https://production-api.example.com/api/negotiate'

何が問題?

開発環境(dev)、ステージング(staging)、本番(production)で異なるURLを使う可能性があるのに、固定値になっています。
環境ごとにコードを書き換えるのは現実的ではありません。

TypeScript移行時の気づき

CodeRabbitのレビューで「URLがハードコードされている」と指摘されました。
Viteの型定義ファイル(env.d.ts)を見ていた時、import.meta.envで環境変数が使えることを知りました。

修正後

const NEGOTIATE_URL =
  import.meta.env.VITE_SIGNALR_NEGOTIATE_URL ||
  'https://production-api.example.com/api/negotiate'

.envファイルで環境変数を定義:

# .env.development
VITE_SIGNALR_NEGOTIATE_URL=http://localhost:7071/api/negotiate

# .env.production
VITE_SIGNALR_NEGOTIATE_URL=https://production-api.example.com/api/negotiate

学んだこと:

  • ViteではVITE_プレフィックスの環境変数がimport.meta.envで使える
  • フォールバック値(||)を設定することで、環境変数がない場合も安全
  • TypeScriptはenv.d.tsで環境変数の型も定義できる

5. 古いトークン再利用 - 自動再接続で認証エラー

元のコード(JavaScript)

const { url, accessToken } = await response.json()

connection = new HubConnectionBuilder()
  .withUrl(url, {
    accessTokenFactory: () => accessToken  // 最初のトークンを保持
  })
  .withAutomaticReconnect()  // 再接続時も古いトークンを使う!
  .build()

何が問題?

SignalRには自動再接続機能(.withAutomaticReconnect())があります。
しかし、再接続時に最初のアクセストークンをそのまま使うと、トークンの有効期限が切れている場合に認証エラーが発生します。

TypeScript移行時の気づき

CodeRabbitのレビューで「自動再接続時に古いトークンを再利用している」と指摘されました。

accessTokenFactoryの型を確認すると、Promise<string>も受け付けることがわかりました:

// SignalRの型定義
accessTokenFactory?: () => string | Promise<string>

クロージャで変数をキャプチャする時、その値のライフタイム(いつまで有効か)を意識する必要があることを学びました。

修正後

connection = new HubConnectionBuilder()
  .withUrl(url, {
    accessTokenFactory: async () => {
      // 再接続のたびに新しいトークンを取得
      try {
        const response = await fetch(NEGOTIATE_URL)
        const data = await response.json()
        return data.accessToken || accessToken  // フォールバック
      } catch {
        return accessToken  // エラー時は初期トークンを使用
      }
    }
  })
  .build()

学んだこと:

  • クロージャ = 関数が外側の変数を「キャプチャ」する仕組み
  • キャプチャした値のライフタイム(いつまで有効か)を意識する
  • async () => { ... } で非同期処理を書ける(Promiseを返す関数)

TypeScriptの型定義も学べた

オプショナルチェイニング ?.

connection?.on('deviceUpdated', callback)
//        ↑ connectionがnullなら何もしない

// これと同じ
if (connection) {
  connection.on('deviceUpdated', callback)
}

関数を返す関数の型

(): () => void
//  ↑ 戻り値の型 = 「引数なし、戻り値なしの関数」

// 使い方
const cleanup: () => void = onDeviceUpdated(callback)
cleanup() // 後で呼び出せる

interface で型定義

interface NegotiateResponse {
  url: string
  accessToken: string
}

// 使用例
const data: NegotiateResponse = await response.json()
data.url // OK
data.token // エラー: プロパティ 'token' は存在しません

TypeScriptで「気づく力」が上がった

今回の5つの問題は、TypeScriptの型チェックだけでは見つからないものばかりです。

問題 TypeScript型チェック 実際の発見方法
メモリリーク ❌ コンパイル通る 「戻り値の型」を書く時に気づいた
レスポンス検証 any型で通る 「この型、保証されてる?」と疑問に思った
タイムアウト ❌ コンパイル通る Promiseの失敗ケースを考えた
環境変数 ❌ コンパイル通る env.d.tsを読んで気づいた
トークン再利用 ❌ コンパイル通る 型定義を読んで「async可能では?」と気づいた

TypeScriptの価値 = 型チェックだけじゃない

型を明示的に書くことで、

  • 「この関数、何を返すべき?」
  • 「この値、どこまで信頼できる?」
  • 「失敗のパターン、考えた?」

という思考のチェックリストができました。

JavaScriptでは「動けばOK」と見過ごしていた設計の甘さが、TypeScriptで可視化されました。

Claude CodeとCodeRabbitを組み合わせて活用することで、効果的に問題を発見できました。
AIツールをうまく使いながら、型を書いて学ぶサイクルが回せたのが大きな収穫でした。


まとめ

TypeScript移行は「.js.tsにして、型を書くだけ」ではありませんでした。

型を書く過程で、こんな疑問が次々に浮かびました:

  • 「この関数、何を返すべき?」→ クリーンアップ関数の追加
  • 「この型、本当に保証されてる?」→ ランタイム検証の追加
  • 「失敗のケース、考えた?」→ タイムアウトの実装
  • 「この値、環境で変わるべきでは?」→ 環境変数の導入
  • 「この変数、いつまで有効?」→ トークン更新の実装

TypeScriptは**「気づく力」を高めてくれるツール**でした。

型システムが強制する「明示的に書く」という行為が、設計の甘さを可視化してくれました。


次回は残りの10ファイルも移行していきます!

移行のコツ:

  1. 焦らず1ファイルずつ
  2. 型を書きながら「なぜ?」を考える
  3. ライブラリの型定義を読む(学びの宝庫)
  4. AIレビュー(CodeRabbit等)も活用

参考リンク

Discussion