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レスポンスが不正な形式の場合、urlやaccessTokenがundefinedになります。
でも、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ファイルずつ
- 型を書きながら「なぜ?」を考える
- ライブラリの型定義を読む(学びの宝庫)
- AIレビュー(CodeRabbit等)も活用
Discussion