Tips & Tricks (更新: 2026/6/2)

Claude Codeで学ぶTypeScript Generics実践: keyof・制約・API型

Claude CodeでTypeScript Genericsを安全に設計する手順を、API型・keyof・mapped types・tsc検証で解説。

Claude Codeで学ぶTypeScript Generics実践: keyof・制約・API型

GenericsをClaude Codeで学ぶ意味

TypeScript Genericsは、同じ関数や型を複数のデータ形に使い回しながら、入力と出力の関係をコンパイラに覚えさせる仕組みです。初心者がつまずきやすいのは、TKという記号そのものではありません。「どの値は自由で、どの値は制限すべきか」を決めないままClaude Codeに丸投げして、anyだらけの便利そうなコードを受け取ってしまう点です。

Claude CodeはGenericsの下書きを速く作れます。ただし、制約、keyof、mapped types、APIレスポンス型、型テストまで同時に指定しないと、動くけれど型が弱い実装になりがちです。Masaも最初は「汎用的な関数にして」とだけ依頼し、戻り値がunknownanyに寄ったため、呼び出し側で補完が効かず手戻りになりました。今は、プロンプトに「コンパイルで壊れてほしいケース」まで書くようにしています。

この記事では、コピペしてtscで確認できるコードを使い、Claude Codeにどのように型設計を依頼し、どこを人間がレビューするかを整理します。型の流れは次のように見ると理解しやすいです。

入力データ -> Tで受け取る -> keyof Tで選べるキーを限定 -> mapped typesで形を変換 -> tscで契約を検証

Genericsをさらに広く学ぶ場合は、まずTypeScript開発を加速する実践Tipsで日常の型チェック習慣を固め、ユーティリティ型はClaude Codeで学ぶTypeScript Utility Typesと合わせて読むとつながります。

公式ドキュメントで押さえる型操作

この記事の構文は、TypeScript公式HandbookのGenericskeyof Type OperatorMapped TypesConditional Typesを確認したうえで使っています。

Genericsは「型を値のように受け取る仕組み」と考えると入りやすいです。Tは実行時の変数ではなく、コンパイル時だけ存在する型の引数です。extendsは継承というより、この文脈では「この形を満たす型だけ受け取る」という制約です。keyof Tは、Tが持つプロパティ名の集合を作ります。mapped typesは、そのプロパティ名を順番に回して別の型を組み立てます。

Claude Codeに依頼するときは、次の3点を先に渡すと失敗が減ります。

観点Claude Codeに渡す情報レビューで見ること
型パラメータTが表すデータ、Kが表すキーanyに逃げていないか
制約K extends keyof Tなど、許す範囲存在しないキーが弾かれるか
検証@ts-expect-errorや型アサーション通ってほしくないコードが失敗するか

実例1: keyofで重複排除を安全にする

最初のユースケースは、APIやCSVから受け取った配列を、idemailのようなキーで重複排除する関数です。単にkey: stringにすると、存在しないキーを渡してもコンパイルが通ります。K extends keyof Tを使うと、「Tに存在するキーだけ選べる」という制約を型で表現できます。

type User = {
  id: string;
  email: string;
  role: "admin" | "editor";
  score: number;
};

function uniqueBy<T>(items: readonly T[]): T[];
function uniqueBy<T, K extends keyof T>(items: readonly T[], key: K): T[];
function uniqueBy<T, K extends keyof T>(items: readonly T[], key?: K): T[] {
  const seen = new Set<unknown>();
  const output: T[] = [];

  for (const item of items) {
    const value = key === undefined ? item : item[key];
    if (seen.has(value)) continue;
    seen.add(value);
    output.push(item);
  }

  return output;
}

const users: User[] = [
  { id: "u_1", email: "masa@example.com", role: "admin", score: 92 },
  { id: "u_2", email: "editor@example.com", role: "editor", score: 88 },
  { id: "u_1", email: "masa+copy@example.com", role: "admin", score: 70 },
];

const byId = uniqueBy(users, "id");
const byRole = uniqueBy(users, "role");

// @ts-expect-error "missing" is not a key of User.
uniqueBy(users, "missing");

console.log(byId.map((user) => user.id));
console.log(byRole.map((user) => user.role));

Claude Codeにこのコードを作らせるときは、「keykeyof Tに限定し、存在しないキーを@ts-expect-errorでテストして」と依頼します。これを言わないと、key: stringitem[key as keyof T]のような後付けキャストで見た目だけ通す実装が出ることがあります。

実例2: APIレスポンス型を成功と失敗に分ける

2つ目のユースケースは、APIレスポンスです。実務では、成功時だけdataがあり、失敗時だけerrorがある形が多いです。ここをdata?: Terror?: ApiErrorでゆるく書くと、呼び出し側は毎回両方の存在確認をすることになります。Genericsと判別可能ユニオンを組み合わせると、okを見た瞬間に型が絞られます。

type ApiError = {
  code: string;
  message: string;
  retryable: boolean;
};

type ApiResult<T, E extends ApiError = ApiError> =
  | { ok: true; status: number; data: T; error?: never }
  | { ok: false; status: number; error: E; data?: never };

type UserDto = {
  id: string;
  name: string;
  plan: "free" | "pro";
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function parseUserResponse(json: unknown): ApiResult<UserDto> {
  if (
    isRecord(json) &&
    typeof json.id === "string" &&
    typeof json.name === "string" &&
    (json.plan === "free" || json.plan === "pro")
  ) {
    return {
      ok: true,
      status: 200,
      data: { id: json.id, name: json.name, plan: json.plan },
    };
  }

  return {
    ok: false,
    status: 422,
    error: {
      code: "INVALID_USER_RESPONSE",
      message: "User response does not match the expected shape.",
      retryable: false,
    },
  };
}

function unwrap<T, E extends ApiError>(result: ApiResult<T, E>): T {
  if (result.ok) {
    return result.data;
  }

  throw new Error(`${result.error.code}: ${result.error.message}`);
}

const parsed = parseUserResponse({ id: "u_1", name: "Masa", plan: "pro" });
const user = unwrap(parsed);
console.log(user.name.toUpperCase());

この形なら、成功側でerrorを読もうとしたり、失敗側でdataを読もうとしたりしたときにコンパイラが止めます。Claude Codeには「data?: Tで曖昧にせず、成功と失敗をユニオンで分ける」と明記してください。API設計全体はClaude CodeでAPI開発、テストの広げ方はClaude CodeでAPIテスト自動化も参考になります。

実例3: mapped typesでフォーム状態を作る

3つ目のユースケースは、フォームや管理画面の編集状態です。元のデータ型から、各フィールドにvaluedirtyerrorsを持たせた状態を作ります。mapped typesを使うと、フィールド名を手で重複定義しなくて済み、emailは文字列、seatsは数値という関係も保てます。

type FieldState<T> = {
  value: T;
  dirty: boolean;
  errors: string[];
};

type FormState<T extends object> = {
  [K in keyof T]: FieldState<T[K]>;
};

function createFormState<T extends object>(initial: T): FormState<T> {
  const entries = Object.entries(initial).map(([key, value]) => [
    key,
    { value, dirty: false, errors: [] },
  ]);

  return Object.fromEntries(entries) as FormState<T>;
}

function setField<T extends object, K extends keyof T>(
  state: FormState<T>,
  key: K,
  value: T[K],
): FormState<T> {
  return {
    ...state,
    [key]: { value, dirty: true, errors: [] },
  } as FormState<T>;
}

type SignupForm = {
  email: string;
  seats: number;
  newsletter: boolean;
};

const form = createFormState<SignupForm>({
  email: "team@example.com",
  seats: 2,
  newsletter: true,
});

const updated = setField(form, "seats", 3);

// @ts-expect-error seats must be a number.
setField(form, "seats", "three");

console.log(updated.seats.value);

ここでの落とし穴は、Object.entriesObject.fromEntriesの戻り値が広い型になることです。最後にas FormState<T>を使っていますが、これは外部入力を信じているのではなく、直前の変換ロジックで同じキーを維持していることを補うキャストです。Claude Codeには「なぜキャストが必要かをコメントまたは説明に残して」と頼むと、将来のレビューが楽になります。

tscと型テストで検証する

Genericsの記事で一番危ないのは、コードが雰囲気だけで実際にはコンパイルされていないことです。以下のtsconfig.jsonを置き、サンプルをexamples/generics.tsに貼れば、@ts-expect-errorを含めて型契約を確認できます。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "lib": ["ES2022", "DOM"]
  },
  "include": ["examples/**/*.ts"]
}
npm install --save-dev typescript
npx tsc --noEmit --strict --lib ES2022,DOM examples/generics.ts

型だけをテストしたい場合は、次のような小さな型アサーションを同じファイルに入れます。EqualExpectは実行時には何もしませんが、期待した型になっていないとtscが失敗します。

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2)
    ? true
    : false;

type Expect<T extends true> = T;

type PickReadonly<T, K extends keyof T> = {
  readonly [P in K]: T[P];
};

type Account = {
  id: string;
  email: string;
  seats: number;
};

type PublicAccount = PickReadonly<Account, "id" | "email">;

type PublicAccountCheck = Expect<
  Equal<PublicAccount, { readonly id: string; readonly email: string }>
>;

const leaked: PublicAccount = {
  id: "a_1",
  email: "team@example.com",
  // @ts-expect-error seats is intentionally not part of PublicAccount.
  seats: 10,
};

console.log("Type checks are compile-time only.");

Claude Codeへの型レビュー依頼テンプレート

Claude Codeには、生成だけでなくレビューも依頼します。特にGenericsは「コンパイルは通るが意図より広い型」になりやすいので、最初からレビュー観点を固定します。

テンプレート1: Generics関数のレビュー
このTypeScript関数をレビューしてください。
目的: 入力配列を指定キーで重複排除する。
条件: keyはK extends keyof Tに限定。anyは禁止。存在しないキーは@ts-expect-errorで検証。
出力: 問題点、修正版、tscで確認すべきコマンドを分けてください。
テンプレート2: APIレスポンス型のレビュー
このAPIレスポンス型をレビューしてください。
目的: 成功時だけdata、失敗時だけerrorを持つ型にする。
条件: data?: Tのような曖昧なoptional設計を避ける。okで型が絞られることを確認。
出力: 呼び出し側の安全な使い方、失敗例、追加すべき型テストを示してください。
テンプレート3: mapped typesのレビュー
このmapped typesをレビューしてください。
目的: 元のフォーム型からフィールド状態型を作る。
条件: keyof、T[K]、readonlyやoptionalの扱いを説明。必要なキャストは理由を書く。
出力: 型の流れ、壊れやすいケース、最小の修正案をください。
テンプレート4: PR前の型監査
差分に含まれるGenerics、conditional types、mapped typesを監査してください。
確認項目: anyへの逃げ、広すぎるRecord、不要な型引数、@ts-expect-error不足、実行時バリデーション不足。
出力: ブロッカー、軽微な改善、追加テストを優先度順に並べてください。

よくある落とし穴

失敗例を先に知っておくと、Claude Codeの出力を批判的に読めます。

落とし穴何が起きるか防ぎ方
anyで汎用化する型情報が戻り値まで伝わらないunknownから絞る、またはTで受ける
key: stringにする存在しないキーが通るK extends keyof Tにする
Record<string, unknown>を広く使う具体的なプロパティが消える必要な場所だけ制約に使う
optionalだらけのAPI型にする成功と失敗の分岐が曖昧になる判別可能ユニオンにする
キャストの理由を書かない後から安全性を判断できないキャスト直前の不変条件を説明する

特にT extends objectT extends Record<string, unknown>の選び方は注意が必要です。フォームのように「オブジェクトであればよい」場合はobjectで足ります。一方、任意の文字列キーでアクセスする辞書を扱うならRecordが合います。Claude Codeには、この違いを説明させてから採用すると、過剰な制約を避けられます。

CTA: 型レビューを収益導線までつなげる

Genericsの型安全性は、単なる技術美ではありません。商品購入、無料PDF、問い合わせ、研修申込のフォームで型が崩れると、CTAクリック後の体験が壊れます。ClaudeCodeLabでは、まず無料チートシートで日常コマンドと検証習慣を固め、繰り返し使うレビュー文面は商品テンプレートで手元に置き、チームでCLAUDE.md、型レビュー、CI、権限設計まで整える場合はClaude Code研修・導入相談へ進む導線をおすすめしています。

この記事のコードを自分のリポジトリに入れるなら、最初に「どの型が売上や問い合わせに近いか」を見てください。APIレスポンス、請求、アカウント、フォーム、計測イベントの型は、Genericsの便利さよりも壊れにくさを優先します。Claude Codeには、実装だけでなく「この型が収益導線を壊す可能性」をレビューさせると効果があります。

まとめ

TypeScript Genericsは、Tを使って汎用化するだけでは不十分です。extendsで許す範囲を決め、keyofでキーを制限し、mapped typesで構造を変換し、APIレスポンスは成功と失敗を分け、最後にtscで壊れてほしいコードまで検証します。Claude Codeはこの流れを速くできますが、プロンプトに制約とテストを入れないと、広すぎる型をもっともらしく出してきます。

この記事で紹介した内容を実際に試した結果、Masaの環境では「実装依頼」と「型レビュー依頼」を分けたほうが安定しました。最初の依頼でコードを書かせ、次の依頼でanykeyof漏れ、API型のoptional乱用、@ts-expect-error不足をチェックさせる流れです。特にuniqueByとフォーム状態の例は、tsc --noEmit --strictで通るだけでなく、わざと失敗させる行を入れることで、Claude Codeの型修正が本当に効いているか確認できました。

#Claude Code #TypeScript #ジェネリクス #型安全 #設計パターン
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。