JavaScript から TypeScript への移行ガイド
既存 JS プロジェクトを段階的に TypeScript 化する実践的なロードマップと移行テクニック
JavaScript から TypeScript への移行ガイド
既存 JS プロジェクトを段階的に TypeScript 化する実践的なロードマップと移行テクニック
この章で学ぶこと
- 段階的移行戦略 -- 全面書き換えではなく、ファイル単位で安全に TypeScript を導入する手順
- tsconfig の段階的厳密化 --
allowJsから始めてstrict: trueに到達するまでの設定チェーンの管理 - よくある移行パターン -- 型定義の補完、any の排除、サードパーティ型の導入テクニック
- 大規模プロジェクトでの移行戦略 -- 1万行以上のプロジェクトでの実践的なアプローチ
- 移行後の品質維持 -- CI/CD での型チェック統合、チーム内の TypeScript 標準化
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- TypeScript テスト完全ガイド の内容を理解していること
1. 移行ロードマップ
1-1. 全体フロー
段階的移行の 5 フェーズ:
Phase 1: 準備 Phase 2: 共存 Phase 3: 変換
+---------------+ +---------------+ +---------------+
| tsconfig 導入 | | .js + .ts | | 主要ファイルを |
| allowJs: true | | 共存 | | .ts に変換 |
| strict: false | | checkJs: true | | any を除去 |
+---------------+ +---------------+ +---------------+
| | |
v v v
Phase 4: 厳密化 Phase 5: 完了
+---------------+ +---------------+
| strict: true | | 全ファイル .ts |
| 個別オプション | | CI で型チェック |
| を順次有効化 | | JSDoc 除去 |
+---------------+ +---------------+
期間の目安:
~1,000行: 1-2日
~10,000行: 1-2週間
~100,000行: 1-3ヶ月
~1,000,000行: 3-12ヶ月
1-2. Phase 1 -- 準備
# TypeScript と関連ツールをインストール
npm install -D typescript @types/node
# フレームワークの型もインストール
npm install -D @types/express @types/cors @types/lodash
# tsconfig.json を生成
npx tsc --init// Phase 1 の tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}// package.json にスクリプト追加
{
"scripts": {
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch"
}
}1-3. Phase 2 -- JS と TS の共存
// JSDoc で既存 JS ファイルに型をつける(ファイル変換前)
// src/utils.js
/**
* @param {string} name
* @param {number} age
* @returns {{ name: string, age: number, id: string }}
*/
function createUser(name, age) {
return {
name,
age,
id: Math.random().toString(36).slice(2),
};
}
/**
* @typedef {Object} Config
* @property {string} apiUrl
* @property {number} timeout
* @property {boolean} [debug]
*/
/**
* @param {Config} config
* @returns {void}
*/
function initApp(config) {
// ...
}
/**
* @template T
* @param {T[]} items
* @param {(item: T) => boolean} predicate
* @returns {T | undefined}
*/
function findItem(items, predicate) {
return items.find(predicate);
}
/**
* @param {unknown} value
* @returns {value is string}
*/
function isString(value) {
return typeof value === "string";
}
module.exports = { createUser, initApp, findItem, isString };// Phase 2 の tsconfig.json 更新
{
"compilerOptions": {
"allowJs": true,
"checkJs": true, // JS ファイルも型チェック
"strict": false,
// JSDoc の型チェックエラーを段階的に表示
"noImplicitAny": false
}
}JSDoc の型アノテーション一覧:
基本型:
@param {string} name -- 文字列
@param {number} age -- 数値
@param {boolean} active -- 真偽値
@param {Date} createdAt -- Date
@param {any} data -- any
@param {unknown} input -- unknown
複合型:
@param {string | number} id -- ユニオン型
@param {string[]} tags -- 配列
@param {{ name: string }} user -- オブジェクト
@param {?string} name -- nullable
@param {string} [name] -- optional
ジェネリクス:
@template T
@param {T} value
@returns {T}
型定義:
@typedef {Object} User
@property {string} name
@property {number} age
型ガード:
@param {unknown} value
@returns {value is string}
1-4. Phase 3 -- ファイル変換
変換の手順(1ファイルにつき):
1. .js → .ts にリネーム
2. require → import に変換
3. module.exports → export に変換
4. 型エラーを修正(暫定的に as any も許容)
5. テストが通ることを確認
6. コミット
注意: 1ファイルごとにコミットすることで
問題が発生した場合にロールバックが容易
1-5. Phase 4 -- 厳密化
// 段階的に有効化
// Step 1: 暗黙の any を検出
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true
}
}
// Step 2: null チェックを追加
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true
}
}
// Step 3: 関数型の厳密化
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true
}
}
// Step 4: 完全な strict
{
"compilerOptions": {
"strict": true
}
}
// Step 5: 追加の厳密性
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}1-6. Phase 5 -- 完了と品質維持
// 最終的な tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
// allowJs を false に(移行完了)
"allowJs": false,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}2. ファイル変換テクニック
2-1. 変換の優先順位
変換優先順位(依存の葉から始める):
依存グラフ:
index.ts ──→ routes.js ──→ controllers.js ──→ services.js
| |
v v
models.js utils.js ← まずここから
|
v
database.js
変換順序:
1. utils.js → utils.ts (依存なし)
2. models.js → models.ts (utils のみ依存)
3. database.js → database.ts
4. services.js → services.ts
5. controllers.js → controllers.ts
6. routes.js → routes.ts
7. index.js → index.ts
理由:
- 依存の葉から変換すると、型エラーの連鎖が少ない
- 変換済みファイルから型情報が伝播する
- テストが段階的に通りやすい
2-2. 基本変換パターン
// Before: src/user-service.js
const db = require("./database");
class UserService {
constructor(database) {
this.db = database;
}
async getUser(id) {
const user = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!user) return null;
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: new Date(user.created_at),
};
}
async createUser(data) {
return this.db.query(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
[data.name, data.email]
);
}
async getUsersByRole(role) {
const users = await this.db.query(
"SELECT * FROM users WHERE role = $1",
[role]
);
return users.map(u => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
}));
}
}
module.exports = { UserService };
// ─────────────────────────────────────────────
// After: src/user-service.ts
import { Database } from "./database";
// まず型定義を作成
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
interface CreateUserDto {
name: string;
email: string;
}
interface UserRow {
id: string;
name: string;
email: string;
role: string;
created_at: string;
}
type UserRole = "USER" | "ADMIN" | "MODERATOR";
class UserService {
constructor(private readonly db: Database) {}
async getUser(id: string): Promise<User | null> {
const user = await this.db.query<UserRow>(
"SELECT * FROM users WHERE id = $1",
[id]
);
if (!user) return null;
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: new Date(user.created_at),
};
}
async createUser(data: CreateUserDto): Promise<User> {
const row = await this.db.query<UserRow>(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
[data.name, data.email]
);
return {
id: row.id,
name: row.name,
email: row.email,
createdAt: new Date(row.created_at),
};
}
async getUsersByRole(role: UserRole): Promise<User[]> {
const users = await this.db.queryMany<UserRow>(
"SELECT * FROM users WHERE role = $1",
[role]
);
return users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
createdAt: new Date(u.created_at),
}));
}
}
export { UserService, type User, type CreateUserDto, type UserRole };2-3. require → import の変換
// Before (CommonJS)
const express = require("express");
const { UserService } = require("./user-service");
const config = require("./config.json");
const path = require("path");
const fs = require("fs").promises;
// After (ESM)
import express from "express";
import { UserService } from "./user-service.js"; // NodeNext なら拡張子必要
import config from "./config.json" with { type: "json" };
import path from "node:path";
import { readFile, writeFile } from "node:fs/promises";
// ──────────────────────────────────────
// Before (module.exports)
module.exports = { UserService };
module.exports.default = app;
// After (export)
export { UserService };
export default app;
// ──────────────────────────────────────
// Before (条件付き require)
let sharp;
try {
sharp = require("sharp");
} catch {
sharp = null;
}
// After (動的 import)
let sharp: typeof import("sharp") | null;
try {
sharp = await import("sharp");
} catch {
sharp = null;
}
// ──────────────────────────────────────
// Before (require.resolve)
const packagePath = require.resolve("my-package/package.json");
// After (import.meta.resolve)
const packagePath = import.meta.resolve("my-package/package.json");2-4. Express アプリケーションの変換例
// Before: app.js
const express = require("express");
const cors = require("cors");
const { UserService } = require("./services/user-service");
const { authMiddleware } = require("./middleware/auth");
const app = express();
app.use(cors());
app.use(express.json());
app.get("/users", async (req, res) => {
try {
const users = await UserService.findAll();
res.json(users);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get("/users/:id", async (req, res) => {
try {
const user = await UserService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: "Not found" });
}
res.json(user);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post("/users", authMiddleware, async (req, res) => {
try {
const user = await UserService.create(req.body);
res.status(201).json(user);
} catch (err) {
if (err.code === "VALIDATION_ERROR") {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
module.exports = app;
// ─────────────────────────────────────────────
// After: app.ts
import express, { type Request, type Response, type NextFunction } from "express";
import cors from "cors";
import { UserService } from "./services/user-service";
import { authMiddleware } from "./middleware/auth";
import type { User, CreateUserDto } from "./types";
const app = express();
app.use(cors());
app.use(express.json());
// エラーレスポンスの型
interface ErrorResponse {
error: string;
details?: unknown;
}
// 型付きリクエストハンドラ
app.get("/users", async (_req: Request, res: Response<User[] | ErrorResponse>) => {
try {
const users = await UserService.findAll();
res.json(users);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
});
app.get(
"/users/:id",
async (req: Request<{ id: string }>, res: Response<User | ErrorResponse>) => {
try {
const user = await UserService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: "Not found" });
}
res.json(user);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
}
);
app.post(
"/users",
authMiddleware,
async (
req: Request<unknown, unknown, CreateUserDto>,
res: Response<User | ErrorResponse>
) => {
try {
const user = await UserService.create(req.body);
res.status(201).json(user);
} catch (err: unknown) {
if (err instanceof Error && "code" in err && err.code === "VALIDATION_ERROR") {
return res.status(400).json({ error: err.message });
}
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
}
);
export default app;2-5. React コンポーネントの変換
// Before: UserCard.jsx
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
function UserCard({ user, onEdit, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(user.name);
useEffect(() => {
setName(user.name);
}, [user.name]);
const handleSave = () => {
onEdit(user.id, { name });
setIsEditing(false);
};
return (
<div className="user-card">
{isEditing ? (
<input value={name} onChange={(e) => setName(e.target.value)} />
) : (
<span>{user.name}</span>
)}
<span>{user.email}</span>
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Cancel" : "Edit"}
</button>
{isEditing && <button onClick={handleSave}>Save</button>}
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
);
}
UserCard.propTypes = {
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
}).isRequired,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default UserCard;
// ─────────────────────────────────────────────
// After: UserCard.tsx
import { useState, useEffect, type FC } from "react";
interface User {
id: string;
name: string;
email: string;
}
interface UserCardProps {
user: User;
onEdit: (id: string, data: { name: string }) => void;
onDelete: (id: string) => void;
}
const UserCard: FC<UserCardProps> = ({ user, onEdit, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(user.name);
useEffect(() => {
setName(user.name);
}, [user.name]);
const handleSave = () => {
onEdit(user.id, { name });
setIsEditing(false);
};
return (
<div className="user-card">
{isEditing ? (
<input value={name} onChange={(e) => setName(e.target.value)} />
) : (
<span>{user.name}</span>
)}
<span>{user.email}</span>
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Cancel" : "Edit"}
</button>
{isEditing && <button onClick={handleSave}>Save</button>}
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
);
};
export default UserCard;
// PropTypes は不要に → npm uninstall prop-types3. any の段階的排除
3-1. any のトリアージ
any 排除の優先順位:
+------------------+----------+-----------+-------------------+
| カテゴリ | 危険度 | 対応 | 推定作業量 |
+------------------+----------+-----------+-------------------+
| API レスポンス | 高 | zod 導入 | 中 |
| 関数パラメータ | 高 | 型定義 | 小 |
| イベントハンドラ | 中 | 型定義 | 小 |
| catch 変数 | 中 | unknown | 小 |
| JSON.parse 結果 | 中 | zod | 中 |
| サードパーティ | 低 | @types/* | 小〜中 |
| 一時的な TODO | 低 | コメント | 将来 |
+------------------+----------+-----------+-------------------+
// Step 1: 明示的な any を unknown に置き換え
// Before
function processData(data: any): any {
return data.map((item: any) => item.name);
}
// After
function processData(data: unknown): string[] {
if (!Array.isArray(data)) {
throw new Error("Expected array");
}
return data.map((item: unknown) => {
if (typeof item === "object" && item !== null && "name" in item) {
return String((item as { name: unknown }).name);
}
throw new Error("Invalid item");
});
}
// Step 2: zod でバリデーション
import { z } from "zod";
const ItemSchema = z.object({ name: z.string() });
const DataSchema = z.array(ItemSchema);
function processData(data: unknown): string[] {
const parsed = DataSchema.parse(data);
return parsed.map((item) => item.name);
}
// Step 3: JSON.parse の安全な処理
// Before
function parseConfig(json: string): any {
return JSON.parse(json);
}
// After
const ConfigSchema = z.object({
apiUrl: z.string().url(),
port: z.number().int().positive(),
debug: z.boolean().default(false),
});
type Config = z.infer<typeof ConfigSchema>;
function parseConfig(json: string): Config {
const raw: unknown = JSON.parse(json);
return ConfigSchema.parse(raw);
}3-2. @types/* の導入
# 型定義パッケージを検索・インストール
npm install -D @types/express @types/lodash @types/cors @types/compression
# 型定義が存在するか確認
npm info @types/some-package
# 複数パッケージを一括インストール
npm install -D @types/express @types/cors @types/morgan @types/cookie-parser// src/types/untyped-module.d.ts
// 型定義がないサードパーティモジュール用
// 最小限の型定義(一時的、段階的に充実させる)
declare module "untyped-lib" {
export function doSomething(input: string): Promise<unknown>;
export interface Config {
apiKey: string;
timeout?: number;
}
}
// 型定義を段階的に充実させる
declare module "legacy-lib" {
export interface Options {
format: "json" | "csv" | "xml";
encoding?: BufferEncoding;
strict?: boolean;
}
export function parse(input: string, options?: Options): Record<string, unknown>;
export function stringify(data: Record<string, unknown>, options?: Options): string;
export class Parser {
constructor(options?: Options);
parse(input: string): Record<string, unknown>;
on(event: "data", handler: (chunk: unknown) => void): this;
on(event: "error", handler: (error: Error) => void): this;
on(event: "end", handler: () => void): this;
}
export default Parser;
}
// CSS / 画像モジュールの型定義(Vite / webpack 用)
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.svg" {
import type { FC, SVGProps } from "react";
const component: FC<SVGProps<SVGSVGElement>>;
export default component;
}
declare module "*.png" {
const url: string;
export default url;
}
// グローバル型の拡張
declare global {
interface Window {
__APP_CONFIG__: {
apiUrl: string;
version: string;
};
}
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
API_KEY: string;
PORT?: string;
}
}
}
export {}; // モジュールとして認識させるために必要3-3. any を使わない型安全なユーティリティ
// any を使わずに柔軟な型を定義するパターン
// 1. unknown + 型ガード
function isError(value: unknown): value is Error {
return value instanceof Error;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getErrorMessage(error: unknown): string {
if (isError(error)) return error.message;
if (typeof error === "string") return error;
return "Unknown error";
}
// 2. ジェネリクスで柔軟性を確保
function safeJsonParse<T>(json: string, schema: z.ZodType<T>): T | null {
try {
const raw: unknown = JSON.parse(json);
return schema.parse(raw);
} catch {
return null;
}
}
// 3. satisfies でオブジェクトの型チェック
const routes = {
home: "/",
about: "/about",
user: "/users/:id",
} satisfies Record<string, string>;
// 4. Record<string, unknown> で動的オブジェクト
function filterObject(
obj: Record<string, unknown>,
predicate: (key: string, value: unknown) => boolean
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(obj).filter(([key, value]) => predicate(key, value))
);
}4. strict 化のロードマップ
4-1. noImplicitAny 対応パターン
// エラー: Parameter 'x' implicitly has an 'any' type
// 対応パターン集
// 1. コールバック引数
// Before
array.forEach(function (item) { /* ... */ });
// After
array.forEach(function (item: ItemType) { /* ... */ });
// もしくはアロー関数(型推論が効く場合)
array.forEach((item) => { /* ... */ });
// 2. オブジェクトの動的アクセス
// Before
function getValue(obj, key) { return obj[key]; }
// After
function getValue<T extends Record<string, unknown>>(
obj: T,
key: keyof T
): T[keyof T] {
return obj[key];
}
// 3. イベントハンドラ
// Before
element.addEventListener("click", function (e) { /* ... */ });
// After(DOM の型定義から自動推論)
element.addEventListener("click", (e: MouseEvent) => { /* ... */ });
// 4. デストラクチャリング
// Before
function processResponse({ data, status }) { /* ... */ }
// After
interface ApiResponse {
data: unknown;
status: number;
}
function processResponse({ data, status }: ApiResponse) { /* ... */ }
// 5. 関数のオーバーロード
// Before
function format(value) {
if (typeof value === "number") return value.toFixed(2);
if (typeof value === "string") return value.trim();
return String(value);
}
// After
function format(value: number): string;
function format(value: string): string;
function format(value: unknown): string;
function format(value: unknown): string {
if (typeof value === "number") return value.toFixed(2);
if (typeof value === "string") return value.trim();
return String(value);
}
// 6. reduce のアキュムレータ
// Before
const total = items.reduce((sum, item) => sum + item.price, 0);
// After
const total = items.reduce<number>((sum, item) => sum + item.price, 0);
// もしくは初期値から推論される場合はそのまま4-2. strictNullChecks 対応パターン
// エラー: Object is possibly 'null'
// 対応パターン集
// 1. 早期リターン(Guard Clause)
function getUser(id: string): User | null {
const user = findById(id);
if (!user) return null; // または throw
// ここ以降 user は User 型
return user;
}
// 2. Optional Chaining + Nullish Coalescing
const name = user?.name ?? "Unknown";
const city = user?.address?.city ?? "N/A";
// 3. 非 null アサーション(確実な場合のみ)
const element = document.getElementById("app")!;
// ↑ element が null の可能性がある場合は使わない
// 4. Map / Set の型安全なアクセス
const map = new Map<string, User>();
const user = map.get("key"); // User | undefined
if (user) {
console.log(user.name); // OK
}
// 5. 配列の find
const found = users.find((u) => u.id === targetId);
if (!found) {
throw new Error(`User ${targetId} not found`);
}
// found は User 型
// 6. Promise の結果
async function fetchUser(id: string): Promise<User | null> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return null;
return response.json() as Promise<User>;
}5. 大規模プロジェクトでの移行戦略
5-1. モジュール境界での移行
大規模プロジェクトでは、モジュール境界で区切って移行する:
+------------------+ +------------------+ +------------------+
| Authentication | | User Management | | Order System |
| (TypeScript化済) | --> | (移行中) | --> | (JavaScript) |
+------------------+ +------------------+ +------------------+
| | |
v v v
+------------------+ +------------------+ +------------------+
| 型定義ファイル | | 部分的に型付き | | .d.ts で橋渡し |
| 完全に型安全 | | allowJs + checkJs | | 将来移行 |
+------------------+ +------------------+ +------------------+
ポイント:
1. ドメイン単位でモジュールを分割
2. モジュール間のインターフェースに .d.ts を定義
3. 高リスク/高頻度変更のモジュールから優先移行
4. 新機能は常に TypeScript で開発
5-2. any カウンターの導入
// scripts/count-any.ts -- any の数をカウントするスクリプト
import { Project } from "ts-morph";
const project = new Project({
tsConfigFilePath: "./tsconfig.json",
});
let totalAny = 0;
const fileStats: { file: string; count: number }[] = [];
for (const sourceFile of project.getSourceFiles()) {
const filePath = sourceFile.getFilePath();
const text = sourceFile.getFullText();
// 明示的な any をカウント
const anyCount = (text.match(/:\s*any\b/g) || []).length;
// as any をカウント
const asAnyCount = (text.match(/as\s+any\b/g) || []).length;
const total = anyCount + asAnyCount;
if (total > 0) {
fileStats.push({ file: filePath, count: total });
totalAny += total;
}
}
console.log(`Total any count: ${totalAny}`);
console.log("\nTop 10 files with most 'any':");
fileStats
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.forEach(({ file, count }) => {
console.log(` ${count} any: ${file}`);
});// package.json
{
"scripts": {
"count-any": "tsx scripts/count-any.ts",
"migration-status": "tsx scripts/migration-status.ts"
}
}5-3. 移行進捗の可視化
// scripts/migration-status.ts
import * as fs from "fs";
import * as path from "path";
function countFiles(dir: string): { js: number; ts: number; jsx: number; tsx: number } {
const result = { js: 0, ts: 0, jsx: 0, tsx: 0 };
function walk(currentDir: string) {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
walk(path.join(currentDir, entry.name));
} else {
const ext = path.extname(entry.name);
if (ext === ".js") result.js++;
if (ext === ".ts" && !entry.name.endsWith(".d.ts")) result.ts++;
if (ext === ".jsx") result.jsx++;
if (ext === ".tsx") result.tsx++;
}
}
}
walk(dir);
return result;
}
const counts = countFiles("./src");
const total = counts.js + counts.ts + counts.jsx + counts.tsx;
const tsTotal = counts.ts + counts.tsx;
const jsTotal = counts.js + counts.jsx;
const percentage = ((tsTotal / total) * 100).toFixed(1);
console.log("=== Migration Status ===");
console.log(`TypeScript files: ${tsTotal} (${percentage}%)`);
console.log(`JavaScript files: ${jsTotal} (${(100 - Number(percentage)).toFixed(1)}%)`);
console.log(` .ts: ${counts.ts}`);
console.log(` .tsx: ${counts.tsx}`);
console.log(` .js: ${counts.js}`);
console.log(` .jsx: ${counts.jsx}`);
console.log(`Total: ${total}`);
console.log(`\nProgress: [${"█".repeat(Math.floor(Number(percentage) / 5))}${"░".repeat(20 - Math.floor(Number(percentage) / 5))}] ${percentage}%`);6. よくある移行パターン
6-1. コールバック地獄から async/await へ
// Before: コールバック (JavaScript)
function getUser(id, callback) {
db.query("SELECT * FROM users WHERE id = ?", [id], (err, rows) => {
if (err) return callback(err);
if (rows.length === 0) return callback(new Error("Not found"));
callback(null, rows[0]);
});
}
// After: async/await (TypeScript)
async function getUser(id: string): Promise<User> {
const rows = await db.query<UserRow[]>(
"SELECT * FROM users WHERE id = ?",
[id]
);
if (rows.length === 0) {
throw new NotFoundError(`User ${id} not found`);
}
return mapUserRow(rows[0]);
}6-2. 設定オブジェクトの型定義
// Before: 動的な設定オブジェクト
const config = {
database: {
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
name: process.env.DB_NAME || "myapp",
},
redis: {
url: process.env.REDIS_URL || "redis://localhost:6379",
},
server: {
port: parseInt(process.env.PORT || "3000"),
cors: {
origin: process.env.CORS_ORIGIN || "*",
},
},
};
// After: zod による型安全な設定
import { z } from "zod";
const ConfigSchema = z.object({
database: z.object({
host: z.string().default("localhost"),
port: z.coerce.number().int().positive().default(5432),
name: z.string().default("myapp"),
ssl: z.boolean().default(false),
}),
redis: z.object({
url: z.string().url().default("redis://localhost:6379"),
}),
server: z.object({
port: z.coerce.number().int().positive().default(3000),
cors: z.object({
origin: z.string().default("*"),
}),
}),
});
type Config = z.infer<typeof ConfigSchema>;
export const config: Config = ConfigSchema.parse({
database: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
name: process.env.DB_NAME,
},
redis: {
url: process.env.REDIS_URL,
},
server: {
port: process.env.PORT,
cors: {
origin: process.env.CORS_ORIGIN,
},
},
});比較表
移行アプローチの比較
| アプローチ | 期間 | リスク | チーム影響 | 推奨プロジェクト規模 |
|---|---|---|---|---|
| ビッグバン(全ファイル一括変換) | 短 | 高 | 大 | 小(~50ファイル) |
| 段階的移行(ファイル単位) | 長 | 低 | 小 | 中〜大 |
| 新機能のみ TS | 最長 | 最低 | 最小 | 大(レガシー) |
| 別ブランチで並行 | 中 | 中 | 中 | 中 |
| モジュール境界で分割 | 中〜長 | 低 | 小 | 大(マイクロサービス) |
any 排除ツールの比較
| ツール | 用途 | 自動修正 | 精度 |
|---|---|---|---|
tsc --strict |
暗黙 any 検出 | なし | 高 |
@typescript-eslint/no-explicit-any |
明示 any 検出 | なし | 高 |
ts-prune |
未使用 export 検出 | なし | 中 |
| zod | ランタイム型検証 | 型推論 | 最高 |
| TypeStat | 自動型追加 | あり | 中 |
| ts-morph | プログラム的な型操作 | あり | 高 |
アンチパターン
AP-1: 全ファイルを一括変換
# NG: 一括で .js → .ts にリネーム
find src -name "*.js" -exec bash -c 'mv "$0" "${0%.js}.ts"' {} \;
# → 数百のコンパイルエラーが一度に発生し、対応不能に
# OK: 1ファイルずつ変換、各変換後にテスト
# 1. utils.js → utils.ts (型エラー修正、テスト実行)
# 2. models.js → models.ts (型エラー修正、テスト実行)
# 3. ...(1日 3-5 ファイルのペースで)AP-2: as any でエラーを黙らせる
// NG: as any で型エラーを握りつぶす
const result = someFunction(data as any) as any;
// OK: TODO コメント付きで一時的に許容
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = someFunction(data as any); // TODO: #123 で型を修正
// さらに OK: 正しい型を定義
interface InputData {
name: string;
values: number[];
}
const result = someFunction(data as InputData);AP-3: 型定義ファイルを書きすぎる
// NG: 全てのサードパーティモジュールに詳細な .d.ts を書く
// → 保守コストが膨大
// OK: 段階的なアプローチ
// Step 1: 最小限の型定義
declare module "old-lib" {
export function process(data: unknown): unknown;
}
// Step 2: 使用頻度の高い関数のみ型を充実
declare module "old-lib" {
export function process<T>(data: T): ProcessResult<T>;
export interface ProcessResult<T> {
data: T;
metadata: Record<string, string>;
}
}
// Step 3: DefinitelyTyped に PR を出すことも検討AP-4: 移行中にリファクタリングも同時進行
NG:
.js → .ts 変換 + ロジック変更 + リファクタリング
→ 変更が多すぎてレビューが困難
→ バグが入り込んでも原因特定が困難
→ テストが壊れた時に何が原因かわからない
OK:
Step 1: .js → .ts(型のみ追加、ロジック変更なし)
Step 2: テスト確認、コミット
Step 3: リファクタリング(別のコミットで)
移行チェックリスト
Phase 1 準備:
[ ] TypeScript インストール
[ ] tsconfig.json 作成 (allowJs: true, strict: false)
[ ] ビルドパイプラインに tsc --noEmit 追加
[ ] @types/* パッケージインストール
[ ] ESLint を typescript-eslint に設定
[ ] CI で型チェックを実行
Phase 2 共存:
[ ] checkJs: true 有効化
[ ] JSDoc で主要関数に型アノテーション
[ ] 共通の型定義ファイル (types/) 作成
[ ] 型なしサードパーティの .d.ts 作成
[ ] エディタの TypeScript 設定確認
Phase 3 変換:
[ ] ユーティリティファイルから .ts 変換開始
[ ] require → import 変換
[ ] module.exports → export 変換
[ ] any を具体的な型に置換
[ ] テスト実行・CI で型チェック
[ ] 1ファイルずつコミット
Phase 4 厳密化:
[ ] noImplicitAny: true
[ ] strictNullChecks: true
[ ] strictFunctionTypes: true
[ ] strict: true
[ ] noUncheckedIndexedAccess: true
Phase 5 完了:
[ ] 全ファイル .ts 化
[ ] allowJs: false
[ ] checkJs 削除
[ ] JSDoc 型アノテーション除去
[ ] prop-types 削除(React)
[ ] CI で strict ビルドを必須に
[ ] any カウントが 0 であることを確認
[ ] チームの TypeScript コーディングガイドライン作成
実践演習
演習1: 基本的な実装
以下の要件を満たすコードを実装してください。
要件:
- 入力データの検証を行うこと
- エラーハンドリングを適切に実装すること
- テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
"""基本的な実装パターンの演習"""
def __init__(self):
self.data = []
def validate_input(self, value):
"""入力値の検証"""
if value is None:
raise ValueError("入力値がNoneです")
return True
def process(self, value):
"""データ処理のメインロジック"""
self.validate_input(value)
self.data.append(value)
return self.data
def get_results(self):
"""処理結果の取得"""
return {
'count': len(self.data),
'data': self.data
}
# テスト
def test_exercise1():
ex = Exercise1()
assert ex.process(1) == [1]
assert ex.process(2) == [1, 2]
assert ex.get_results()['count'] == 2
try:
ex.process(None)
assert False, "例外が発生するべき"
except ValueError:
pass
print("全テスト合格!")
test_exercise1()演習2: 応用パターン
基本実装を拡張して、以下の機能を追加してください。
# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
class AdvancedExercise:
"""応用パターンの演習"""
def __init__(self, max_size: int = 100):
self._items: List[Dict] = []
self._max_size = max_size
self._created_at = datetime.now()
def add(self, key: str, value: any) -> bool:
"""アイテムの追加(サイズ制限付き)"""
if len(self._items) >= self._max_size:
return False
self._items.append({
'key': key,
'value': value,
'timestamp': datetime.now().isoformat()
})
return True
def find(self, key: str) -> Optional[Dict]:
"""キーによる検索"""
for item in reversed(self._items):
if item['key'] == key:
return item
return None
def remove(self, key: str) -> bool:
"""キーによる削除"""
for i, item in enumerate(self._items):
if item['key'] == key:
self._items.pop(i)
return True
return False
def stats(self) -> Dict:
"""統計情報"""
return {
'total_items': len(self._items),
'max_size': self._max_size,
'usage_percent': len(self._items) / self._max_size * 100,
'uptime': str(datetime.now() - self._created_at)
}
# テスト
def test_advanced():
ex = AdvancedExercise(max_size=3)
assert ex.add("a", 1) == True
assert ex.add("b", 2) == True
assert ex.add("c", 3) == True
assert ex.add("d", 4) == False # サイズ制限
assert ex.find("b")['value'] == 2
assert ex.remove("b") == True
assert ex.find("b") is None
stats = ex.stats()
assert stats['total_items'] == 2
print("応用テスト全合格!")
test_advanced()演習3: パフォーマンス最適化
以下のコードのパフォーマンスを改善してください。
# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
"""非効率な検索"""
for i in range(len(data)):
for j in range(i + 1, len(data)):
if data[i] + data[j] == target:
return (i, j)
return (-1, -1)
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
"""ハッシュマップを使った効率的な検索"""
seen = {}
for i, num in enumerate(data):
complement = target - num
if complement in seen:
return (seen[complement], i)
seen[num] = i
return (-1, -1)
# ベンチマーク
def benchmark():
import random
data = list(range(5000))
random.shuffle(data)
target = data[100] + data[4000]
start = time.time()
result1 = slow_search(data, target)
slow_time = time.time() - start
start = time.time()
result2 = fast_search(data, target)
fast_time = time.time() - start
print(f"非効率版: {slow_time:.4f}秒")
print(f"効率版: {fast_time:.6f}秒")
print(f"高速化率: {slow_time/fast_time:.0f}倍")
benchmark()ポイント:
- アルゴリズムの計算量を意識する
- 適切なデータ構造を選択する
- ベンチマークで効果を測定する
トラブルシューティング
よくあるエラーと解決策
| エラー | 原因 | 解決策 |
|---|---|---|
| 初期化エラー | 設定ファイルの不備 | 設定ファイルのパスと形式を確認 |
| タイムアウト | ネットワーク遅延/リソース不足 | タイムアウト値の調整、リトライ処理の追加 |
| メモリ不足 | データ量の増大 | バッチ処理の導入、ページネーションの実装 |
| 権限エラー | アクセス権限の不足 | 実行ユーザーの権限確認、設定の見直し |
| データ不整合 | 並行処理の競合 | ロック機構の導入、トランザクション管理 |
デバッグの手順
- エラーメッセージの確認: スタックトレースを読み、発生箇所を特定する
- 再現手順の確立: 最小限のコードでエラーを再現する
- 仮説の立案: 考えられる原因をリストアップする
- 段階的な検証: ログ出力やデバッガを使って仮説を検証する
- 修正と回帰テスト: 修正後、関連する箇所のテストも実行する
# デバッグ用ユーティリティ
import logging
import traceback
from functools import wraps
# ロガーの設定
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
def debug_decorator(func):
"""関数の入出力をログ出力するデコレータ"""
@wraps(func)
def wrapper(*args, **kwargs):
logger.debug(f"呼び出し: {func.__name__}(args={args}, kwargs={kwargs})")
try:
result = func(*args, **kwargs)
logger.debug(f"戻り値: {func.__name__} -> {result}")
return result
except Exception as e:
logger.error(f"例外発生: {func.__name__}: {e}")
logger.error(traceback.format_exc())
raise
return wrapper
@debug_decorator
def process_data(items):
"""データ処理(デバッグ対象)"""
if not items:
raise ValueError("空のデータ")
return [item * 2 for item in items]パフォーマンス問題の診断
パフォーマンス問題が発生した場合の診断手順:
- ボトルネックの特定: プロファイリングツールで計測
- メモリ使用量の確認: メモリリークの有無をチェック
- I/O待ちの確認: ディスクやネットワークI/Oの状況を確認
- 同時接続数の確認: コネクションプールの状態を確認
| 問題の種類 | 診断ツール | 対策 |
|---|---|---|
| CPU負荷 | cProfile, py-spy | アルゴリズム改善、並列化 |
| メモリリーク | tracemalloc, objgraph | 参照の適切な解放 |
| I/Oボトルネック | strace, iostat | 非同期I/O、キャッシュ |
| DB遅延 | EXPLAIN, slow query log | インデックス、クエリ最適化 |
設計判断ガイド
選択基準マトリクス
技術選択を行う際の判断基準を以下にまとめます。
| 判断基準 | 重視する場合 | 妥協できる場合 |
|---|---|---|
| パフォーマンス | リアルタイム処理、大規模データ | 管理画面、バッチ処理 |
| 保守性 | 長期運用、チーム開発 | プロトタイプ、短期プロジェクト |
| スケーラビリティ | 成長が見込まれるサービス | 社内ツール、固定ユーザー |
| セキュリティ | 個人情報、金融データ | 公開データ、社内利用 |
| 開発速度 | MVP、市場投入スピード | 品質重視、ミッションクリティカル |
アーキテクチャパターンの選択
| アーキテクチャ選択フロー |
|---|
| ① チーム規模は? |
| ├─ 小規模(1-5人)→ モノリス |
| └─ 大規模(10人+)→ ②へ |
| ② デプロイ頻度は? |
| ├─ 週1回以下 → モノリス + モジュール分割 |
| └─ 毎日/複数回 → ③へ |
| ③ チーム間の独立性は? |
| ├─ 高い → マイクロサービス |
| └─ 中程度 → モジュラーモノリス |
トレードオフの分析
技術的な判断には必ずトレードオフが伴います。以下の観点で分析を行いましょう:
1. 短期 vs 長期のコスト
- 短期的に速い方法が長期的には技術的負債になることがある
- 逆に、過剰な設計は短期的なコストが高く、プロジェクトの遅延を招く
2. 一貫性 vs 柔軟性
- 統一された技術スタックは学習コストが低い
- 多様な技術の採用は適材適所が可能だが、運用コストが増加
3. 抽象化のレベル
- 高い抽象化は再利用性が高いが、デバッグが困難になる場合がある
- 低い抽象化は直感的だが、コードの重複が発生しやすい
# 設計判断の記録テンプレート
class ArchitectureDecisionRecord:
"""ADR (Architecture Decision Record) の作成"""
def __init__(self, title: str):
self.title = title
self.context = ""
self.decision = ""
self.consequences = []
self.alternatives = []
def set_context(self, context: str):
"""背景と課題の記述"""
self.context = context
return self
def set_decision(self, decision: str):
"""決定内容の記述"""
self.decision = decision
return self
def add_consequence(self, consequence: str, positive: bool = True):
"""結果の追加"""
self.consequences.append({
'description': consequence,
'type': 'positive' if positive else 'negative'
})
return self
def add_alternative(self, name: str, reason_rejected: str):
"""却下した代替案の追加"""
self.alternatives.append({
'name': name,
'reason_rejected': reason_rejected
})
return self
def to_markdown(self) -> str:
"""Markdown形式で出力"""
md = f"# ADR: {self.title}\n\n"
md += f"## 背景\n{self.context}\n\n"
md += f"## 決定\n{self.decision}\n\n"
md += "## 結果\n"
for c in self.consequences:
icon = "✅" if c['type'] == 'positive' else "⚠️"
md += f"- {icon} {c['description']}\n"
md += "\n## 却下した代替案\n"
for a in self.alternatives:
md += f"- **{a['name']}**: {a['reason_rejected']}\n"
return mdFAQ
Q1: 移行にどのくらいの期間がかかりますか?
規模によります。1万行程度なら 1〜2 週間、10 万行なら 1〜3 ヶ月が目安です。重要なのは「完全な移行」を待たずに、Phase 2(共存)の時点で既に型チェックの恩恵を受けられることです。移行は「終わり」がある作業ではなく、型の品質を継続的に改善するプロセスです。
Q2: 既存のテストは動き続けますか?
はい。allowJs: true の状態では既存の .js ファイルはそのまま動きます。ファイルを .ts に変換しても、テストランナーが TypeScript をサポートしていれば(Vitest, Jest + ts-jest など)テストは継続して動作します。
Q3: チームメンバーが TypeScript を知らない場合はどうすべきですか?
Phase 2(JSDoc 型アノテーション)から始めることで、TypeScript の構文を学ばずに型の恩恵を受けられます。並行して TypeScript の基本を学ぶ学習時間を確保し、新機能の開発は TypeScript で行う方針にすると自然に習熟していきます。ペアプログラミングで知識を共有することも効果的です。
Q4: 移行中に新機能の開発はどうすべきですか?
新機能は最初から TypeScript で開発してください。新しいファイルは .ts で作成し、strict: true の設定で書きます。既存の JS ファイルとの連携は JSDoc や .d.ts ファイルで橋渡しします。これにより「新しいコードは常に型安全」という基準を維持できます。
Q5: monorepo の場合、どのパッケージから移行すべきですか?
- 共有ライブラリ(shared パッケージ)から始める -- 他のパッケージに型情報が伝播する
- 次にバックエンド(型が最も重要な箇所)
- 最後にフロントエンド(PropTypes → TypeScript 型への移行が必要)
まとめ表
| 概念 | 要点 |
|---|---|
| 段階的移行 | ファイル単位で変換、依存の葉から開始 |
| allowJs | JS と TS の共存を可能にする設定 |
| checkJs | JS ファイルの型チェックを有効化 |
| JSDoc 型 | .ts 変換前に JSDoc で型を追加 |
| any 排除 | unknown + zod で段階的に型安全化 |
| strict 化 | noImplicitAny → strictNullChecks → strict |
| 移行進捗 | スクリプトで .ts/.js の比率を可視化 |
| 新機能は TS | 移行中でも新しいコードは TypeScript で |
まとめ
このガイドでは以下の重要なポイントを学びました:
- 基本概念と原則の理解
- 実践的な実装パターン
- ベストプラクティスと注意点
- 実務での活用方法
次に読むべきガイド
- tsconfig.json -- 移行各フェーズの推奨設定詳細
- ESLint + TypeScript -- 移行中の lint ルール設定
- Zod バリデーション -- any 排除の強力なツール
参考文献
-
TypeScript - Migrating from JavaScript https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html
-
Total TypeScript - Migrating to TypeScript https://www.totaltypescript.com/tutorials/migrating-to-typescript
-
Airbnb の TypeScript 移行記 -- Brie Bunge, JSConf 2019 大規模 JS→TS 移行の実践レポート
-
ts-morph -- TypeScript コードのプログラム的な操作 https://github.com/dsherret/ts-morph