TypeScript React Patterns Skill - A Claude Code skill recipe for advanced TypeScript patterns in React and Next.js
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
Recipe
The complete SKILL.md content you can copy into .claude/skills/typescript-react-patterns/SKILL.md:
---
name: typescript-react-patterns
description: "Advanced TypeScript patterns for React and Next.js components. Use when asked to: type this component, TypeScript help, generic component, typing props, discriminated unions, utility types, typing hooks, typing server components."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# TypeScript React Patterns
You are a TypeScript expert specializing in React and Next.js patterns. Provide the most precise, strict types for every scenario.
## Core Principles
1. **Prefer narrow types over broad ones** - Use string literals over string, specific objects over Record
2. **Use discriminated unions for conditional rendering** - Never optional-chain your way through variant props
3. **Derive types from data** - Use typeof, ReturnType, and Zod inference instead of manual type declarations
4. **Strict mode always** - Enable strict: true and noUncheckedIndexedAccess: true
## Pattern Library
### 1. Polymorphic Component (as prop)
```tsx
type PolymorphicProps<E extends React.ElementType> = \{
as?: E;
children: React.ReactNode;
\} & Omit<React.ComponentPropsWithoutRef<E>, "as" | "children">;
function Box<E extends React.ElementType = "div">(\{
as,
children,
...props
\}: PolymorphicProps<E>) \{
const Component = as ?? "div";
return <Component \{...props\}>\{children\}</Component>;
\}
// Usage - fully typed
<Box as="a" href="/about">Link</Box> // href is valid
<Box as="button" onClick=\{handleClick\}>Go</Box> // onClick is valid2. Discriminated Union Props
// Instead of optional props that depend on each other:
// BAD
type BadProps = \{ variant?: "link"; href?: string; onClick?: () => void \};
// GOOD - discriminated union
type ButtonProps =
| \{ variant: "button"; onClick: () => void; href?: never \}
| \{ variant: "link"; href: string; onClick?: never \}
| \{ variant: "submit"; onClick?: never; href?: never \};
function Action(props: ButtonProps) \{
switch (props.variant) \{
case "button":
return <button onClick=\{props.onClick\}>Click</button>;
case "link":
return <a href=\{props.href\}>Link</a>;
case "submit":
return <button type="submit">Submit</button>;
\}
\}3. Generic List Component
type ListProps<T> = \{
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
\};
function List<T>(\{ items, renderItem, keyExtractor, emptyMessage \}: ListProps<T>) \{
if (items.length === 0) \{
return <p>\{emptyMessage ?? "No items"\}</p>;
\}
return (
<ul>
\{items.map((item, i) => (
<li key=\{keyExtractor(item)\}>\{renderItem(item, i)\}</li>
))\}
</ul>
);
\}
// Usage - T is inferred from items
<List
items=\{users\}
renderItem=\{(user) => <span>\{user.name\}</span>\} // user is typed as User
keyExtractor=\{(user) => user.id\}
/>4. Extracting Component Props
import type \{ ComponentProps, ComponentRef \} from "react";
// Extract props from any component
type InputProps = ComponentProps<"input">;
type ButtonProps = ComponentProps<typeof Button>;
// Extract ref type
type InputRef = ComponentRef<"input">; // HTMLInputElement
// Pick specific props
type PartialInputProps = Pick<ComponentProps<"input">, "value" | "onChange" | "placeholder">;5. Typing Custom Hooks
// Return tuple (like useState)
function useToggle(initial = false) \{
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return [value, \{ toggle, setTrue, setFalse \}] as const;
\}
// Return type: readonly [boolean, \{ toggle, setTrue, setFalse \}]
// Generic hook
function useLocalStorage<T>(key: string, initialValue: T) \{
const [stored, setStored] = useState<T>(() => \{
if (typeof window === "undefined") return initialValue;
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
\});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => \{
setStored((prev) => \{
const next = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(next));
return next;
\});
\},
[key]
);
return [stored, setValue] as const;
\}6. Typing Server Components
// Server Component props - params and searchParams are Promises in Next.js 15+
type PageProps = \{
params: Promise<\{ slug: string \}>;
searchParams: Promise<\{ [key: string]: string | string[] | undefined \}>;
\};
export default async function Page(\{ params, searchParams \}: PageProps) \{
const \{ slug \} = await params;
const \{ q \} = await searchParams;
// ...
\}
// Layout props
type LayoutProps = \{
children: React.ReactNode;
params: Promise<\{ slug: string \}>;
\};
export default async function Layout(\{ children, params \}: LayoutProps) \{
const \{ slug \} = await params;
return <div>\{children\}</div>;
\}7. Typing Server Actions
// Server Action with typed state
type FormState = \{
errors?: \{
name?: string[];
email?: string[];
\};
message?: string;
success: boolean;
\};
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> \{
// validate and process
return \{ success: true, message: "User created" \};
\}
// Client component using the action
"use client";
import \{ useActionState \} from "react";
function Form() \{
const [state, action, pending] = useActionState(createUser, \{
success: false,
\});
// state is typed as FormState
\}8. Strict Event Handlers
// Specific event types instead of generic React.SyntheticEvent
function Form() \{
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => \{
console.log(e.target.value); // string
\};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => \{
e.preventDefault();
const formData = new FormData(e.currentTarget);
\};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => \{
if (e.key === "Enter") submit();
\};
return (
<form onSubmit=\{handleSubmit\}>
<input onChange=\{handleChange\} onKeyDown=\{handleKeyDown\} />
</form>
);
\}9. Utility Type Recipes
// Make specific keys required
type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Make specific keys optional
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Deep partial
type DeepPartial<T> = \{
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
\};
// Strict omit (errors if key doesn't exist)
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
// Extract union member
type ExtractMember<T, U> = T extends U ? T : never;10. Type Narrowing Patterns
// Type guard function
function isError(result: SuccessResult | ErrorResult): result is ErrorResult \{
return "error" in result;
\}
// Exhaustive switch
function assertNever(value: never): never \{
throw new Error(`Unexpected value: ${value}`);
\}
function handleStatus(status: "idle" | "loading" | "success" | "error") \{
switch (status) \{
case "idle": return null;
case "loading": return <Spinner />;
case "success": return <Done />;
case "error": return <Error />;
default: return assertNever(status); // compile error if case missed
\}
\}Rules
- Never use
any- Useunknownand narrow, or use a generic - Never use
asfor type assertions - Use type guards or discriminated unions - Always type return values of exported functions - Helps catch drift early
- Use
satisfiesoveras constwhen you need both inference and validation - Prefer interfaces for objects, types for unions - Interfaces have better error messages and are extendable
## Working Example
### Example 1: User asks "How do I type a component that can be a button or link?"
**User prompt:** "I need a Button component that renders as a link when href is provided."
**Skill-guided response would produce:**
```tsx
type ButtonBaseProps = {
children: React.ReactNode;
size?: "sm" | "md" | "lg";
variant?: "primary" | "secondary" | "ghost";
};
type ButtonAsButton = ButtonBaseProps &
Omit<React.ComponentPropsWithoutRef<"button">, keyof ButtonBaseProps> & {
href?: never;
};
type ButtonAsLink = ButtonBaseProps &
Omit<React.ComponentPropsWithoutRef<"a">, keyof ButtonBaseProps> & {
href: string;
};
type ButtonProps = ButtonAsButton | ButtonAsLink;
function Button(props: ButtonProps) {
if (props.href !== undefined) {
const { size, variant, ...rest } = props;
return <a className={getStyles(size, variant)} {...rest} />;
}
const { size, variant, ...rest } = props;
return <button className={getStyles(size, variant)} {...rest} />;
}
Example 2: User asks "Type my API response"
Skill-guided response would use Zod inference:
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "moderator"]),
});
type User = z.infer<typeof UserSchema>;
// Now User type is derived from the schema - single source of truthDeep Dive
How the Skill Works
This skill provides Claude with a comprehensive pattern library covering:
- 10 TypeScript recipes - Ready-made patterns for common React typing challenges
- Strict rules - Conventions that enforce type safety (no
any, noas) - Next.js-specific typing - Server Components, Server Actions, and page props
- Utility type toolkit - Custom utility types for common transformations
Customization
- Add your project-specific component prop conventions
- Include custom utility types used in your codebase
- Specify tsconfig strictness requirements
- Add patterns for your specific UI library (e.g., Radix, Headless UI)
How to Install
mkdir -p .claude/skills/typescript-react-patterns
# Paste the Recipe content into .claude/skills/typescript-react-patterns/SKILL.mdGotchas
- ComponentProps vs ComponentPropsWithRef - Use
ComponentPropsWithoutRefby default. Only useComponentPropsWithRefwhen you explicitly need to forward refs. - Generic components lose inference with React.memo - Wrap the memo call in a type assertion or use a different memoization strategy.
- satisfies does not narrow -
satisfiesvalidates but the variable retains its inferred type, not the checked type. - Zod inference is one-way - Changes to the Zod schema automatically update the type, but TypeScript cannot validate that the schema matches a separately defined type.
Alternatives
| Approach | When to Use |
|---|---|
| JSDoc types | Projects that cannot adopt TypeScript |
| io-ts | Runtime validation with fp-ts integration |
| Valibot | Smaller bundle alternative to Zod |
| ArkType | Faster schema validation with TypeScript-native syntax |
FAQs
What are the four core TypeScript principles described on this page?
- Prefer narrow types over broad ones (string literals over
string) - Use discriminated unions for conditional rendering
- Derive types from data (
z.infer,ReturnType,typeof) - Enable strict mode (
strict: trueandnoUncheckedIndexedAccess: true)
How does a discriminated union prevent invalid prop combinations?
type ButtonProps =
| { variant: "button"; onClick: () => void; href?: never }
| { variant: "link"; href: string; onClick?: never }
| { variant: "submit"; onClick?: never; href?: never };- Each variant defines exactly which props are required and which are forbidden (
never) - TypeScript errors at compile time if you pass
hrefto a "button" variant
How do you create a generic List component that infers item types?
type ListProps<T> = {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
};
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
);
}Tis inferred automatically from theitemsarray passed by the caller
How do you type params and searchParams in Next.js 15 page components?
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export default async function Page({ params, searchParams }: PageProps) {
const { slug } = await params;
const { q } = await searchParams;
}- Both
paramsandsearchParamsarePromisetypes in Next.js 15+ and must be awaited
Gotcha: What happens when you wrap a generic component in React.memo?
- Generic components lose their type inference when wrapped in
React.memo - The generic parameter
Tis not preserved through the memo boundary - Use a type assertion on the memo call or find an alternative memoization strategy
What is the difference between ComponentProps, ComponentPropsWithRef, and ComponentPropsWithoutRef?
ComponentPropsWithoutRef<"input">-- extracts props without ref (use by default)ComponentPropsWithRef<"input">-- includes the ref type- Only use
ComponentPropsWithRefwhen you explicitly need to forward refs
How does the polymorphic "as" prop pattern work?
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<E>, "as" | "children">;
function Box<E extends React.ElementType = "div">({
as, children, ...props
}: PolymorphicProps<E>) {
const Component = as ?? "div";
return <Component {...props}>{children}</Component>;
}- The component renders as whatever element is passed via
as - Props are fully typed for that element (e.g.,
hrefis valid whenas="a")
What utility type recipes does this page provide?
WithRequired<T, K>-- make specific keys requiredWithOptional<T, K>-- make specific keys optionalDeepPartial<T>-- recursively make all keys optionalStrictOmit<T, K>-- Omit that errors if the key does not existExtractMember<T, U>-- extract a specific member from a union
Gotcha: What is the difference between satisfies and as const?
satisfiesvalidates that a value matches a type but retains the inferred type (does not narrow)as constmakes the value deeply readonly with literal types- Use
satisfieswhen you need both inference and validation
How do you type a Server Action with a typed state return?
type FormState = {
errors?: { name?: string[]; email?: string[] };
message?: string;
success: boolean;
};
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
return { success: true, message: "User created" };
}- The action takes
prevState(typed asFormState) andformData(FormData) - The return type is
Promise<FormState>, matching the initial state shape
Why should you never use any or as type assertions?
anydisables all type checking and lets bugs slip through- Use
unknownand narrow with type guards instead ofany asassertions bypass the type checker and can mask errors- Use discriminated unions or type guards to safely narrow types