React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

skillstypescriptreactgenericsdiscriminated-unionsutility-typestyping

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 valid

2. 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

  1. Never use any - Use unknown and narrow, or use a generic
  2. Never use as for type assertions - Use type guards or discriminated unions
  3. Always type return values of exported functions - Helps catch drift early
  4. Use satisfies over as const when you need both inference and validation
  5. 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 truth

Deep Dive

How the Skill Works

This skill provides Claude with a comprehensive pattern library covering:

  1. 10 TypeScript recipes - Ready-made patterns for common React typing challenges
  2. Strict rules - Conventions that enforce type safety (no any, no as)
  3. Next.js-specific typing - Server Components, Server Actions, and page props
  4. 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.md

Gotchas

  • ComponentProps vs ComponentPropsWithRef - Use ComponentPropsWithoutRef by default. Only use ComponentPropsWithRef when 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 - satisfies validates 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

ApproachWhen to Use
JSDoc typesProjects that cannot adopt TypeScript
io-tsRuntime validation with fp-ts integration
ValibotSmaller bundle alternative to Zod
ArkTypeFaster 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: true and noUncheckedIndexedAccess: 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 href to 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>
  );
}
  • T is inferred automatically from the items array 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 params and searchParams are Promise types 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 T is 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 ComponentPropsWithRef when 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., href is valid when as="a")
What utility type recipes does this page provide?
  • WithRequired<T, K> -- make specific keys required
  • WithOptional<T, K> -- make specific keys optional
  • DeepPartial<T> -- recursively make all keys optional
  • StrictOmit<T, K> -- Omit that errors if the key does not exist
  • ExtractMember<T, U> -- extract a specific member from a union
Gotcha: What is the difference between satisfies and as const?
  • satisfies validates that a value matches a type but retains the inferred type (does not narrow)
  • as const makes the value deeply readonly with literal types
  • Use satisfies when 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 as FormState) and formData (FormData)
  • The return type is Promise<FormState>, matching the initial state shape
Why should you never use any or as type assertions?
  • any disables all type checking and lets bugs slip through
  • Use unknown and narrow with type guards instead of any
  • as assertions bypass the type checker and can mask errors
  • Use discriminated unions or type guards to safely narrow types