【TypeScript】雑に指示して品質を出す【Claude Code】
モチベ
コーディング規約やガイドラインを整備しても、守られるかは書く人の注意力に依存します。人間、AIへの指示でも、「規約を読んで従って」はどこまでいっても「お願い」です。
品質をお願いではなく仕組みで担保することで雑に指示を出せるようにしたいというのが今回の狙いです
- lint設定で機械的に強制できるもの
- コーディング規約として文脈に応じて参照させるもの
- プロセスとして標準化するもの
この3層に分けて整備しました。
また、実装にはBiome、Claude Codeのhooks・rules・skillsを使ってます
前提
- 技術スタック: Turborepo + pnpm / Next.js + React / Hono + Prisma + Zod / Biome / Vitest
- Claude Codeを開発の主軸として使用
- この記事はフロントエンド(Nextjs, React)中心
全体像
この記事に関わるファイル構成です。
.claude/
├── settings.json # Hooks定義
├── hooks/
│ ├── biome-format.sh # コード編集時に自動実行
│ └── gh-setup.sh # セッション開始時に自動実行
├── rules/
│ ├── api.md
│ └── frontend.md
└── skills/
├── impl-plan/
├── api-test/
├── frontend-test/
└── code-review/
biome.jsonc # Linter/Formatter設定
docs/ # Rulesから @docs/ で参照
├── api-design.md
├── api-directory-structure.md
├── frontend-coding-guidelines.md
├── frontend-directory-structure.md
└── frontend-testing-guidelines.md
lintの自動実行、コーディング規約の自動参照、プロセスの標準化 — この3層をClaude Codeで実装しました。それぞれ異なるタイミングで自動的に動きます。
Lintで強制する
「命名規則を守って」とプロンプトに「お願い」してもAIは無視します。
一方、linterはルール違反を検知して自動修正することで、「強制的」に規約を適用します。
機械的に検証できるルールは、プロンプトではなくlinterに書きます。
以下は今回のプロジェクトでBiomeに書いているルールです
biome.jsonc の全文
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/*.css"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
// Hooks依存配列の欠落は古い値参照・無限ループの原因、any型は型安全性を無効化、未使用変数はデッドコード
"recommended": true,
"style": {
// 意味のない数値はコードの可読性を著しく下げる(0, 1, -1は許容)
"noMagicNumbers": "error",
// 子要素がないタグの閉じ忘れを防ぎ統一する
"useSelfClosingElements": "error",
// 命名の一貫性を強制し可読性を保つ(変数: camelCase、型: PascalCase、定数: CONSTANT_CASEも許可)
"useNamingConvention": "error",
// ネストした三項演算子は可読性が著しく低下する
"noNestedTernary": "error",
// Boolean属性の暗黙trueは意図が不明確になる
"noImplicitBoolean": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["apps/api/**", "apps/infra/**"],
"linter": {
"rules": {
"style": {
"useNamingConvention": "off"
}
}
}
},
{
"includes": ["**/__tests__/**", "**/*.test.ts", "**/*.spec.ts"],
"linter": {
"rules": {
"style": {
"noMagicNumbers": "off"
}
}
}
}
]
}
自動実行の仕組み
Biomeの設定だけでは、AIがコードを書くたびに手動で実行する必要があります。
Claude CodeのHooks(PostToolUse)を使い、AIがファイルを編集するたびにBiomeが自動実行されるようにしてます
settings.json(Hooks定義)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/biome-format.sh",
"timeout": 30000
}
]
}
]
}
}
biome-format.sh の全文
set -e
TOOL_INPUT=$(cat)
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r \
'.file_path // .edits[0].file_path // empty' 2>/dev/null)
if [ -z "$FILE_PATH" ]; then exit 0; fi
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css) ;;
*) exit 0 ;;
esac
if [ ! -f "$FILE_PATH" ]; then exit 0; fi
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}" 2>/dev/null || exit 0
pnpm biome check --write --no-errors-on-unmatched "$FILE_PATH" 2>/dev/null || true
exit 0
入力からどのファイルを編集したかを受け取り、対象がTS/JS/JSON/CSSならbiome check --writeを実行します。
これにより、AIが書いたコードは毎回自動的にフォーマット・lint設定に準拠するようになります
linterで強制できないもの
しかし、すべてのコーディング規約をlinterで強制できるわけではありません。現在のプロジェクトではフロントエンドガイドラインだけで約30の規約がありますが、useEffectの判断フロー、asキャスト禁止、アーキテクチャパターンなどは文脈依存の判断が必要です。linterでは表現できないこれらの規約を、次のセクションで扱います
規約で認識させる
linterで強制できない規約は、明文化してAIに自動参照させます。Claude CodeのRules(.claude/rules/)を使い、対象ファイルの編集時にルールファイルが自動的に読み込まれる仕組みです。
ルールファイルを整備しました。各ファイルはその階層の責任範囲を定義しています。
| ファイル | 適用範囲 | 責任範囲 |
|---|---|---|
| api.md | apps/api/** |
APIの型安全性と認可 |
| frontend.md |
apps/web/**, apps/admin/**, packages/ui/**
|
UIの一貫性と状態管理 |
Rulesとdocsの責任範囲
Rulesとdocsは役割が異なります。
| Rules(.claude/rules/) | docs/ | |
|---|---|---|
| 性質 | プロジェクト固有の制約 | 他プロジェクトでも流用可能な汎用ガイドライン |
| 例 | Zodスキーマの書き方、テストでのレスポンス型付け | useEffect判断フロー、命名規則、ディレクトリ構成 |
| 読み込み | pathsで対象ファイル編集時に自動読込 | Rulesから@docs/で参照 |
Rulesの冒頭で@docs/を参照することで、人間用に書いたドキュメントをAIにもそのまま読ませています。ガイドラインを更新すれば、AIの挙動も自動的に変わります。
何を書くか
現状、Rulesに書いているのは2種類です。
- docsのコーディング規約にあるが、何度も繰り返し守られなかったもの
- NG/OKの具体例を添えて念押しする
- プロジェクト固有のルール
- フォームの状態管理、APIレスポンスの型付けなど
たとえば、テストでのasキャストは何度指摘しても繰り返されたので、Rulesに明示しました。
## テストでのレスポンス型付け
`res.json()` は `Promise<unknown>` を返す。`as` キャストではなく、
既存の Zod スキーマの `.parse()` を使って型付けとバリデーションを兼ねること。
// NG
const body = (await res.json()) as { sessions: unknown[] };
// OK
const body = SessionsResponseSchema.parse(await res.json());
NG/OKの具体例をルールに書いておけば、プロンプトで毎回指示しなくてもAIが判断に迷う余地が減ります。
実際に使用しているRulesの一部とドキュメントを以下に示します(プロダクトや企業の情報は伏せています)。Rulesにはプロジェクト固有のルールを書くべきなので、以下は参考程度に見てください。
api.md(Rules)の抜粋
---
paths: apps/api/**/*
---
@docs/api-directory-structure.md
@docs/api-design.md
# プロジェクト固有ルール
## 過剰な抽象化の回避
const + スプレッド構文で済むものをファクトリ関数にしない。最小限の手段で目的を達成すること。
## テストでのレスポンス型付け
`res.json()` は `Promise<unknown>` を返す。`as` キャストではなく、
既存の Zod スキーマの `.parse()` を使って型付けとバリデーションを兼ねること。
// NG
const body = (await res.json()) as { sessions: unknown[] };
// OK
const body = SessionsResponseSchema.parse(await res.json());
## 作業完了前の検証
コード変更後、作業完了を報告する前に必ず以下を実行すること:
1. `pnpm --filter api test` — テスト通過
2. `pnpm --filter api build` — 型エラーなし
特に `res.json()` の戻り値型など、テスト実行だけでは検出できない型エラーがあるため、
build による型チェックは必須。
frontend.md(Rules)の抜粋
---
paths: apps/web/**/*,apps/admin/**/*,packages/ui/**/*
---
@docs/frontend-coding-guidelines.md
@docs/frontend-directory-structure.md
# プロジェクト固有ルール
## biome-ignore の扱い
- 抑制より先にルールに従う方法を検討する
- どうしても必要な場合のみ理由を明記して使用
## Server Component での日時フォーマット
- Server Component(AWS 上では UTC で実行される)で日時を表示する場合、`new Date()` ではなく `@date-fns/tz` の `TZDate` を使い JST に変換すること
- `format()` は `TZDate` のタイムゾーン情報を尊重するため、サーバーが UTC でも JST で表示される
## useEffect
- useEffect を書く前に `docs/frontend-coding-guidelines.md` の「useEffectの乱用」セクションの判断フローを確認すること
- 「外部システムとの同期」以外の用途では useEffect を使わない
## フォーム状態管理
- フォームの入力状態は `react-hook-form`(`useForm` + `Controller`)で管理する
- `useState` で個別に管理しない
frontend-coding-guidelines.md(docs)
## 爆弾系(高リスク項目)
### useEffectの乱用
useEffectは「**外部システムとの同期**」専用のツール。それ以外の用途では使わない。
#### 判断フロー(3つの問い)
1. **このコードが実行される理由は?** → ユーザー操作が原因ならイベントハンドラへ
2. **コンポーネントが表示されたから実行すべきか?** → Yes ならEffect、No ならイベントハンドラ
3. **レンダー中に計算できるか?** → Yes ならEffectもstateも不要
> 参照: [エフェクトは不要かも(React公式)](https://ja.react.dev/learn/you-might-not-need-an-effect) / [ReactのuseEffectは極力使わないで(Zenn)](https://zenn.dev/begineer/articles/8a0696fda04c09)
#### パターン1: レンダー中に計算できる派生値
// ❌ Bad: useEffectでstateを更新(無駄な再レンダー)
const [filtered, setFiltered] = useState(items);
useEffect(() => {
setFiltered(items.filter((item) => item.name.includes(query)));
}, [items, query]);
// ✅ Good: レンダー中に直接計算
const filtered = items.filter((item) => item.name.includes(query));
#### パターン2: ユーザー操作への応答
// ❌ Bad: stateの変化をuseEffectで監視してURL更新
const email = watch("email");
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (email) { params.set("email", email); } else { params.delete("email"); }
router.replace(`?${params.toString()}`);
}, [email, router, searchParams]);
// ✅ Good: イベントハンドラで直接URL更新
{...register("email", {
onChange: (e) => {
const params = new URLSearchParams(searchParams.toString());
if (e.target.value) { params.set("email", e.target.value); } else { params.delete("email"); }
router.replace(`?${params.toString()}`);
},
})}
#### パターン3: propsが変わったらstateをリセット
// ❌ Bad: useEffectでリセット
const [comment, setComment] = useState("");
useEffect(() => {
setComment("");
}, [userId]);
// ✅ Good: keyでコンポーネントをリセット
<CommentForm key={userId} />
#### useEffectが必要なケース
- 外部ライブラリ(jQuery等)との同期
- ブラウザAPIのイベントリスナー登録・解除
- APIからのデータ取得(ただしフレームワーク組み込みの仕組み推奨)
- コンポーネント表示時の Server Action 呼び出し(view count、再生ログ作成など)
#### パターン4: コンポーネントマウント時に Server Action を呼ぶ
Server Component 内で副作用のある処理(ログ作成など)を直接呼ぶと、Server Action の revalidation や Link の prefetch により Server Component が再実行され、副作用が重複する。Client Component の useEffect + startTransition で呼ぶことで1回だけの実行を保証する。
> 参照: [Next.js 公式 — useEffect で Server Action を呼ぶパターン](https://nextjs.org/docs/app/getting-started/mutating-data#useeffect)
### 型キャスト(as)
型キャストは型システムを騙す行為。実行時エラーの原因になる。型ガードやジェネリクスで解決する。
### null vs undefined
両者を極力避ける。使い分けに一貫性がないとバグの温床になる。
### `||` より `??`
`||`は`0`や`""`もfalsy扱いする。意図しない挙動を防ぐためnullish coalescing(`??`)を使用する。
---
## コードスタイル
### 命名規則
#### 配列変数: `{noun}s`
// ❌ Bad
const userList = [];
// ✅ Good
const users = [];
#### ファイル名: camelCase
✅ Good: accordion.tsx, someComponent.tsx
❌ Bad: Accordion.tsx, some-component.tsx
#### 省略の禁止
変数名・関数名は省略せず明確にする。将来の可読性のため。
### 型定義
#### type推奨
`interface`より`type`を使用する。interfaceは意図しない型マージが起こるリスクがある。
#### 配列記法: `Foo[]`
`Array<Foo>`より`Foo[]`の方が簡潔で読みやすい。
#### 型推論の活用
TypeScriptの型推論が効いている場合は型注釈を省略する。冗長な型定義はノイズになる。
#### Iプレフィックス不要
C#やJavaの慣習は不要。TypeScriptではシンプルな命名が推奨される。
### Export
#### named export推奨
default exportは名前の一貫性が保てない。named exportでimport時の名前を強制する。Next.jsのファイルルーティングは例外。
### その他
#### オブジェクト省略記法
プロパティ名と変数名が同じ場合は省略記法を使用する。
#### let 禁止
`let` は再代入を許すため、値の追跡が困難になる。`const` + 即時関数式(IIFE)や関数抽出で代替する。
> Biome の `useConst` は「再代入しない let」のみ検出。再代入する let は検出できないため、コードレビューで担保する。
#### JSX の条件分岐はコンポーネントに抽出する
JSX の条件分岐が複雑になる場合、IIFE やインライン三項演算子ではなくコンポーネントに抽出する。抽出先には生データを渡し、データの分解・分岐ロジックはコンポーネント内に閉じる。
#### 条件による出し分けはコンポーネントで返す
条件によってUIを出し分ける場合、コンポーネント参照を返す関数ではなくJSXを返すコンポーネントにする。
---
## マインド(設計思想)
### ファイルサイズ
- 100 行以上のファイルが誕生しそうな場合は、ロジックやコンポーネントを自分の中で分解できていない可能性大
- 1 ファイルで複数機能を持つpagesとかutilsとかtestなどは許容する
### 共通化
- 小さく作る。その恩恵の 1 つに共通化がある。
- 条件や props が増えそうだと予想されるものは無理に共通化しない
- 関数やコンポーネントで同じ処理をしているからといって切り出さない
### 純粋関数
副作用を分離する。API呼び出しは値を返却するだけにし、状態更新は呼び出し側で行う。テストしやすくなる。
### UIレイアリング
UI側でデータを整形する。ロジック側でUI用のデータを準備しない。関心の分離を保つ。
### hookに不要な依存を持ち込まない
hookは自身の関心(フォーム状態管理、画面遷移ロジックなど)に集中する。特定のコンポーネントだけが消費するデータ変換を hook に持たせると、hook が外部の型に不要に依存し、責務が曖昧になる。
### Context があるなら props で渡さない
`FormProvider` 等で context を提供しているなら、子コンポーネントや hook は `useFormContext` で取得する。context で取れるものを props で渡すと、Provider の存在意義がなくなり、props チェーンが増える。
### メモ化
`useMemo`や`useCallback`は必要性を十分検討してから実装する。不要なメモ化はかえってパフォーマンスを悪化させる。
api-design.md(docs)の抜粋
# API 設計ガイドライン
## List エンドポイントのセマンティクス一貫性
List(コレクション取得)エンドポイントは、認可済みの全ユーザーに対して同じセマンティクスの結果を返すべき。
- OK: セキュリティフィルタ(権限のないリソースを除外する) — 返す「量」が変わるだけ
- NG: ロールによってエンドポイントの意味自体が変わる(一覧 vs 単一取得)
### レスポンス設計
「リソースがない」ことが正常な状態の場合は、404 ではなく 200 + null を返す。
404 は「リソースのアドレスが存在しない」を意味する。「リソースが未作成」は有効なアドレスに対する有効な状態であり、エラーではない。
## 同一リソースの複数ビュー
同じリソースに対して、用途ごとに異なるレスポンス(フィールド数や派生データの有無)が必要な場合がある。
### `/admin` プレフィックスは使わない
`/api/admin/resources` のようなプレフィックス分離は採用しない。理由:
- UI の関心が API に漏れる: 「管理画面用」はクライアント側の都合であり、リソースの性質ではない
- 認可はミドルウェアの責務: パスではなく `requirePermission` で制御する
### 採用パターン: 静的パスセグメントによるビュー分離
GET /api/{resource} → 固有属性のみ(軽量、汎用)
GET /api/{resource}/summary → 派生データ・集計値を含む拡張ビュー
GET /api/{resource}/:id → 単一リソースの詳細
他に、docs/にはディレクトリ構成(API・フロントエンド)やテストガイドラインも置いています。ディレクトリ構成をドキュメントに書いておくことで、AIが実装時にファイルの作成場所を間違えなくなります。
プロセスを標準化する
「これ実装して」と雑に指示すると、AIは既存のコードベースのパターンを無視してコードを書き始めます。要件も読まない、既存の命名規則やアーキテクチャも確認しない。手順をテンプレート化して毎回同じプロセスを踏ませる必要があります。
Claude CodeのSkills(.claude/skills/)で実装しました。スラッシュコマンドでワークフローが起動します。
| スキル | 用途 |
|---|---|
/impl-plan |
実装計画の策定 |
/code-review |
コードレビュー |
/api-test |
APIテスト生成 |
/frontend-test |
フロントエンドテスト生成 |
/impl-plan — 実装計画の策定
たとえば/impl-plan 要件定義.mdと打つと、以下のプロセスが自動で走ります。
- 要件の整理
- 要件定義から「何を作るか」「どこに入るか」を明確にし、複雑さを評価する。曖昧な点があればここで止めてユーザーに確認する
- コードベース探索
— Exploreエージェントを並列起動し、類似実装・命名規則・エラー処理・型定義・テストパターンなど7カテゴリを調査する。既存のパターンに合わない実装を防ぐための材料集め - Architect Agentが計画を起草
— 調査結果と要件をもとに、専用のサブエージェントが実装計画を作成する。模倣すべきパターン、変更するファイル一覧、フェーズ分割(MVP → Core → Edge Cases)、テスト戦略を含む - Devil's Advocate Agentがレビュー
— 別のサブエージェントが計画の穴を突く。既存パターンからの逸脱、命名の適切性、エッジケースの見落とし、スコープの妥当性を検証する - ユーザー承認
— 計画とレビュー指摘を提示し、ユーザーが判断する。承認されるまで実装には進まない
「AIが勝手に作った実装」ではなく「要件とコードベースに基づいて合意した計画」から実装が始まります。
/api-testも同様に、実装コードではなく要件定義からテストケースを導出し、ユーザーの承認を得てからコード生成に進みます。
Skillの中身はプロジェクトやチームのワークフローによって異なります。ここでは全文は載せませんが、自分たちの開発プロセスで「毎回同じ手順を踏みたい」部分をテンプレート化するのがポイントです。
ありがとうございました
AIは不要な作業を防ぐことはほとんどしません。だからこそ、人間の注意力ではなく仕組みで品質を担保する設計が必要と感じています
以下のような「何をどの仕組みに任せるか」の判断基準を考えることが重要と考えています
| 判断基準 | 任せ先 | 例 |
|---|---|---|
| 機械的に検証できる | Lint(自動実行) | フォーマット、lint、import順序 |
| 文脈依存だが定型的 | 規約(自動参照) | コーディング規約、アーキテクチャパターン |
| 判断が必要 | プロセス(明示的に起動) | コードレビュー、実装計画、テスト設計 |
ありがとうございました!
Discussion