TypeScript Generics with Claude Code: keyof, constraints, API types
Learn safe TypeScript Generics with Claude Code: constraints, keyof, mapped types, API result types, and tsc checks.
Why generics need stricter prompts
TypeScript Generics let one function, class, or type work with many data shapes while preserving the relationship between input and output. The beginner mistake is not the letter T; it is asking Claude Code for a “generic helper” without saying which parts should stay flexible and which parts must be constrained. That vague prompt often returns code that looks reusable but quietly widens everything to any, unknown, or Record<string, unknown>.
Claude Code is useful for drafting generic helpers because it can connect a use case, a type parameter, and a test quickly. It still needs guardrails. For a production helper, ask for constraints, keyof, mapped types, API response narrowing, and a tsc check in the same task. Masa’s practical rule is simple: if a type helper affects API data, billing, forms, analytics, or a purchase CTA, it should include a failing example that proves the type is not too wide.
The mental model is:
input value -> captured as T -> key narrowed by keyof T -> shape transformed by mapped types -> contract verified by tsc
The syntax in this article was checked against the official TypeScript Handbook pages for Generics, keyof type operator, Mapped Types, and Conditional Types. For adjacent Claude Code workflow context, pair this article with TypeScript tips for Claude Code and TypeScript utility types with Claude Code.
The review checklist before code
Generics are compile-time tools. T is not a runtime variable; it is a type argument that lets the compiler remember what went into the function and what should come back. In this article, extends means “only accept types that satisfy this shape.” keyof T creates the set of property names available on T. A mapped type loops over those property names and builds a new object type.
Before letting Claude Code change a repository, give it a small contract table:
| Question | What to tell Claude Code | What to review |
|---|---|---|
What does T represent? | The domain object, DTO, or form model | The result does not lose the original type |
| What should be constrained? | K extends keyof T, E extends ApiError, or T extends object | Invalid calls fail during compilation |
| How will the contract be tested? | @ts-expect-error, Expect, or a strict tsc command | The bad example actually fails |
This table prevents a common failure: Claude Code writes a helper that compiles only because it adds a cast at the end. A cast can be acceptable when it documents a transformation TypeScript cannot infer, but it should not hide a weak design.
Use case 1: deduplicate by a safe key
The first practical example is a uniqueBy helper for API rows, CSV imports, and UI lists. If key is typed as string, callers can pass a property that does not exist. With K extends keyof T, the key must be one of the actual properties of the item type.
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));
When prompting Claude Code, say: “Use overloads, keep the key limited to keyof T, and include an @ts-expect-error call for a missing key.” Without that instruction, the model may produce key: string plus a cast such as item[key as keyof T]. That only moves the risk from the compiler to runtime.
Use case 2: model API responses without optional soup
The second example is an API response type. Many codebases use data?: T and error?: ApiError on one interface. It feels convenient, but every caller now has to ask whether both fields, one field, or neither field exists. A discriminated union makes the state explicit: success has data; failure has error; the ok field narrows the type.
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());
This pattern is a strong fit for Claude Code because the prompt can describe both the runtime validation and the type contract. For broader backend design, continue with API development with Claude Code and API testing with Claude Code.
Use case 3: build form state with mapped types
The third example is a form state model. You start with a plain business type and derive a UI state for every field. Mapped types keep the field names and field value types aligned, so email stays a string, seats stays a number, and newsletter stays a boolean.
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);
The important review point is the cast after Object.fromEntries. The cast is not a shortcut around user input; it is documenting a transformation where the same keys are preserved but TypeScript cannot infer the exact mapped type. Ask Claude Code to explain every cast it adds.
Verify with tsc and type tests
Generic examples should be verified, not merely read. Put the samples in examples/generics.ts, then use a strict compiler check.
{
"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
For compile-time assertions, use small helper types. They do not run in JavaScript, but they fail the build when the resulting type is not the one you intended.
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 review templates
Use Claude Code for review after generation. These templates keep the conversation concrete.
Template 1: Generic helper review
Review this TypeScript function.
Goal: deduplicate an input array by a selected key.
Constraints: key must be K extends keyof T. Do not use any. Include @ts-expect-error for a missing key.
Output: issues, corrected code, and the tsc command to verify it.
Template 2: API response type review
Review this API response type.
Goal: success has data, failure has error.
Constraints: avoid vague optional fields such as data?: T. Confirm that ok narrows the type.
Output: safe caller example, failure example, and extra type tests.
Template 3: mapped type review
Review this mapped type.
Goal: derive field state from an existing form model.
Constraints: explain keyof, T[K], readonly, optional properties, and any necessary cast.
Output: type flow, fragile cases, and the smallest safe fix.
Template 4: pre-PR type audit
Audit the generics, conditional types, and mapped types in this diff.
Check for: any, overly broad Record usage, unnecessary type parameters, missing @ts-expect-error tests, and missing runtime validation.
Output: blockers, minor improvements, and extra tests in priority order.
Pitfalls to catch early
The most common failures are predictable:
| Pitfall | What breaks | Safer habit |
|---|---|---|
Using any to look generic | Return types lose useful information | Capture the relationship with T |
Typing keys as string | Missing properties compile | Use K extends keyof T |
Overusing Record<string, unknown> | Specific property names disappear | Prefer object unless you need dictionary access |
| Making every API field optional | Callers cannot trust data or error | Use a discriminated union |
| Adding unexplained casts | Reviewers cannot judge safety | Document the invariant before the cast |
One subtle choice is T extends object versus T extends Record<string, unknown>. A form model usually only needs to be an object. A dictionary helper that accepts arbitrary string keys may need Record. Ask Claude Code to explain that tradeoff before accepting the generated type.
CTA: connect type safety to revenue paths
Generics are not only a language trick. Weak types in lead forms, checkout flows, API payloads, product templates, or analytics events can break the path from article reader to customer. Start with the free Claude Code cheatsheet for daily commands, use ClaudeCodeLab products when prompt packs and setup material should be reusable, and use Claude Code training and consultation when a team needs CLAUDE.md, type review rules, CI checks, and rollout guidance.
When you apply these examples to your own repository, identify the types closest to revenue first: account, billing, form, API response, and event tracking types. Ask Claude Code not only “does this compile?” but also “could this type mistake break the conversion path?”
What I verified
After trying this workflow, Masa found that separating implementation prompts from type-review prompts produced better results. First generate the helper, then ask Claude Code to audit any, missing keyof, optional-heavy API results, and absent @ts-expect-error checks. The uniqueBy and form-state examples were especially useful because tsc --noEmit --strict proves both sides of the contract: valid calls compile, and intentionally invalid calls are rejected.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.