Node.js + TypeScript でサーバーサイド/CLI/ライブラリを開発する際の実践的な指針をまとめたものです。
目次
- ランタイムとツールチェーン
- プロジェクト構成
- tsconfig の設定
- 型設計
- 非同期処理
- エラーハンドリング
- 設定と環境変数
- 入力バリデーション
- ロギングと可観測性
- セキュリティ
- パフォーマンス
- テスト
- Lint / フォーマット
- 依存関係の管理
- ビルドとデプロイ
- チェックリスト
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で直接起動している - ソースマップを有効にしている