TypeScript-React
Type-safe React components — props, generics, discriminated unions, Zod schemas, and utility types for AI-assisted development.
TypeScript-React
Type-safe React components — props, generics, discriminated unions, Zod schemas, and utility types for AI-assisted development.
Principles
1. Why TypeScript Matters for AI-Assisted Development
TypeScript makes AI-generated code significantly more reliable. When the AI generates a component with typed props, the compiler catches misuse immediately. Without types, bugs from wrong prop types, missing fields, or incorrect API response shapes go unnoticed until runtime.
TypeScript also makes AI prompts more effective — when you describe types in your prompt, the AI generates code that matches your data model precisely. Types are documentation that never drifts from the code.
Non-negotiable settings in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}2. Props Typing: interface vs type
Both work for props. Convention: use interface for props because they're extendable and produce clearer error messages:
interface ButtonProps {
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export function Button({ variant = "primary", size = "md", children, ...props }: ButtonProps) {
return <button {...props}>{children}</button>;
}Extending HTML element props:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
}This gives your component all native button attributes (type, disabled, aria-label, etc.) without listing them manually.
Children typing:
children: React.ReactNode— accepts anything renderable (most common)children: string— only accepts textchildren: (data: T) => React.ReactNode— render prop pattern
3. Generic Components
Generics let you build type-safe reusable components where the data type flows through:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage — T is inferred as User
<List
items={users}
renderItem={user => <span>{user.name}</span>} // user is typed as User
keyExtractor={user => user.id} // user is typed as User
/>Constraining generics:
interface HasId {
id: string;
}
function DataTable<T extends HasId>({ data }: { data: T[] }) {
// T must have an id property — guaranteed
return data.map(row => <tr key={row.id}>...</tr>);
}4. Discriminated Unions for Variants
Discriminated unions make impossible states unrepresentable. Use a shared type or status field as the discriminator:
type Notification =
| { type: "success"; message: string }
| { type: "error"; message: string; retryAction?: () => void }
| { type: "loading"; progress: number };
function NotificationBanner({ notification }: { notification: Notification }) {
switch (notification.type) {
case "success":
return <div className="bg-green-50 text-green-800">{notification.message}</div>;
case "error":
return (
<div className="bg-red-50 text-red-800">
{notification.message}
{notification.retryAction && (
<button onClick={notification.retryAction}>Retry</button>
)}
</div>
);
case "loading":
return <div className="bg-blue-50"><ProgressBar value={notification.progress} /></div>;
}
}TypeScript narrows the type in each case branch — retryAction is only accessible in the "error" branch.
5. Type-Safe Event Handlers
React provides typed event objects. Use them instead of any:
// Form events
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
}
// Change events — typed to the element
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
console.log(e.target.value); // string
}
// Mouse events
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}
// Keyboard events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") submit();
}For callback props, define the handler type explicitly:
interface Props {
onChange: (value: string) => void; // Not (e: ChangeEvent) => void
onSelect: (item: Item) => void; // Domain-specific, not event-specific
}6. React Utility Types
React exports useful types you should know:
// Get the props of any component
type ButtonProps = React.ComponentProps<typeof Button>;
type InputProps = React.ComponentProps<"input">;
// Get the ref type of a component
type InputRef = React.ComponentRef<typeof Input>;
// Add children to any props type
type WithChildren<T> = T & { children: React.ReactNode };
// ElementType — a component or HTML element string
type Props = {
as?: React.ElementType; // "div" | "span" | typeof CustomComponent
};
// Extract props from a component with ref
type FullButtonProps = React.ComponentPropsWithRef<"button">;
type ButtonPropsNoRef = React.ComponentPropsWithoutRef<"button">;7. Strict Mode and Strict Null Checks
strict: true in tsconfig.json enables all strict checks. The most impactful:
strictNullChecks — null and undefined are distinct types, not assignable to everything:
function getUser(id: string): User | null { ... }
const user = getUser("123");
user.name; // Error: user might be null
user?.name; // OKnoUncheckedIndexedAccess — array/object indexing returns T | undefined:
const items = ["a", "b", "c"];
const item = items[5]; // Type: string | undefined (not string)
if (item) {
console.log(item.toUpperCase()); // OK — narrowed to string
}8. as const and Literal Types
as const creates narrow literal types from values:
// Without as const — type is string[]
const colors = ["red", "green", "blue"];
// With as const — type is readonly ["red", "green", "blue"]
const colors = ["red", "green", "blue"] as const;
type Color = (typeof colors)[number]; // "red" | "green" | "blue"Template literal types for string patterns:
type Spacing = "sm" | "md" | "lg";
type Direction = "top" | "right" | "bottom" | "left";
type PaddingClass = `p${Direction}-${Spacing}`;
// "ptop-sm" | "ptop-md" | ... | "pleft-lg"9. Zod + TypeScript: Schema-First
Zod lets you define a schema once and derive both the runtime validator and the TypeScript type:
import { z } from "zod";
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["user", "admin", "moderator"]),
createdAt: z.coerce.date(),
});
// Derive the TypeScript type from the schema
type User = z.infer<typeof userSchema>;
// { id: string; name: string; email: string; role: "user" | "admin" | "moderator"; createdAt: Date }
// Runtime validation
function validateUser(data: unknown): User {
return userSchema.parse(data); // Throws on invalid
}
// Safe validation
function safeValidateUser(data: unknown) {
return userSchema.safeParse(data);
// { success: true, data: User } | { success: false, error: ZodError }
}The rule: Define Zod schemas as the single source of truth. Derive TypeScript types with z.infer. Never maintain separate type definitions and validation logic.
10. Type-Safe API Routes and Server Actions
Type safety across the client-server boundary:
// lib/validations/product.ts
import { z } from "zod";
export const createProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "books"]),
description: z.string().max(1000).optional(),
});
export type CreateProductInput = z.infer<typeof createProductSchema>;// actions/products.ts
"use server";
import { createProductSchema } from "@/lib/validations/product";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string[]> };
export async function createProduct(formData: FormData): Promise<ActionResult<{ id: string }>> {
const result = createProductSchema.safeParse({
name: formData.get("name"),
price: Number(formData.get("price")),
category: formData.get("category"),
description: formData.get("description"),
});
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors };
}
const product = await db.product.create({ data: result.data });
return { success: true, data: { id: product.id } };
}11. Avoiding any
any disables type checking. Every any is a bug waiting to happen.
Use unknown instead of any:
// BAD — any bypasses all type checking
function processData(data: any) {
return data.name.toUpperCase(); // No error, even if data has no name
}
// GOOD — unknown requires narrowing before use
function processData(data: unknown) {
if (typeof data === "object" && data !== null && "name" in data) {
return (data as { name: string }).name.toUpperCase();
}
throw new Error("Invalid data");
}Type guards for safe narrowing:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value
);
}
if (isUser(data)) {
console.log(data.email); // TypeScript knows data is User
}Zod for runtime validation — the cleanest way to go from unknown to typed:
const data = userSchema.parse(unknownData); // Throws if invalid, returns User if validLLM Instructions
Typing Components
When generating typed React components:
- Define
interface Props(orinterface [Component]Props) above the component - Extend HTML element props when wrapping native elements:
extends React.ButtonHTMLAttributes<HTMLButtonElement> - Use
React.ReactNodeforchildrenunless you need a more specific type - Destructure props in the function signature with defaults where appropriate
- Use
forwardRefwith explicit generic types:forwardRef<HTMLInputElement, Props>
Generic Components
When building reusable data components:
- Make the data type generic:
function Table<T>(props: TableProps<T>) - Constrain generics when you need specific properties:
T extends { id: string } - Let TypeScript infer the generic from usage — don't require explicit type arguments
- Use
keyof Tfor column/field selectors to ensure type safety
Zod for Runtime Validation
When working with external data (API responses, form data, URL params):
- Define a Zod schema as the single source of truth
- Derive the TypeScript type with
z.infer<typeof schema> - Use
schema.safeParse()for error handling,schema.parse()for throwing - Validate at system boundaries: API handlers, Server Actions, form submission
- Don't validate internal data passed between your own functions
Typing Events and Callbacks
When defining event handlers in components:
- For callback props, use domain types:
onSelect: (item: Item) => void - For DOM event handlers, use React's typed events:
React.ChangeEvent<HTMLInputElement> - Avoid
anyin event handlers — always type the event parameter - Use
React.FormEvent<HTMLFormElement>for form submission
Type Safety Across Boundaries
When data crosses client-server boundaries:
- Share Zod schemas between client and server
- Validate on the server even if the client validates too
- Type Server Action return values as discriminated unions:
ActionResult<T> - Use
z.inferto keep client-side types in sync with server validation
Examples
1. Generic Data Table
A fully typed data table where column definitions are constrained to the data type:
interface Column<T> {
key: keyof T & string;
header: string;
width?: string;
render?: (value: T[keyof T & string], row: T) => React.ReactNode;
sortable?: boolean;
}
interface DataTableProps<T extends { id: string }> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
className?: string;
}
export function DataTable<T extends { id: string }>({
data,
columns,
onRowClick,
className,
}: DataTableProps<T>) {
return (
<table className={cn("w-full", className)}>
<thead>
<tr className="border-b text-left text-sm text-gray-500">
{columns.map(col => (
<th key={col.key} className="px-4 py-3" style={{ width: col.width }}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => (
<tr
key={row.id}
onClick={() => onRowClick?.(row)}
className={cn(
"border-b",
onRowClick && "cursor-pointer hover:bg-gray-50",
)}
>
{columns.map(col => (
<td key={col.key} className="px-4 py-3">
{col.render
? col.render(row[col.key], row)
: String(row[col.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// Usage — columns are type-checked against User
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
createdAt: string;
}
const columns: Column<User>[] = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{
key: "role",
header: "Role",
render: (value) => (
<span className={value === "admin" ? "text-purple-600 font-medium" : ""}>
{String(value)}
</span>
),
},
{
key: "createdAt",
header: "Joined",
render: (value) => new Date(String(value)).toLocaleDateString(),
},
];
<DataTable data={users} columns={columns} onRowClick={user => router.push(`/users/${user.id}`)} />2. Discriminated Union Notification Component
A notification system where each type has its own shape:
type NotificationType =
| {
type: "success";
title: string;
message: string;
autoDismiss?: number;
}
| {
type: "error";
title: string;
message: string;
retryAction?: () => void;
errorCode?: string;
}
| {
type: "warning";
title: string;
message: string;
action?: { label: string; onClick: () => void };
}
| {
type: "info";
title: string;
message: string;
link?: { label: string; href: string };
};
const icons: Record<NotificationType["type"], React.ReactNode> = {
success: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
error: <XCircleIcon className="h-5 w-5 text-red-500" />,
warning: <AlertTriangleIcon className="h-5 w-5 text-amber-500" />,
info: <InfoIcon className="h-5 w-5 text-blue-500" />,
};
const styles: Record<NotificationType["type"], string> = {
success: "border-green-200 bg-green-50",
error: "border-red-200 bg-red-50",
warning: "border-amber-200 bg-amber-50",
info: "border-blue-200 bg-blue-50",
};
export function Notification({ notification }: { notification: NotificationType }) {
return (
<div className={cn("flex gap-3 rounded-lg border p-4", styles[notification.type])}>
{icons[notification.type]}
<div className="flex-1">
<p className="font-medium">{notification.title}</p>
<p className="mt-1 text-sm text-gray-600">{notification.message}</p>
{/* Type-specific actions — TypeScript narrows automatically */}
{notification.type === "error" && notification.retryAction && (
<button onClick={notification.retryAction} className="mt-2 text-sm text-red-600 underline">
Retry
</button>
)}
{notification.type === "warning" && notification.action && (
<button onClick={notification.action.onClick} className="mt-2 text-sm font-medium text-amber-700">
{notification.action.label}
</button>
)}
{notification.type === "info" && notification.link && (
<a href={notification.link.href} className="mt-2 inline-block text-sm text-blue-600 underline">
{notification.link.label}
</a>
)}
</div>
</div>
);
}3. Zod + React Hook Form + Server Action
End-to-end type safety from form schema to server validation:
// lib/validations/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
email: z.string().email("Invalid email address"),
subject: z.enum(["general", "support", "billing", "partnership"], {
required_error: "Please select a subject",
}),
message: z.string().min(10, "Message must be at least 10 characters").max(5000),
});
export type ContactInput = z.infer<typeof contactSchema>;// actions/contact.ts
"use server";
import { contactSchema, type ContactInput } from "@/lib/validations/contact";
type ContactResult =
| { success: true; message: string }
| { success: false; errors: Record<string, string[]> };
export async function submitContact(data: ContactInput): Promise<ContactResult> {
// Server-side validation (never trust client)
const result = contactSchema.safeParse(data);
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors };
}
await sendEmail({
to: "support@example.com",
subject: `[${result.data.subject}] from ${result.data.name}`,
body: result.data.message,
replyTo: result.data.email,
});
return { success: true, message: "Message sent successfully" };
}// components/contact-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactInput } from "@/lib/validations/contact";
import { submitContact } from "@/actions/contact";
import { useState } from "react";
export function ContactForm() {
const [serverMessage, setServerMessage] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
});
async function onSubmit(data: ContactInput) {
const result = await submitContact(data);
if (result.success) {
setServerMessage(result.message);
reset();
} else {
setServerMessage("Failed to send. Please try again.");
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label htmlFor="name" className="block text-sm font-medium">Name</label>
<input id="name" {...register("name")} className="mt-1 w-full rounded border px-3 py-2" />
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" type="email" {...register("email")} className="mt-1 w-full rounded border px-3 py-2" />
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium">Subject</label>
<select id="subject" {...register("subject")} className="mt-1 w-full rounded border px-3 py-2">
<option value="">Select a subject</option>
<option value="general">General Inquiry</option>
<option value="support">Support</option>
<option value="billing">Billing</option>
<option value="partnership">Partnership</option>
</select>
{errors.subject && <p className="mt-1 text-sm text-red-600">{errors.subject.message}</p>}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">Message</label>
<textarea id="message" rows={5} {...register("message")} className="mt-1 w-full rounded border px-3 py-2" />
{errors.message && <p className="mt-1 text-sm text-red-600">{errors.message.message}</p>}
</div>
{serverMessage && <p className="text-sm text-green-600">{serverMessage}</p>}
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
);
}4. Typed Route Params and Search Params
Type-safe params in Next.js App Router:
// app/products/[category]/page.tsx
import { z } from "zod";
import { notFound } from "next/navigation";
const categorySchema = z.enum(["electronics", "clothing", "books"]);
const searchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
sort: z.enum(["name", "price", "newest"]).default("name"),
q: z.string().optional(),
});
interface Props {
params: Promise<{ category: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function CategoryPage({ params, searchParams }: Props) {
const { category: rawCategory } = await params;
const rawSearch = await searchParams;
// Validate category
const categoryResult = categorySchema.safeParse(rawCategory);
if (!categoryResult.success) notFound();
// Validate search params
const search = searchParamsSchema.parse(rawSearch);
const products = await getProducts({
category: categoryResult.data,
page: search.page,
sort: search.sort,
query: search.q,
});
return <ProductGrid products={products} />;
}5. Template Literal Design Tokens
Type-safe design token utilities:
// lib/tokens.ts
const sizes = ["xs", "sm", "md", "lg", "xl"] as const;
type Size = (typeof sizes)[number];
const colors = ["brand", "gray", "red", "green", "blue"] as const;
type Color = (typeof colors)[number];
const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
type Shade = (typeof shades)[number];
// Template literal type for color tokens
type ColorToken = `${Color}-${Shade}`;
// Type-safe token getter
function getColorVar(token: ColorToken): string {
return `var(--color-${token})`;
}
// Usage
getColorVar("brand-500"); // OK
getColorVar("brand-550"); // Error: not a valid shade
getColorVar("purple-500"); // Error: not a valid color
// Size-aware spacing
type SpacingKey = `space-${Size}`;
const spacing: Record<SpacingKey, string> = {
"space-xs": "0.25rem",
"space-sm": "0.5rem",
"space-md": "1rem",
"space-lg": "1.5rem",
"space-xl": "2rem",
};
// Polymorphic component with typed `as` prop
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, "as">;
function Box<E extends React.ElementType = "div">({
as,
...props
}: PolymorphicProps<E>) {
const Component = as || "div";
return <Component {...props} />;
}
// Usage — type-safe prop passthrough
<Box as="a" href="/about">Link styled as Box</Box>
<Box as="button" onClick={() => {}}>Button styled as Box</Box>
<Box as="section" className="p-4">Section</Box>Common Mistakes
1. Using any for Props
Wrong:
function UserCard({ user }: { user: any }) {
return <h2>{user.name}</h2>; // No type checking — user.namee wouldn't error
}Fix:
interface User {
id: string;
name: string;
email: string;
}
function UserCard({ user }: { user: User }) {
return <h2>{user.name}</h2>; // Type-checked
}2. Unexported Shared Types
Wrong: Types defined inline, duplicated across files.
Fix: Export shared types from a central location:
// types/user.ts
export interface User { id: string; name: string; email: string; role: Role; }
export type Role = "admin" | "user" | "moderator";
// Or derive from Zod schema
export type User = z.infer<typeof userSchema>;3. any in Event Handlers
Wrong:
function handleChange(e: any) {
setName(e.target.value);
}Fix:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setName(e.target.value);
}4. Type Assertions Over Type Guards
Wrong:
const user = data as User; // No runtime check — crashes if data isn't a UserFix:
// Type guard — runtime check + type narrowing
function isUser(data: unknown): data is User {
return typeof data === "object" && data !== null && "id" in data && "email" in data;
}
if (isUser(data)) {
console.log(data.email); // Safe
}
// Or use Zod
const user = userSchema.parse(data); // Runtime validated5. Not Using strict: true
Wrong:
{
"compilerOptions": {
"strict": false
}
}Fix: Always enable strict mode. It catches entire categories of bugs:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}6. Duplicate Types and Schemas
Wrong:
// types/user.ts
interface User { name: string; email: string; }
// validations/user.ts
const userSchema = z.object({ name: z.string(), email: z.string().email() });
// These can drift apartFix: Single source of truth with Zod:
const userSchema = z.object({ name: z.string(), email: z.string().email() });
type User = z.infer<typeof userSchema>;7. Using React.FC
Wrong:
const Button: React.FC<ButtonProps> = ({ children }) => {
return <button>{children}</button>;
};React.FC was removed from Create React App templates. It adds implicit children (old behavior), doesn't support generics well, and provides no real benefit.
Fix:
function Button({ children }: ButtonProps) {
return <button>{children}</button>;
}8. Not Using satisfies
Wrong:
const config: Config = { ... }; // Widens the type — loses literal typesFix:
const config = { ... } satisfies Config; // Validates AND preserves literal typessatisfies checks that the value matches the type without widening it. You keep autocomplete for literal values.
9. No noUncheckedIndexedAccess
Wrong:
const item = items[0]; // Type: Item (but could be undefined!)
item.name; // Runtime crash if items is emptyFix: Enable noUncheckedIndexedAccess in tsconfig:
const item = items[0]; // Type: Item | undefined
if (item) {
item.name; // Safe — narrowed
}10. Unnecessary Generics
Wrong:
function getLength<T extends { length: number }>(arr: T): number {
return arr.length;
}Fix: Don't use generics when you don't need the type parameter elsewhere:
function getLength(arr: { length: number }): number {
return arr.length;
}Use generics when the type parameter appears in multiple positions (input → output, or multiple inputs that must match).
See also: React Fundamentals | Component Patterns | Forms & Validation | Data Fetching | Next.js Patterns
Last reviewed: 2026-02
By Ryan Lind, Assisted by Claude Code and Google Gemini.
Next.js Patterns
Next.js 15 App Router — Server Components, Server Actions, caching, middleware, and deployment patterns for AI-assisted development.
Component Patterns
Composition, compound components, custom hooks, headless UI, and reusable patterns for React — structured for AI-assisted development.