Part 1: Why TypeScript/React Is Different
Prompting for TypeScript and React is different from prompting for general code because you're working with a type system and a component model that constrain what "correct" means. A Python function that works is done. A TypeScript function that works but uses any everywhere is not done. A React component that works but re-renders 40 times per keystroke is not done.
AI doesn't know your quality bar unless you tell it. These are the constraints that turn mediocre AI output into production-quality TypeScript/React code:
- Types are mandatory context. Always paste your type definitions in the prompt. AI generates much better code when it has the types than when it's guessing.
- Specify the React patterns. "Functional component with hooks" eliminates class components. "Use Zustand, not useState for shared state" eliminates the wrong state pattern. AI defaults to whatever it's seen most — which isn't always what you want.
- Name the anti-patterns. "No
anytypes" and "NouseEffectfor derived state" prevent the two most common AI mistakes in TypeScript/React code.
Types + Patterns + Constraints + Task. Paste the relevant types, name the patterns you want, list what to avoid, then describe what to build. This formula produces code that matches your project on the first generation — not the third.
Part 2: Type-First Prompting
The single most effective technique for TypeScript prompting: define your types first, then ask AI to implement against them. Types act as a contract — they constrain the output to something that compiles.
Define Types Before Implementation
Here are my types:
interface Task {
id: string;
title: string;
status: 'todo' | 'in-progress' | 'review' | 'done';
priority: 'low' | 'medium' | 'high' | 'urgent';
assigneeId: string | null;
projectId: string;
position: number;
createdAt: string;
completedAt: string | null;
}
interface CreateTaskInput {
title: string;
status?: Task['status'];
priority?: Task['priority'];
assigneeId?: string;
projectId: string;
}
type TaskUpdate = Partial<Omit<Task, 'id' | 'createdAt'>>;
Implement a createTask function that takes CreateTaskInput, generates an ID and timestamp, and returns a Task. Default status is 'todo', default priority is 'medium'.
AI now generates code that uses the exact types you defined. No guessing about field names, no inventing extra properties, no mismatched types. The output compiles on the first try because the contract was established upfront.
Compare this to the vague version: "Write a function that creates a task with a title, status, priority, and project ID." That prompt produces code with whatever field names AI invents — and they probably won't match your existing types.
Using Types to Infer Implementation
Here's my API response type and my Zustand store type:
// API response
interface ApiResponse<T> {
data: T;
meta?: { total: number; page: number; pageSize: number };
}
// Store
interface TaskStore {
tasks: Task[];
isLoading: boolean;
error: string | null;
pagination: { total: number; page: number; pageSize: number };
fetchTasks: (projectId: string, page?: number) => Promise<void>;
createTask: (input: CreateTaskInput) => Promise<Task>;
updateTask: (id: string, update: TaskUpdate) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
}
Implement the Zustand store. The API base URL is /api. Handle loading and error states. Use immer middleware.
The store type itself tells AI exactly what to implement — every action, every state field, every return type. AI fills in the body of each function to match the signatures you defined. You're programming AI with TypeScript.
Getting AI to Infer Types From Usage
When you have code but no types (common when working with JavaScript codebases being migrated to TypeScript):
Here's a JavaScript module with no type annotations:
[paste the module]
And here's how it's used across the codebase:
[paste: grep -n "userService\." src/ -r --include="*.js"]
Infer TypeScript interfaces for all function parameters and return types based on actual usage. Be specific — use string literals and union types where the usage implies specific values.
The instruction "use string literals and union types where the usage implies specific values" prevents AI from typing everything as string. If every call passes 'admin' or 'user', the type should be 'admin' | 'user', not string.
Pro Tip: Paste Types in Every Prompt
Even if AI "should" remember the types from earlier in the conversation, paste them again. Context drift is real — by message 15, AI may have subtly changed a field name or type. Re-pasting your types costs 5 seconds and prevents 10 minutes of debugging mismatched types.
Part 3: Component Prompts
React components have patterns that AI needs explicit guidance on. Without constraints, AI generates components that work but don't follow modern best practices.
The Base Component Prompt
Create a TaskCard component.
Props:
interface TaskCardProps {
task: Task;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
isDragging?: boolean;
}
Requirements:
- Functional component, named export
- Show title, priority badge (color-coded), assignee name if assigned, due date if set
- Priority colors: low=gray, medium=blue, high=amber, urgent=red
- Edit button and delete button with confirm dialog
- isDragging adds opacity-50 and shadow-lg classes
- Use Tailwind for all styling
- No inline styles, no CSS modules
Every component prompt should include: the Props interface (typed), visual requirements, interaction behavior, styling approach, and anti-patterns to avoid. This produces a production-ready component on the first generation.
Custom Hook Prompts
Create a useDebounce hook.
// Desired signature:
function useDebounce<T>(value: T, delay: number): T
Requirements:
- Generic type parameter for any value type
- Clean up timeout on unmount and when value changes
- Return the debounced value
- Include JSDoc with usage example
For hooks, always include the desired function signature. This is the strongest constraint you can give AI — it defines the inputs, outputs, and generic parameters. AI fills in the implementation to match.
Composition Patterns
I have a Modal component that accepts children. Create a ConfirmDialog that uses it.
Here's the Modal component:
[paste Modal.tsx]
interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string; // default: "Confirm"
cancelLabel?: string; // default: "Cancel"
variant?: 'danger' | 'warning' | 'info'; // affects confirm button color
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
}
Compose ConfirmDialog using the existing Modal. Don't duplicate Modal's functionality.
The key instruction is "Don't duplicate Modal's functionality." Without it, AI often generates a standalone dialog that reimplements the backdrop, positioning, and close behavior — ignoring the Modal you already have.
Preventing Common React Anti-Patterns
Add these constraints to your prompts when you see AI falling into them:
- "Don't use
useEffectfor derived state" — AI loves putting calculations in useEffect that should just beconst filtered = items.filter(...)in the render body. This prevents unnecessary re-renders and complexity. - "Don't create state for values that can be computed" — If
fullNamecan be derived fromfirstNameandlastName, it shouldn't be in state. - "Memoize expensive computations with
useMemo, notuseEffect+setState" — The difference between a component that renders once and one that renders in an infinite loop. - "Use
useCallbackfor event handlers passed as props" — Prevents child components from re-rendering when the parent renders. - "No
indexas key in lists that can be reordered" — AI defaults tokey={index}. Usekey={item.id}.
Part 4: State & Data Flow
State management and data fetching are where AI prompts need the most precision. These patterns affect the entire application — a wrong approach in a store propagates everywhere.
Zustand Store Prompt
Create a Zustand store for project management.
interface ProjectStore {
projects: Project[];
activeProjectId: string | null;
isLoading: boolean;
error: string | null;
// Actions
fetchProjects: () => Promise<void>;
createProject: (name: string) => Promise<Project>;
archiveProject: (id: string) => Promise<void>;
setActiveProject: (id: string) => void;
}
Implementation rules:
- Use immer middleware for immutable updates
- API calls use this pattern: set loading → fetch → set data → clear loading. On error: set error message, clear loading.
- fetchProjects filters out archived projects
- createProject optimistically adds to list, reverts on API failure
- Auth token comes from useAuthStore.getState().token
- API base URL: /api
The critical details: specifying the loading/error pattern, the optimistic update behavior, and how to get the auth token. Without these, AI will implement a working store that handles none of these correctly.
Data Fetching With React Query
Create a custom hook for fetching and mutating tasks using TanStack Query (React Query).
// Query keys
const taskKeys = {
all: ['tasks'] as const,
byProject: (projectId: string) => [...taskKeys.all, projectId] as const,
detail: (id: string) => [...taskKeys.all, 'detail', id] as const,
};
// Desired hooks:
// useTasks(projectId) — returns { tasks, isLoading, error }
// useTask(id) — returns { task, isLoading, error }
// useCreateTask() — returns mutation with optimistic update
// useUpdateTask() — returns mutation with optimistic update
// useDeleteTask() — returns mutation with optimistic removal
Rules:
- Mutations invalidate relevant queries on success
- Optimistic updates for create/update/delete
- Roll back optimistic updates on error
- Type everything — no any
- Stale time: 30 seconds for lists, 60 seconds for details
This prompt is dense, but each line prevents a specific mistake. The query key factory prevents cache invalidation bugs. The stale times prevent over-fetching. The optimistic update requirement prevents the jarring "flash of loading state" on every mutation.
Form State With React Hook Form
Create a task creation form using React Hook Form + Zod.
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
assigneeId: z.string().uuid().nullable().default(null),
dueDate: z.string().date().nullable().default(null),
});
type CreateTaskForm = z.infer<typeof createTaskSchema>;
Requirements:
- Use zodResolver for validation
- Show inline error messages below each field
- Disable submit button while submitting
- Reset form after successful submission
- onSubmit prop receives validated data
- Priority uses radio buttons, not a select
- Tailwind styling
Pasting the Zod schema is the key. It defines every field, every validation rule, every default value, and every type — and the form AI generates will match exactly.
Part 5: API & Backend Prompts
TypeScript on the backend follows the same type-first principle, but with additional concerns: validation, error handling, and database queries.
Express Route With Validation
Create an Express route for creating tasks.
// Shared types (used by both frontend and backend)
interface CreateTaskInput {
title: string;
status?: 'todo' | 'in-progress' | 'review' | 'done';
priority?: 'low' | 'medium' | 'high' | 'urgent';
assigneeId?: string;
projectId: string;
}
// Zod schema for validation
const createTaskSchema = z.object({
title: z.string().min(1).max(200),
status: z.enum(['todo', 'in-progress', 'review', 'done']).default('todo'),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
assigneeId: z.string().uuid().optional(),
projectId: z.string().uuid(),
});
Route requirements:
- POST /api/tasks
- Validate body with Zod schema, return 400 with specific field errors
- Check that projectId belongs to the authenticated user (userId from req.user)
- Use taskRepo.create() for database insertion
- Return 201 with { data: task }
- Wrap everything in try/catch, return 500 with error code on failure
Repository Pattern
Create a task repository using better-sqlite3.
interface TaskRepo {
findByProject(projectId: string): Task[];
findById(id: string): Task | null;
create(input: CreateTaskInput & { id: string; createdAt: string }): Task;
update(id: string, changes: TaskUpdate): Task | null;
delete(id: string): boolean;
move(id: string, status: Task['status'], position: number): Task | null;
}
Rules:
- All queries parameterized (no string interpolation)
- SELECT specifies columns (no SELECT *)
- create returns the full created row
- update returns null if the row doesn't exist
- move updates both status and position, sets completedAt when moved to 'done'
- Use db.prepare() for all queries
Specifying "no string interpolation" and "no SELECT *" explicitly prevents the two most common security and data exposure issues in AI-generated database code.
Error Handling Chain
Create a typed error handling system for the API.
class AppError extends Error {
constructor(
public code: string, // e.g. "TASK_CREATE_TITLE_REQUIRED"
public statusCode: number, // e.g. 400
public details?: Record<string, string> // field-level errors
) {
super(code);
this.name = 'AppError';
}
}
// Express error handler middleware signature:
// (err: Error, req: Request, res: Response, next: NextFunction) => void
Create:
1. The AppError class (above)
2. Error factory functions: notFound(resource), validation(details), unauthorized(), forbidden()
3. Express error handler middleware that formats AppError vs unknown errors
4. An asyncHandler wrapper that catches promise rejections and forwards to the error handler
This prompt builds a complete error handling pipeline from the types outward. The factory functions prevent inconsistent error formats across routes. The asyncHandler wrapper prevents the "unhandled promise rejection" issue that plagues Express apps.
Part 6: The Prompt Library
Copy-paste-ready prompts for common TypeScript/React tasks. Customize the bracketed sections for your project.
New Component
Create a [ComponentName] component.
Props:
[paste your Props interface]
Requirements:
- Functional component, named export
- [describe visual layout and elements]
- [describe interactions and behavior]
- [describe conditional rendering]
- Use Tailwind for styling
- No any types
- Include JSDoc on the component
Custom Hook
Create a custom hook: [hookName]
Signature:
[paste desired function signature with generics]
Behavior:
- [describe what the hook manages]
- [describe side effects and cleanup]
- [describe return values]
Constraints:
- Clean up on unmount
- Handle race conditions if async
- Type all parameters and return values
- Include JSDoc with usage example
Zustand Store
Create a Zustand store for [domain].
Store interface:
[paste your store interface with all state and actions]
Implementation rules:
- Use immer middleware
- Loading pattern: set loading → fetch → set data → clear loading
- Error pattern: set error message, clear loading
- Auth token from: [how to get it]
- API base URL: [url]
- [any optimistic update requirements]
- [any specific caching or deduplication needs]
API Route
Create an Express route: [METHOD] [path]
Input validation:
[paste Zod schema]
Authorization:
- [who can access this route]
- [what ownership checks are needed]
Implementation:
- Use [repoName] for database access
- Return format: { data: [type] } on success
- Error format: { error: string, code: string }
- [specific business logic requirements]
Error handling:
- 400 for validation failures with field-level errors
- 401/403 for auth failures
- 404 for missing resources
- 500 with error code for unexpected failures
Test Suite
Write tests for [module/function name].
Source:
[paste the implementation]
Test framework: Vitest
Test structure:
- describe block per public function
- Happy path for each function
- Error/edge cases: [list specific cases]
- Mock these dependencies: [list]
Patterns:
- Use factories for test data, not raw objects
- Assert specific values, not just truthiness
- Test error messages, not just that errors are thrown
- [any project-specific test conventions]
Type Extraction
Here's a JavaScript module with no types:
[paste the module]
Here's how it's used:
[paste grep results of usage across codebase]
Generate TypeScript interfaces for:
- All function parameters
- All return types
- Any internal data structures
Rules:
- Use string literals and union types where usage implies specific values
- Use readonly for arrays that aren't mutated
- Use optional (?) for parameters that aren't always provided
- No any — use unknown if the type truly can't be determined
Refactor for Performance
This React component re-renders too often. Optimize it.
Current component:
[paste component]
Current re-render triggers:
[describe what causes unnecessary re-renders]
Allowed optimizations:
- useMemo for expensive computations
- useCallback for event handlers passed as props
- React.memo for child components
- Move state down to the component that needs it
Not allowed:
- Don't change the component's API (props)
- Don't change the visual output
- Don't add new dependencies
Migration: JavaScript to TypeScript
Convert this JavaScript file to TypeScript.
Source:
[paste .js file]
Usage context:
[paste imports/calls from other files]
Rules:
- Infer types from usage, not from generic patterns
- Use strict types — no any
- Use string literals and unions where values are constrained
- Add readonly where arrays aren't mutated
- Keep the exact same runtime behavior
- Keep the same exports (named/default)
- Add JSDoc where the types alone don't explain intent
TypeScript/React Prompts — Summary
- Type-first prompting — Always paste type definitions in the prompt. Types constrain AI output to something that compiles.
- The formula — Types + Patterns + Constraints + Task. In that order, every time.
- Component prompts — Include Props interface, visual requirements, interaction behavior, and styling approach. Name anti-patterns to avoid.
- State management — Paste the store interface. Specify loading/error patterns, optimistic updates, and auth token access.
- API routes — Paste Zod schemas for validation. Specify authorization checks, response formats, and error handling.
- Re-paste types every time — Context drift is real. 5 seconds of pasting prevents 10 minutes of debugging.
- Name the anti-patterns — "No any," "No useEffect for derived state," "No SELECT *." AI avoids what you explicitly prohibit.