Node.js + TypeScript でサーバーサイド/CLI/ライブラリを開発する際の実践的な指針をまとめたものです。


目次

  1. ランタイムとツールチェーン
  2. プロジェクト構成
  3. tsconfig の設定
  4. 型設計
  5. 非同期処理
  6. エラーハンドリング
  7. 設定と環境変数
  8. 入力バリデーション
  9. ロギングと可観測性
  10. セキュリティ
  11. パフォーマンス
  12. テスト
  13. Lint / フォーマット
  14. 依存関係の管理
  15. ビルドとデプロイ
  16. チェックリスト

1. ランタイムとツールチェーン

  • Node.js は LTS(偶数バージョン)を使う。 本番環境で奇数バージョン(Current)は避ける。

  • バージョンは .nvmrc / .node-version と package.json の engines で固定し、チーム・CI・本番で揃える。

    {
      "engines": { "node": ">=22" },
      "packageManager": "pnpm@9.0.0"
    }
  • パッケージマネージャは1つに統一し、ロックファイル(package-lock.json / pnpm-lock.yaml / yarn.lock)を必ずコミットする。Corepack で packageManager を強制すると混在を防げる。

  • ESM("type": "module")を基本とする。新規プロジェクトで CommonJS を選ぶ理由はほぼない。

  • 開発時の実行は tsx や Node.js の型ストリッピング機能(node --experimental-strip-types 等、バージョンにより既定で有効)が手軽。ただし型チェックは別途 tsc --noEmit で行う(これらは型を消すだけで検査しない)。

2. プロジェクト構成

レイヤーで分けるより、機能(ドメイン)単位でまとめると変更範囲が局所化しやすい。

src/
├── modules/
│   ├── user/
│   │   ├── user.controller.ts   # HTTP 層(入出力の変換のみ)
│   │   ├── user.service.ts      # ビジネスロジック
│   │   ├── user.repository.ts   # 永続化
│   │   ├── user.schema.ts       # バリデーションスキーマ・型
│   │   └── user.test.ts
│   └── order/
├── shared/
│   ├── errors.ts
│   ├── logger.ts
│   └── config.ts
├── app.ts                        # アプリ組み立て(DI・ミドルウェア)
└── server.ts                     # エントリーポイント(listen のみ)

ポイント:

  • app.ts と server.ts を分離する。テストではサーバーを listen せずに app を直接使える。
  • ビジネスロジックをフレームワーク(Express / Fastify / Hono 等)に依存させない。
  • 循環依存を避ける(madge や ESLint の import/no-cycle で検出可能)。
  • バレルファイル(index.ts での一括 re-export)の多用は、循環依存やツリーシェイク・起動速度の悪化を招くので控えめに。

3. tsconfig の設定

推奨のベース設定(Node.js 向け ESM):

{
  "compilerOptions": {
    // 出力・モジュール
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true,
    "declaration": true,          // ライブラリの場合
 
    // 厳格性(ここが最重要)
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
 
    // 相互運用・安全性
    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}
  • strict: true は必須。 既存プロジェクトでも段階的に有効化する。
  • noUncheckedIndexedAccess で arr[i] や record[key] が T | undefined になり、実行時エラーを大幅に減らせる。
  • module: NodeNext では相対 import に拡張子 .js が必要(import { x } from "./foo.js")。
  • verbatimModuleSyntax を有効にし、型のみの import は import type で書く。
  • 共通設定は @tsconfig/node22 などの公式ベースを extends するのも手。

4. 型設計

any を使わない

  • any の代わりに unknown を使い、型ガードで絞り込む。
  • 外部から来るデータ(JSON、リクエスト、環境変数、DB 結果)はすべて unknown 扱いとし、バリデーションを通して型を付ける。
// ❌
const data: any = JSON.parse(body);
 
// ✅
const data: unknown = JSON.parse(body);
const user = UserSchema.parse(data); // 型安全な User になる

型アサーション(as)・非 null アサーション(!)を避ける

  • as はコンパイラを黙らせるだけで実行時の保証がない。型ガードやスキーマ検証を使う。
  • どうしても必要な箇所はコメントで理由を残す。

判別可能なユニオン(Discriminated Union)を活用する

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
type Event =
  | { type: "created"; id: string }
  | { type: "deleted"; id: string; reason: string };
 
function handle(e: Event) {
  switch (e.type) {
    case "created": /* ... */ break;
    case "deleted": /* ... */ break;
    default: {
      const _exhaustive: never = e; // 網羅性チェック
      throw new Error(`Unknown event: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

その他

  • satisfies 演算子で、推論を保ったまま型適合を確認する。
    const routes = { home: "/", user: "/users/:id" } satisfies Record<string, string>;
  • ブランド型で ID の取り違えを防ぐ。
    type UserId = string & { readonly __brand: "UserId" };
    type OrderId = string & { readonly __brand: "OrderId" };
  • enum より as const オブジェクト + ユニオン型を推奨(型ストリッピングとも相性が良い)。
    export const Role = { Admin: "admin", Member: "member" } as const;
    export type Role = (typeof Role)[keyof typeof Role];
  • 不変データには readonly / Readonly<T> / ReadonlyArray<T> を付ける。
  • 関数の公開 API(export する関数)には戻り値の型を明示する。意図しない型変化を防ぎ、型チェックも速くなる。

5. 非同期処理

  • コールバックではなく async/await を使う。コールバック API は node:util の promisify や fs/promises 等の Promise 版を使う。

  • Promise を放置しない(floating promise 禁止)。 await するか、意図的に投げっぱなしにする場合は void を付けてエラー処理を明示する。ESLint の @typescript-eslint/no-floating-promises で検出。

  • 独立した処理は並列化する。

    // ❌ 直列(遅い)
    const user = await getUser(id);
    const orders = await getOrders(id);
     
    // ✅ 並列
    const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
  • 一部の失敗を許容する場合は Promise.allSettled を使う。

  • 大量の並列処理は同時実行数を制限する(p-limit 等)。無制限の Promise.all は接続枯渇やメモリ不足の原因になる。

  • タイムアウトとキャンセルには AbortController / AbortSignal.timeout() を使う。

    const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
  • ループ内の await は直列化の意図がある場合のみ。forEach に async 関数を渡さない(待たれない)。

  • イベントループをブロックしない。 重い CPU 処理(画像処理、大きな JSON の処理、暗号計算など)は worker_threads に逃がす。同期 API(fs.readFileSync 等)は起動時以外で使わない。

6. エラーハンドリング

カスタムエラークラスを定義する

export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode = 500,
    public readonly isOperational = true,
    options?: ErrorOptions,
  ) {
    super(message, options);
    this.name = new.target.name;
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource: string, options?: ErrorOptions) {
    super(`${resource} not found`, "NOT_FOUND", 404, true, options);
  }
}

原則

  • throw するのは Error(またはそのサブクラス)のみ。 文字列やオブジェクトを throw しない(スタックトレースが失われる)。
  • cause で原因を連鎖させ、元のエラー情報を失わない。
    try { await db.query(sql); }
    catch (err) { throw new AppError("Failed to load user", "DB_ERROR", 500, true, { cause: err }); }
  • catch (err) の err は unknown。instanceof で絞り込んでから使う(useUnknownInCatchVariables は strict に含まれる)。
  • 運用エラー(想定内:入力不正、外部 API 失敗など) とプログラマーエラー(バグ) を区別する。後者は無理に回復せず、ログを出してプロセスを再起動させる方が安全。
  • エラーは握りつぶさない。空の catch {} は禁止。
  • エラー処理は1か所(HTTP ならエラーハンドリングミドルウェア)に集約し、クライアントには内部情報(スタックトレース、SQL など)を返さない。
  • 想定内の失敗が頻繁な処理では、例外の代わりに Result 型を返す設計も有効。

プロセスレベルのハンドリング

process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "Unhandled rejection");
  throw reason; // uncaughtException に流す
});
 
process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "Uncaught exception");
  process.exit(1); // 状態が不明なので終了し、プロセスマネージャに再起動させる
});

グレースフルシャットダウン

const shutdown = async (signal: string) => {
  logger.info({ signal }, "Shutting down");
  server.close();            // 新規接続の受付停止
  await db.end();            // コネクション解放
  process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

コンテナ環境では SIGTERM を受けてから一定時間内に終了する必要がある。強制終了のタイムアウトも設けておく。

7. 設定と環境変数

  • 設定は起動時に一度だけ読み込み・検証し、型付きオブジェクトとして公開する。コード中に process.env.XXX を散在させない。
  • 不足・不正な値があれば起動時に即座に失敗(fail fast) させる。
// src/shared/config.ts
import { z } from "zod";
 
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
 
export const config = EnvSchema.parse(process.env);
  • .env ファイルは Node.js 組み込みの --env-file=.env(または process.loadEnvFile())で読み込める。dotenv は不要な場合が多い。
  • 秘密情報はコミットしない。 .env は .gitignore に入れ、.env.example をコミットする。本番ではシークレットマネージャを使う。

8. 入力バリデーション

  • 外部入力はすべて信用しない。 リクエストボディ、クエリ、パスパラメータ、ヘッダー、外部 API のレスポンス、キューのメッセージなど。
  • スキーマバリデーションライブラリ(Zod / Valibot / TypeBox / ArkType など)を使い、スキーマから型を導出して二重定義を避ける。
export const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
  • バリデーションはシステムの境界(コントローラー) で行い、内部のサービス層は検証済みの型を受け取る。
  • 未知のプロパティは除去(strip)または拒否(strict)して、マスアサインメントを防ぐ。

9. ロギングと可観測性

  • console.log ではなく構造化ロガー(pino 等) を使い、JSON で出力する。
  • ログレベルを適切に使い分ける(debug / info / warn / error / fatal)。
  • リクエストごとに相関 ID(リクエスト ID / トレース ID) を付与する。AsyncLocalStorage を使うと、引数で引き回さずにコンテキストを伝播できる。
  • 個人情報・秘密情報をログに出さない。 pino の redact 等でマスクする。
  • エラーログには err オブジェクトそのものを渡し、スタックトレースと cause を残す。
  • メトリクス・トレースは OpenTelemetry で計装すると、ベンダーに依存せずに済む。
  • ヘルスチェック用エンドポイント(liveness / readiness)を用意する。

10. セキュリティ

  • 依存関係の脆弱性を定期的に確認する(npm audit、Dependabot / Renovate、Socket 等)。
  • SQL インジェクション対策:文字列連結でクエリを組まない。プレースホルダ / ORM / クエリビルダを使う。
  • コマンドインジェクション対策:child_process.exec にユーザー入力を渡さない。execFile / spawn を引数配列で使う。
  • パストラバーサル対策:ユーザー入力からファイルパスを作る場合は path.resolve 後に許可ディレクトリ配下か検証する。
  • プロトタイプ汚染対策:ユーザー入力のキーでオブジェクトを再帰マージしない。Object.create(null) や Map を使う。
  • HTTP サーバーでは
    • セキュリティヘッダーの付与(helmet 等)
    • CORS の許可オリジンを明示
    • レートリミット
    • リクエストボディサイズの上限設定
  • パスワードは bcrypt / argon2 / crypto.scrypt でハッシュ化。トークン比較には crypto.timingSafeEqual を使う。
  • 乱数が必要なセキュリティ用途では Math.random() ではなく crypto.randomUUID() / crypto.randomBytes() を使う。
  • eval / new Function / vm でユーザー入力を実行しない。
  • 本番では非 root ユーザーでプロセスを実行する。
  • 必要に応じて Node.js の Permission Model(--permission)でファイル・子プロセスへのアクセスを制限する。

11. パフォーマンス

  • 計測してから最適化する。 --cpu-prof、clinic.js、0x、Chrome DevTools(--inspect)を活用。

  • ストリームを使う。 大きなファイルやレスポンスを一括でメモリに読み込まない。stream/promises の pipeline を使うとエラー処理とクリーンアップが確実。

    import { pipeline } from "node:stream/promises";
    import { createReadStream, createWriteStream } from "node:fs";
    import { createGzip } from "node:zlib";
     
    await pipeline(createReadStream("in.log"), createGzip(), createWriteStream("in.log.gz"));
  • DB・HTTP クライアントはコネクションプール / Keep-Alive を使う(fetch(undici)は既定で Keep-Alive)。

  • 頻繁に参照され変化の少ないデータはキャッシュする(インメモリ LRU、Redis 等)。キャッシュの無効化戦略も設計する。

  • N+1 クエリを避ける(バッチ取得、DataLoader パターン)。

  • メモリリークの典型(グローバルな配列/Map への蓄積、解除されないイベントリスナー、クロージャの保持)に注意する。

  • 組み込みモジュールは node: プレフィックス付きで import する(import fs from "node:fs")。

12. テスト

  • テストランナーは Vitest か Node.js 組み込みの node:test が軽量で TypeScript とも相性が良い。

  • テストピラミッドを意識する:ユニットテストを厚く、統合テスト・E2E は要所に。

  • テストは AAA(Arrange / Act / Assert) パターンで書き、1テスト1関心事にする。

  • テスト名は「何をしたら何が起きるか」が分かるように書く。

    import { describe, it, expect } from "vitest";
     
    describe("calculateTotal", () => {
      it("returns 0 for an empty cart", () => {
        expect(calculateTotal([])).toBe(0);
      });
     
      it("applies discount when total exceeds threshold", () => {
        const items = [{ price: 6000, qty: 2 }];
        expect(calculateTotal(items)).toBe(10800);
      });
    });
  • 依存性注入(DI) を使ってテスト容易性を高める。モジュールのモックに頼りすぎない。

  • 外部サービスは境界でスタブ化し、DB は Testcontainers などで本物に近い環境で統合テストする。

  • HTTP API は supertest や app.inject()(Fastify)、app.request()(Hono)でサーバーを起動せずにテストする。

  • テストはお互いに独立させ、実行順序や共有状態に依存させない。

  • カバレッジは指標の一つとして使い、数値自体を目的化しない。

  • 型のテストが必要なライブラリでは expectTypeOf(Vitest)や tsd を使う。

13. Lint / フォーマット

  • ESLint + typescript-eslint(型情報を使うルールセット recommendedTypeChecked / strictTypeChecked)を推奨。 特に有効なルール:
    • @typescript-eslint/no-floating-promises
    • @typescript-eslint/no-misused-promises
    • @typescript-eslint/no-explicit-any
    • @typescript-eslint/no-unnecessary-condition
    • @typescript-eslint/switch-exhaustiveness-check
    • @typescript-eslint/consistent-type-imports
  • フォーマットは Prettier または Biome に任せ、レビューでスタイルを議論しない。
  • 高速化を重視するなら Biome / oxlint も選択肢(型情報が必要なルールは typescript-eslint 併用)。
  • lint-staged + husky(または lefthook)でコミット前に自動チェック。
  • CI で typecheck / lint / test を必ず実行する。
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/server.js",
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "format": "prettier --write .",
    "test": "vitest run",
    "check": "npm run typecheck && npm run lint && npm run test"
  }
}

14. 依存関係の管理

  • 依存は最小限に。 追加前に「標準 API で足りないか」を確認する(fetch、node:test、--env-file、structuredClone、crypto.randomUUID、util.parseArgs、fs.glob など、以前は外部パッケージが必要だった機能の多くが組み込みになっている)。
  • 追加する場合はメンテナンス状況、ダウンロード数、依存ツリーの大きさ、ライセンスを確認する。
  • dependencies と devDependencies を正しく分ける(型定義 @types/* やビルドツールは dev)。
  • CI・本番では npm ci(pnpm install --frozen-lockfile)でロックファイル通りにインストールする。
  • Renovate / Dependabot で小さく頻繁にアップデートする。
  • postinstall スクリプトを持つパッケージには注意する(サプライチェーン攻撃の経路)。pnpm ならインストールスクリプトの実行許可を制御できる。

15. ビルドとデプロイ

  • 本番ではビルド済みの JavaScript を node で直接実行する(ts-node / tsx を本番で使わない)。
  • ソースマップを有効にし、node --enable-source-maps でスタックトレースを TS の行番号で読めるようにする。
  • NODE_ENV=production を設定する。
  • Docker はマルチステージビルドで、実行イメージには本番依存とビルド成果物のみを含める。
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
 
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/server.js"]
  • CMD は npm start ではなく node を直接起動する(シグナルが正しく伝わるように)。
  • プロセスのクラッシュ時は、オーケストレーター(Kubernetes、ECS、systemd 等)に再起動を任せる。
  • 複数コアを活用したい場合は、アプリ内で cluster を使うよりコンテナ/プロセスを水平スケールさせる方が運用しやすい。
  • ライブラリを公開する場合は package.json の exports フィールドでエントリーポイントと型定義(types)を明示し、files で公開ファイルを絞る。

16. チェックリスト

プロジェクト設定

  • Node.js LTS を使い、バージョンを固定している
  • ロックファイルをコミットしている
  • tsconfig で strict と noUncheckedIndexedAccess が有効
  • ESM を採用している

コード品質

  • any / 不要な as / ! を使っていない
  • 外部入力はスキーマで検証している
  • floating promise がない(ESLint で検出)
  • エラーは Error サブクラスで投げ、cause で連鎖している
  • 空の catch がない

運用

  • 環境変数を起動時に検証している
  • 構造化ログを出力し、秘密情報をマスクしている
  • グレースフルシャットダウンを実装している
  • unhandledRejection / uncaughtException を処理している
  • ヘルスチェックエンドポイントがある

セキュリティ

  • 依存関係の脆弱性を定期チェックしている
  • クエリはパラメータ化している
  • 秘密情報をリポジトリに含めていない
  • 本番で非 root ユーザーで実行している

CI/CD

  • typecheck / lint / test を CI で実行している
  • 本番はビルド済み JS を node で直接起動している
  • ソースマップを有効にしている

0件のページ