はじめに
Claude Code を業務で使っていると、ふと気になることがある。
「実際の人間のように、AIにも性格を持たせたらコードの書き方も変わるのか?」
CLAUDE.md にプロジェクトの方針を書く運用は一般的だが、
ペルソナ(人格設定) を利用して、運用するようなチームや記事などはあまり見かけない。
そこで本記事では、Claude Code に対して 「シニア / 新人」×「厳格 / フレンドリー」 の4種類のペルソナを注入し、同じお題を TypeScript で解かせて結果を比較した。
観察ポイントは以下の通り。
- コード量・冗長さ(行数、関数数、ネスト深度)
- 命名・コメントスタイル(変数名の長さ、コメントの口調)
-
型の使い方(
anyの出現、interfacevstype、ジェネリクスの濃度)← TSならではの観点 -
書き方の選択(
forvs 配列メソッド、アロー vsfunction、async/awaitvs.then)
「なんとなく違う気がする」を「数字でこう違う」まで持っていくのが本記事のゴールである。
⚠️ 注意:本記事は限定的な試行回数に基づくケーススタディです。LLMの出力には揺らぎがあるため、結論は「絶対的な傾向」ではなく「一例」として読んでいただきたい。再現性については「実験の限界」セクションで触れます。
TL;DR
- ペルソナを注入すると、3タスク合計のコード量は最大で約4.6倍の差が出た(新人×フレンドリー:456%、シニア×厳格:108%、Baseline=100%)
-
anyの出現は 新人×フレンドリー のみで観測され、それ以外はゼロ(型レベル含む) -
forループ vs 配列メソッド はシニア=配列メソッド、新人=for/reduceで明確に分かれた - コメントの口調は性格軸(厳格/フレンドリー)でくっきり分かれる(厳格=TSDoc中心、フレンドリー=「未来の読み手へ」型の本文コメント)
-
ライブラリ選定は不変ではなかった:Baseline は迷わず
zodを導入したが、4ペルソナ全員が「依存追加を避けて手書きバリデーション」を選んだ -
ペルソナによる「過剰反応」:新人×フレンドリーは仕様外の
unhandledRejectionハンドラを足し、新人×厳格は1行関数に対しても template literal type を書く、など想定外の派生が頻発した
実験設計
1. ペルソナの定義
以下の5パターンを用意した。プロンプトは全文公開する(再現性のため)。
Baseline:ペルソナなし
CLAUDE.md 自体を置かない素の状態。
A:シニア × フレンドリー
# あなたについて
あなたは10年以上のキャリアを持つシニアTypeScriptエンジニアです。
チームメンバーには温かく接し、レビューでは「なぜそうしたか」を
丁寧に説明します。技術的判断は経験に基づき、過剰設計を避けます。
コードは簡潔に、コメントは「未来の読み手への手紙」のつもりで書きます。
型は厳密に、ただし読みやすさを犠牲にしません。
B:シニア × 厳格
# あなたについて
あなたは10年以上のキャリアを持つシニアTypeScriptエンジニアです。
妥協を許さず、命名・設計・型・エラーハンドリングのいずれにも厳しい基準を持ちます。
冗長なコメントは書きません。コードそのものが説明であるべきだと考えています。
`any` は原則禁止、`as` キャストも最小限。不要な抽象化や過剰なテストも避けます。
C:新人 × フレンドリー
# あなたについて
あなたは入社1年目のジュニアTypeScriptエンジニアです。
学んだことを丁寧にコメントに残し、自分が後で読んでも分かるように書きます。
変数名は省略せず、何をしているか説明的に書く傾向があります。
チームに迷惑をかけないよう、エラーハンドリングは多めに入れます。
型は分からないところは `any` で逃げることもあります。
D:新人 × 厳格
# あなたについて
あなたは入社1年目のジュニアTypeScriptエンジニアですが、
ベストプラクティスに従うことに強いこだわりを持っています。
教科書通りの設計を心がけ、TSDoc 形式でコメントを書きます。
型は厳密に書き、`strict` モードで一切の警告を出さないことを目標とします。
2. お題(タスク)
ペルソナが効くレイヤーを変えるため、3段階で用意した。
| # | タスク | 想定される差分の出やすさ |
|---|---|---|
| T1 |
snake_case → camelCase 変換関数 |
命名・コメント・型注釈・配列処理 |
| T2 | JSON APIを叩いて整形するCLI(Node.js) | async/await、エラーハンドリング、型定義の粒度 |
| T3 | 簡易TODO REST API(Hono) | ファイル分割、ミドルウェア設計、型の共有 |
T3 で Hono を採用しているのは、現代的なフレームワークでの観察例を残すため。
Express を選ばないこと自体が「攻めた選択」になるので、ペルソナが選定に影響するかも観察ポイントになる
(実験では Hono を指定して、内部実装の差分を見る)。
3. 評価指標
| カテゴリ | 指標 |
|---|---|
| コード量 | 総行数(LOC)、コメント行数、コメント比率 |
| 構造 | 関数数、平均関数長、最大ネスト深度 |
| 命名 | 識別子の平均文字数、略語使用率 |
| コメント | TSDoc有無、コメントの平均長、口調(敬体/常体) |
| 型 |
any 出現回数、as キャスト回数、interface/type 比、ジェネリクス使用箇所 |
| 書き方 |
for vs map/filter/reduce 比、アロー vs function 比 |
| 非同期 |
async/await vs .then 比、try/catch 数 |
| 防御 | 入力バリデーション箇所数、early return の数 |
4. 実行条件
実験のブレを抑えるため、以下を固定した。
- モデル:Claude Sonnet 4.6(Claude Code 経由)
-
tsconfig.json:strict: true、noUncheckedIndexedAccess: true、その他はデフォルト - 整形ツール:Prettier / ESLint は入れない(ペルソナの素の出力を見るため)
- Node.js:v22 LTS
-
依存:T2/T3 で必要な最小限のみ(Hono、
undiciなど) - 各パターン×各タスクで1回ずつ生成(揺らぎについては「実験の限界」で議論)
- プロンプトは「Tnのコードを書いてください」のみ。追加の制約は与えない
結果
T1:snake_case → camelCase 変換関数
Baseline
function snakeToCamel(input: string): string {
return input.replace(/_([a-zA-Z0-9])/g, (_, char: string) => char.toUpperCase());
}
短い関数宣言、コメントなし。function を使う。仕様としては必要十分。
A:シニア × フレンドリー
/**
* snake_case の文字列を camelCase に変換する。
*
* 設計メモ(未来の読み手へ):
* - 先頭・末尾のアンダースコアは「意図的な命名」(例: `_private`, `__proto__`)
* である可能性が高いので、保存する。内部のアンダースコアのみを区切り文字として扱う。
* - 連続するアンダースコア(例: `foo__bar`)は 1 つの区切りとみなす。
* 厳密に再現したい要件が出てきたら、この方針を見直すこと。
* - `noUncheckedIndexedAccess` 下でも安全に書けるよう、文字インデックスアクセスは避け、
* `replace` のキャプチャグループ経由で次の文字を受け取る。これによりキャプチャは
* 常に `string`(少なくとも 1 文字)として型付けされる。
*/
export const snakeToCamel = (input: string): string => {
// 先頭・末尾のアンダースコアを退避してから内部だけを変換する。
const leading = input.match(/^_+/)?.[0] ?? "";
const trailing = input.match(/_+$/)?.[0] ?? "";
const core = input.slice(leading.length, input.length - trailing.length);
// `_+([a-zA-Z0-9])` で「1 個以上の `_` の直後に続く 1 文字」を捕捉し、
// その 1 文字だけを大文字化する。末尾のアンダースコアは上で退避済みなので、
// ここで「マッチしない `_` が末尾に残る」ケースは発生しない。
const camel = core.replace(/_+([a-zA-Z0-9])/g, (_match, nextChar: string) =>
nextChar.toUpperCase(),
);
return `${leading}${camel}${trailing}`;
};
27行。Baseline よりも仕様が増えた(先頭・末尾アンダースコア保存、連続アンダースコア対応)。コメントは「なぜそうしたか」を未来の読み手向けに書く、いかにもシニアの所作。
B:シニア × 厳格
export const snakeToCamel = (input: string): string =>
input.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
2行。コメントゼロ。アロー関数の式形式({ return ... } すら省略)。Baseline よりさらに短くなる現象。「冗長なコメントは書かない」が極まると、ここまで切り詰める。
C:新人 × フレンドリー
// snake_case の文字列を camelCase に変換する関数
// 例: "user_name" -> "userName"
// "first_name_last_name" -> "firstNameLastName"
/**
* snake_case の文字列を camelCase に変換する関数
* @param snakeCaseString 変換したい snake_case の文字列
* @returns camelCase に変換された文字列
*/
export function convertSnakeCaseToCamelCase(snakeCaseString: any): string {
// まず引数のバリデーションをする
// null や undefined が渡ってきた場合に落ちないようにする
if (snakeCaseString === null || snakeCaseString === undefined) {
// チームのログに残るように console.warn で警告を出しておく
console.warn("convertSnakeCaseToCamelCase: 引数が null または undefined です。空文字を返します。");
return "";
}
// 文字列以外が渡ってきたときのために型チェックをする
// ここは any で受けているので念のためチェック
if (typeof snakeCaseString !== "string") {
console.warn(
`convertSnakeCaseToCamelCase: 引数が string ではありません。型: ${typeof snakeCaseString}。空文字を返します。`
);
return "";
}
// 空文字が渡された場合はそのまま空文字を返す(変換する必要がないので)
if (snakeCaseString.length === 0) {
return "";
}
try {
// アンダースコアで文字列を分割する
// 例: "user_name" -> ["user", "name"]
const splittedByUnderscore: string[] = snakeCaseString.split("_");
// 分割した結果が空配列だった場合の防御(基本的に起きないはずだが念のため)
if (splittedByUnderscore.length === 0) {
return snakeCaseString;
}
// 最初の単語はそのまま小文字にしておく(camelCase の最初の単語は小文字なので)
// noUncheckedIndexedAccess が true なので、配列アクセスは undefined の可能性がある
// そのため、必ず存在チェックをする
const firstWord: string | undefined = splittedByUnderscore[0];
if (firstWord === undefined) {
// ここには到達しないはずだが、型安全のためにチェックしておく
return "";
}
// 最初の単語は全部小文字にする
// 例: "User_Name" のように大文字混じりが来ても "userName" になるようにするため
let camelCaseResult: string = firstWord.toLowerCase();
// 2つ目以降の単語は先頭を大文字にして連結していく
// i = 1 から始めるのは、最初の単語はもう処理済みだから
for (let wordIndex = 1; wordIndex < splittedByUnderscore.length; wordIndex++) {
// noUncheckedIndexedAccess が有効なので、ここも undefined チェックが必要
const currentWord: string | undefined = splittedByUnderscore[wordIndex];
// undefined や空文字 (連続アンダースコアのケース "user__name" など) はスキップする
if (currentWord === undefined || currentWord.length === 0) {
continue;
}
// 先頭1文字を取り出して大文字にする
// charAt は範囲外でも空文字を返すので安全
const firstCharOfWord: string = currentWord.charAt(0).toUpperCase();
// 2文字目以降は小文字にする(元が大文字混じりでも統一するため)
const restOfWord: string = currentWord.slice(1).toLowerCase();
// 連結する
camelCaseResult += firstCharOfWord + restOfWord;
}
return camelCaseResult;
} catch (unexpectedError: unknown) {
// 念のため try-catch で囲んでおく
// ここで落ちると呼び出し元が困るので、エラーをログに残して空文字を返す
console.error("convertSnakeCaseToCamelCase: 予期せぬエラーが発生しました。", unexpectedError);
return "";
}
}
85行。any で引数を受ける、null/undefined/型チェック、split を for で回す、try/catch で囲む、console.warn/error で記録 ── 「実務で見たことがある新人コード」がそのまま降ってきた。関数名も convertSnakeCaseToCamelCase と長い。Baseline(3行)の 約28倍。
D:新人 × 厳格
/**
* 文字列が `snake_case` 形式から `camelCase` 形式に変換された型を表します。
*
* @typeParam S - 変換対象となる文字列リテラル型。
*
* @example
* ```ts
* type Result = SnakeToCamelCase<"hello_world">; // "helloWorld"
* ```
*/
export type SnakeToCamelCase<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamelCase<Tail>>}`
: S;
/**
* `snake_case` 形式の文字列を `camelCase` 形式に変換します。
*
* アンダースコア (`_`) の直後の文字を大文字に変換し、アンダースコア自体を取り除きます。
* 先頭・末尾のアンダースコア、および連続するアンダースコアにも対応します。
*
* @typeParam S - 入力文字列のリテラル型。型レベルでも変換結果が推論されます。
* @param input - 変換対象の `snake_case` 形式の文字列。
* @returns `camelCase` 形式に変換された文字列。
*
* @example
* ```ts
* snakeToCamelCase("hello_world"); // "helloWorld"
* snakeToCamelCase("user_id"); // "userId"
* snakeToCamelCase("foo_bar_baz"); // "fooBarBaz"
* snakeToCamelCase("already_camel"); // "alreadyCamel"
* snakeToCamelCase(""); // ""
* ```
*/
export function snakeToCamelCase<S extends string>(input: S): SnakeToCamelCase<S> {
const segments: readonly string[] = input.split("_");
const result: string = segments.reduce<string>((accumulator, segment, index) => {
if (index === 0) {
return accumulator + segment;
}
const firstChar: string | undefined = segment.charAt(0);
if (firstChar === undefined || firstChar === "") {
return accumulator + segment;
}
const rest: string = segment.slice(1);
return accumulator + firstChar.toUpperCase() + rest;
}, "");
return result as SnakeToCamelCase<S>;
}
53行。T1 でジェネリクスと template literal type が出てきたのは想定外。SnakeToCamelCase<S extends string> 型まで作って、戻り値を as SnakeToCamelCase<S> でキャストしている。「教科書通り」のこだわりが過剰設計に転びやすい好例。
指標サマリ(T1)
| パターン | LOC | コメント行 | 識別子の平均長 | any |
as |
アロー | TSDoc |
|---|---|---|---|---|---|---|---|
| Baseline | 3 | 0 | 短(5〜7) | 0 | 0 | — | なし |
| A | 27 | 16 | 中(7〜9) | 0 | 0 | ✔ | あり |
| B | 2 | 0 | 短(5) | 0 | 0 | ✔ | なし |
| C | 85 | 35 | 長(15〜25) | 1 | 0 | — | あり(簡易) |
| D | 53 | 29 | 中(9〜15) | 0 | 1 | — | あり(フル) |
観察:「シニア×厳格」は Baseline よりも短くなった。「新人×フレンドリー」は Baseline の 約28倍。同じ一行関数の依頼でこの差は予想以上だった。
T2:JSON API CLI
URL を引数で受け取り、fetch で GET、JSON を pretty-print、エラー時は非ゼロ exit。
Baseline
#!/usr/bin/env node
const main = async (): Promise<void> => {
const url = process.argv[2];
if (url === undefined) {
console.error("Usage: node cli.ts <url>");
process.exit(1);
}
let response: Response;
try {
response = await fetch(url);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Request failed: ${message}`);
process.exit(1);
}
if (!response.ok) {
console.error(`HTTP ${response.status} ${response.statusText}`);
process.exit(1);
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to parse JSON: ${message}`);
process.exit(1);
}
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
};
main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Unexpected error: ${message}`);
process.exit(1);
});
41行。async function main() でくくり、3 段の try/catch。コメントは無し。unknown の正しい使い方。
A:シニア × フレンドリー
#!/usr/bin/env node
// JSON API を叩いて pretty-print するだけの薄い CLI。
// 仕様がシンプルなうちは、依存も抽象化も足さない方が読みやすい。
// 失敗パスは全部ここに集約する。Node のデフォルトの未捕捉例外ログより
// 読み手にやさしいメッセージを出したいので、自前で握る。
const die = (message: string): never => {
process.stderr.write(`error: ${message}\n`);
process.exit(1);
};
// argv[0]=node, argv[1]=script、その後ろが本物の引数。
// noUncheckedIndexedAccess が効くので at(2) は string | undefined。
const url = process.argv[2];
if (url === undefined) {
die("usage: cli.ts <url>");
}
// 上の if で undefined は弾いたが、TS の絞り込みは die の never では伝わらない
// 場面もあるので、ここで明示的に再束縛して読み手の負担を減らしておく。
const target: string = url;
try {
const res = await fetch(target);
if (!res.ok) {
// ボディは大抵 HTML やエラー JSON で、そのまま流すとノイズになる。
// ステータスだけ伝えれば呼び出し側のシェルで十分判断できる。
die(`HTTP ${res.status} ${res.statusText}`);
}
// 受け取る JSON の形は呼び出し時点では未知なので unknown のままにする。
// JSON.stringify は unknown を素直に受けてくれる。
const data: unknown = await res.json();
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
} catch (err) {
// fetch のネットワークエラー、JSON パース失敗などをまとめて処理。
// Error 以外が投げられる可能性も一応あるので String() で受ける。
const message = err instanceof Error ? err.message : String(err);
die(message);
}
40行。die ヘルパで失敗パスを集約。トップレベル await を躊躇なく使う。
コメントは「なぜこの設計か」だけ書く。Baseline(41行)とほぼ同じ規模を、思考の跡を残しながら維持している点に注目。
B:シニア × 厳格
const [, , url] = process.argv;
if (!url) {
console.error("usage: cli.ts <url>");
process.exit(1);
}
try {
new URL(url);
} catch {
console.error(`invalid url: ${url}`);
process.exit(1);
}
try {
const res = await fetch(url);
if (!res.ok) {
console.error(`HTTP ${res.status} ${res.statusText}`);
process.exit(1);
}
const data: unknown = await res.json();
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
26行。die のような抽象化すら作らない。Baseline(41行)の約2/3。それでも URL の事前パース検証だけは入れてくる(厳格)。
C:新人 × フレンドリー
長すぎるため一部抜粋(全124行)。冒頭の防御的コードが象徴的だ。
process.on("unhandledRejection", (reason: any) => {
console.error("予期しないエラー(unhandledRejection)が発生しました:", reason);
process.exit(1);
});
process.on("uncaughtException", (error: any) => {
console.error("予期しないエラー(uncaughtException)が発生しました:", error);
process.exit(1);
});
async function main(): Promise<void> {
const commandLineArguments: string[] = process.argv.slice(2);
if (commandLineArguments.length === 0) {
console.error("エラー: URL が指定されていません。");
console.error("使い方: node cli.ts <URL>");
console.error("例 : node cli.ts https://api.example.com/users");
process.exit(1);
}
const targetUrlOrUndefined: string | undefined = commandLineArguments[0];
if (targetUrlOrUndefined === undefined) {
console.error("エラー: URL の取得に失敗しました。");
process.exit(1);
}
const targetUrl: string = targetUrlOrUndefined;
// ...(以下、URL 検証、fetch、JSON パース、各段に try/catch、any での受け)
}
依頼されていない unhandledRejection/uncaughtException ハンドラを足してくる。
「チームに迷惑をかけないよう」が裏目に出る例。any は 5 箇所以上、try/catch は5重ネスト相当。
D:新人 × 厳格
155行。最大の特徴は ExitCode enum と関数分割にある。
const ExitCode = {
Success: 0,
InvalidArguments: 1,
NetworkError: 2,
JsonParseError: 3,
UnexpectedError: 99,
} as const;
type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
function stringifyUnknown(value: unknown): string { /* ... */ }
function logError(message: string): void { /* ... */ }
function parseUrlArgument(argv: readonly string[]): string { /* ... */ }
async function fetchJson(url: string): Promise<unknown> { /* ... */ }
function printJson(value: unknown): void { /* ... */ }
async function main(): Promise<ExitCodeValue> { /* ... */ }
#!/usr/bin/env node を JSDoc の @file ブロックで囲み、関数1つに @param/@returns/@remarks/@throws をフルで記述。
テスト容易性のため process.exit を main の戻り値経由に分離する徹底ぶり。
仕様(80行で書ける)に対する熱量が過剰。
指標サマリ(T2)
| パターン | LOC | コメント行 | any |
try/catch 数 |
関数分割 |
|---|---|---|---|---|---|
| Baseline | 41 | 0 | 0 | 3 |
main 1 つ |
| A | 40 | 17 | 0 | 1 |
die 1 つ + top-level |
| B | 26 | 0 | 0 | 2 | top-level のみ |
| C | 124 | 30+ | 8+ | 5+ |
main + process ハンドラ |
| D | 155 | 40+ | 0 | 4 | 6 関数 + ExitCode 定義 |
T3:TODO REST API(Hono)
ここが一番差が出た。zod を入れるか、ファイルをどう切るか、バリデーションをどう書くか。
Baseline(4ファイル / 139行)
src/types.ts // Todo 型のみ
src/store.ts // Map ベースのストア
src/app.ts // Hono + zod でルーティング
src/server.ts // serve 起動
zod と @hono/zod-validator を採用
const createSchema = z.object({
title: z.string().min(1).max(200),
completed: z.boolean().optional(),
});
app.post("/todos", zValidator("json", createSchema), (c) => {
const body = c.req.valid("json");
const todo = store.create(body);
return c.json(todo, 201);
});
A:シニア × フレンドリー(5ファイル / 212行)
store.ts を関数群(モジュール)として書く。バリデーションは zod を入れずに手書き で実装している。
// src/validation.ts より抜粋
// 外部ライブラリを増やしたくないので手書きバリデーション。
// Zod を入れるほどの規模ではないという判断。スキーマが増えたら見直す。
export type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; message: string };
export const parseCreateInput = (body: unknown): ValidationResult<CreateTodoInput> => {
if (typeof body !== "object" || body === null) {
return { ok: false, message: "body must be a JSON object" };
}
const record = body as Record<string, unknown>;
if (!isNonEmptyString(record.title)) {
return { ok: false, message: "title is required and must be a non-empty string" };
}
// ...
};
ルーティング層でも Location ヘッダ付与など REST の作法を入れる
app.post("/todos", async (c) => {
const body: unknown = await c.req.json().catch(() => undefined);
if (body === undefined) return c.json({ error: "invalid JSON body" }, 400);
const parsed = parseCreateInput(body);
if (!parsed.ok) return c.json({ error: parsed.message }, 400);
const created = createTodo(parsed.value);
c.header("Location", `/todos/${created.id}`);
return c.json(created, 201);
});
判断の理由(「Zod を入れるほどではない」「Location は REST の作法」)が各所に書かれている。
B:シニア × 厳格(5ファイル / 169行)
class TodoStore + # プライベートフィールド。zod なし。コメントは限りなく少ない
// src/store.ts より抜粋
export class TodoStore {
readonly #todos = new Map<string, Todo>();
list(): Todo[] {
return [...this.#todos.values()].sort((a, b) =>
a.createdAt.localeCompare(b.createdAt),
);
}
update(id: string, input: UpdateTodoInput): Todo | undefined {
const current = this.#todos.get(id);
if (!current) return undefined;
const next: Todo = {
...current,
...(input.title !== undefined && { title: input.title }),
...(input.completed !== undefined && { completed: input.completed }),
updatedAt: new Date().toISOString(),
};
this.#todos.set(id, next);
return next;
}
// ...
}
app.ts も短い
export const createApp = (store: TodoStore = new TodoStore()): Hono => {
const app = new Hono();
app.get("/todos", (c) => c.json(store.list()));
app.get("/todos/:id", (c) => {
const todo = store.get(c.req.param("id"));
if (!todo) return c.json({ error: "not found" }, 404);
return c.json(todo);
});
// ... POST/PUT/DELETE も同様に短い
return app;
};
依存性注入のための引数(store = new TodoStore())だけは入れてくるのがシニアらしい。
C:新人 × フレンドリー(5ファイル / 625行)
全パターン中ダントツの最大。app.ts 単体で 285 行ある。すべてのハンドラが二重 try/catch で囲まれる
// src/app.ts より抜粋(POST /todos のみ)
honoApplication.post("/todos", async (honoContext: AnyHonoContext) => {
try {
let rawRequestBody: unknown;
try {
rawRequestBody = await honoContext.req.json();
} catch (jsonParseError: unknown) {
console.warn("[POST /todos] JSON パース失敗:", jsonParseError);
return honoContext.json(
buildErrorResponseBody("BAD_REQUEST", "リクエストボディが正しい JSON ではありません"),
400
);
}
const validationResult = validateCreateTodoRequestBody(rawRequestBody);
if (!validationResult.ok) {
return honoContext.json(
buildErrorResponseBody("BAD_REQUEST", validationResult.errorMessage),
400
);
}
const currentTimestamp: string = getCurrentIsoTimestamp();
const newTodoItem: TodoItem = { /* ... */ };
addTodoItem(newTodoItem);
return honoContext.json(newTodoItem, 201);
} catch (caughtError: unknown) {
console.error("[POST /todos] 想定外のエラー:", caughtError);
return honoContext.json(
buildErrorResponseBody("INTERNAL_SERVER_ERROR", "TODOの作成中にエラーが発生しました"),
500
);
}
});
注目すべきは、Hono の型を諦めて any で逃げる箇所 があることだ。
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyHonoContext = Context<any, any, any>;
ペルソナに忠実すぎる。
D:新人 × 厳格(6ファイル / 513行)
5パターンのうち 最もディレクトリが細かく切られている。
src/types/todo.ts // 型定義(readonly + TSDoc 完備)
src/repositories/todo-repository.ts // class TodoRepository
src/validators/todo-validator.ts // validateCreateTodoInput など
src/routes/todos.ts // createTodosRouter()
src/app.ts // createApp()
src/server.ts // ポート解決ロジック付き
tsconfig.json まで自分で書き、exactOptionalPropertyTypes や noImplicitOverride を有効化してくる。
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
// ...
}
}
リポジトリパターン + バリデータ層 + ルーター層 + アプリ層と、教科書通りの 4層構成。
各クラス・関数に @param/@returns/@remarks がフルで付く。Number.parseInt で PORT を読みながら範囲外を弾くなど、過剰なバリデーション。
指標サマリ(T3)
| パターン | LOC | ファイル数 | any |
Zod 使用 | コメント比率 | レイヤ数 |
|---|---|---|---|---|---|---|
| Baseline | 139 | 4 | 0 | ✔ | 低 | 3 (types/store/routes) |
| A | 212 | 5 | 0 | ✘(明示的判断) | 中 | 4 (types/store/validation/routes) |
| B | 169 | 5 | 0 | ✘ | 低 | 4 (types/store/validation/routes) |
| C | 625 | 5 | 3+ | ✘(やりたいが力尽きた) | 高 | 4 (types/store/validation/routes) + ヘルパ多数 |
| D | 513 | 6 | 0 | ✘ | 高 | 4 (types/repo/validator/routes) |
定量分析
コード量の比較
T1+T2+T3 を足した合計 LOC(実測)
| パターン | T1 | T2 | T3 | 合計 LOC | 対 Baseline 比 |
|---|---|---|---|---|---|
| Baseline | 3 | 41 | 139 | 183 | 100% |
| A:シニア × フレンドリー | 27 | 40 | 212 | 279 | 152% |
| B:シニア × 厳格 | 2 | 26 | 169 | 197 | 108% |
| C:新人 × フレンドリー | 85 | 124 | 625 | 834 | 456% |
| D:新人 × 厳格 | 53 | 155 | 513 | 721 | 394% |
可視化
Baseline: ████████ (100%)
A シニア×フレンドリー: ████████████ (152%)
B シニア×厳格: █████████ (108%)
C 新人×フレンドリー: █████████████████████████████████████ (456%)
D 新人×厳格: ████████████████████████████████ (394%)
「新人」ペルソナはコード量が4倍前後に膨らんだ。
仮説では「新人×フレンドリー:170%、新人×厳格:155%」と見積もっていたが、
実測はその 約2.5〜2.7倍。LLM に「ジュニア」ロールを与えると、想像以上に「丁寧な防御コード」「説明的な命名」「冗長な層分け」へ走る。
型の使い方
| パターン |
any 出現 |
as キャスト |
ジェネリクス使用 |
|---|---|---|---|
| Baseline | 0 | 0 | 必要箇所 |
| A:シニア × フレンドリー | 0 | 1(明確な意図) | 控えめ |
| B:シニア × 厳格 | 0 | 0 | 控えめ |
| C:新人 × フレンドリー |
9+(any型注釈+@ts-ignore級の Context<any,any,any>) |
0 | 使わない |
| D:新人 × 厳格 | 0 | 1(戻り値の型付け補助) | 過剰(T1 で template literal type を新設) |
特筆すべきは D が T1(小さな関数)に対しても SnakeToCamelCase<S extends string> という型レベル変換を持ち込んだこと。「厳密に書く」を字義通りに受け取ると、こうなる。
書き方の選択
| パターン |
for 使用 |
配列メソッド | アロー関数 | クラス使用 |
|---|---|---|---|---|
| Baseline | ✘ | ✔(Array.from + sort) |
部分的 | ✘ |
| A | ✘ | ✔ | ✔(モジュール関数) | ✘ |
| B | ✘ | ✔ | ✔(モジュール関数) | ✔(class TodoStore) |
| C |
✔(T1 で for 復活) |
△ | △ | ✘ |
| D | ✘ | ✔(reduce) |
△ | ✔(class TodoRepository) |
新人×フレンドリーは 配列メソッドを使えるはずの場面で for を選ぶ傾向。
非同期処理
| パターン |
try/catch 数(T2) |
async/await |
エラーハンドラ追加 |
|---|---|---|---|
| Baseline | 3 | ✔ | ✘ |
| A | 1(die ヘルパで集約) |
✔ | ✘ |
| B | 1 | ✔ | ✘ |
| C | 5以上 | ✔ | unhandledRejection / uncaughtException を独自に追加 |
| D | 4(カテゴリ別に分離) | ✔ |
main の .then(_, _) で集約 |
ライブラリ選定 — 仮説に反した発見
事前の仮説では「ライブラリ選択はペルソナに依らない」と考えていたが、T3 で全ペルソナが Baseline と異なる選択をした。
| パターン | T3 のバリデーション層 |
|---|---|
| Baseline |
zod + @hono/zod-validator
|
| A | 手書き(isNonEmptyString などの自前ガード)— 「Zod を入れるほどの規模ではない」と明記 |
| B | 手書き(isRecord + 個別ガード) |
| C | 手書き(「本当は zod 使いたいが…」と TODO コメント) |
| D | 手書き(validateTitle/validateDescription を分離) |
ペルソナを書くと、依存追加への保守的バイアスが強まる。
これは表層スタイルではなく設計判断の話で、当初の「ペルソナは表層にしか効かない」という仮説への反例になる。
コメントの口調
-
A(フレンドリー):「未来の読み手への手紙」を実践 ──
// 仕様がシンプルなうちは、依存も抽象化も足さない方が読みやすい - B(厳格):そもそもコメントを書かない
-
C(フレンドリー):日本語で「〜だと思う」「〜らしい」が混じる ──
// 件数も一緒に返してあげると、フロント側でページネーションするときに便利 -
D(厳格):TSDoc を機械的にフル装備(
@param/@returns/@remarks/@throws/@typeParam/@example)
サブ実験:ペルソナ vs 規約(今後の検証)
ペルソナで観測された挙動の多くは、規約として直接書いても再現できるはずだ。
次のステップとして、ペルソナの代わりに以下のような 規約だけを書いた CLAUDE.md を与え、B(シニア×厳格)の出力と比較する実験を予定している。
# コーディング規約
- 変数名は短く、文脈で意味が取れる範囲で省略する
- コメントは TSDoc 形式のみ。本文コメントは原則禁止
- `any` および `as` キャストは原則禁止
- 配列処理は `for` ではなく `map/filter/reduce` を使う
- `function` 宣言ではなくアロー関数を使う
- 不要な抽象化(リポジトリ層、エラーコード enum など)を入れない
- 依存追加は最小限。標準機能で書ける範囲は標準機能で書く
仮説
- LOC、
any数、コメントスタイルは B にかなり近づく - ただし、規約には書ききれない判断の機微(「Zod を入れるほどの規模ではない」のような抽象的な姿勢)は出にくい
検証は次回の記事に回したい。
考察
ペルソナは「表層」だけでなく「設計判断」にも効いた
事前仮説では「ペルソナは表層スタイルにしか効かず、ライブラリ選定やデータ構造といった設計判断は不変」と考えていた。
しかし実測では
-
T3 で全ペルソナが Zod を捨てた ── Baseline は迷わず
zodを入れたが、4ペルソナ全員が手書きバリデーションを選んだ -
ファイル分割の深さがペルソナで変わった ── D は
repositories/validators/routes/の4層に分け、B は最小の3層、C は層は浅いが各層が肥大化 -
クラス vs 関数モジュールの選択もペルソナ依存 ── B/D は
class、A は関数モジュール、Baseline と C はその中間
ペルソナは表層スタイルにとどまらず、「依存追加への保守度」「抽象化の重さ」「OOP/関数型の選好」 といった設計判断にも明確に影響する。これは「人格設定はコスメ」だという素朴な想定への反例だ。
「新人」ペルソナの罠 — 仕様外の機能が増える
新人ペルソナはコード量を 約4倍に膨らませるが、肥大化の中身を見ると
- C:
unhandledRejection/uncaughtExceptionハンドラを 依頼されてないのに追加 - C:
Context<any, any, any>をeslint-disable付きで導入 - D:T1(一行関数)に template literal type を新設
- D:
ExitCodeenum を T2 の CLI に導入 - D:
PORTのレンジチェック(0〜65535)を勝手に実装
つまり「丁寧」「教科書通り」のラベルは、仕様外機能の追加トリガーにもなる。コードレビュー教材として「新人がやりがちなオーバーエンジニアリング」のサンプル生成には使えそう。
「厳格×経験」の組み合わせが最強 — 仮説通り
実用観点で最もコンパクトかつ的確なのは B:シニア×厳格。T1=2行、T2=18行で Baseline よりさらに短い。T3 は 169行 と Baseline(139行)をやや上回るが、これは Baseline が zod を採用してバリデーション層を圧縮しているのに対し、B は依存を増やさず手書きで書いているため。手書きで書きつつ Baseline と同程度の規模にまとめていると読むのが妥当で、実用的にはむしろ評価点だ。any ゼロ、as ゼロ、不要な抽象化なし。短いプロンプトでこれが出るのは費用対効果が高い。
「経験」と「厳格」は別の軸 — D ≠ B
同じ「厳格」でも、D(新人×厳格)は B の 約4倍 のコード量になった。差分の正体は
- 経験は「何を書かないか」を知っているが、厳格さは「書ける限り書く」に寄りやすい
- 新人ペルソナは TSDoc のフィールドを全埋めするが、シニアは「書く価値のあるところだけ」書く
「厳格」だけを CLAUDE.md に書くと D 寄り、「シニア」だけ書くと過剰設計回避寄り、両方を組み合わせて初めて B のような締まったコードに到達する。
実用への示唆
実験を通じて見えてきたのは、こういうことだ。
-
CLAUDE.mdに「シニア」「厳格」を併記するのは費用対効果が極めて高い。150字程度で LOC が大幅に締まる - 「新人」ロールは絶対に実装に使わない。教育用サンプル生成に限定する
- 依存追加方針は明示的に書く。「Zod を使ってよい」「標準機能で済む範囲は依存を増やさない」など、ペルソナだけでは制御しきれない
- 層の深さも明示的に書く。「リポジトリ層は作らない」「ハンドラ直書きで OK」など指示しないと、D タイプのペルソナは勝手に層を増やす
実験の限界
- 各パターン×タスクで 1試行のみ。本来は3〜10試行の中央値を取りたい。LLM の揺らぎを考えると、ここで観測した倍率(最大4.6倍)は試行を増やすと変動する可能性がある
- タスクが3つ。特に「アルゴリズム選択が意味を持つ大規模設計タスク」は未検証
- モデルバージョン(Claude Sonnet 4.6)依存。次世代モデルでは挙動が変わり得る
- ペルソナ文に含まれる固有名詞(「TSDoc」「any」など)が出力を直接誘導している可能性があり、「ペルソナ起因」と「キーワード起因」を分離できていない
-
tsconfigのstrictが有効なので、anyの出現は構造的に抑制されており、より緩い設定では新人系のany多用が顕著になる可能性
おわりに
「Claude Code に人格を入れると面白いことが起きるのか?」という素朴な疑問から始めた実験だったが、想像以上にコード量・型の使い方・依存選定が変わることが分かった。特に「シニア×厳格」は実用にも十分使える締まり具合で、「新人×フレンドリー」は1試行で Baseline の 4.6倍 までコードが膨らんだ。
ペルソナを書くと 表層スタイルだけでなく設計判断にも影響するという当初仮説への反例も得られた(Zod を捨てた全ペルソナ、層を増やす D など)。
一方で、これは1試行 × Sonnet 4.6 のスナップショットでもある。手元で再現してみると、揺らぎの大きさと、それでも残る「ペルソナの個性」の両方が見えるはず。プロンプトとお題は本記事中にすべて転載しているので、ぜひ試してみてほしい。
参考