TypeScript in React goes beyond adding types to props. The patterns below solve real problems — component APIs that are impossible to misuse, state that self-documents its own shape, and type inference so precise you stop writing types manually. Here are 12 patterns worth knowing.
1. Discriminated Unions for Component Variants
Instead of a bag of optional props that can conflict, use a discriminated union to make impossible states unrepresentable:
type ButtonProps =
| { variant: 'primary'; href?: never }
| { variant: 'link'; href: string }
function Button(props: ButtonProps) { ... }Now TypeScript enforces that href is required when variant="link"and forbidden otherwise. No runtime checks needed.
2. ComponentProps for Extending HTML Elements
When building wrapper components, use React.ComponentProps to inherit all native attributes automatically:
interface InputProps extends React.ComponentProps<'input'> {
label: string
error?: string
}
function Input({ label, error, ...rest }: InputProps) { ... }This gives you type, placeholder, disabled, onChange, and every other native input prop for free — without listing them manually.
3. Template Literal Types for Design Tokens
Enforce valid combinations of design tokens at the type level:
type Size = 'sm' | 'md' | 'lg'
type Color = 'blue' | 'purple' | 'cyan'
type TokenKey = `${Color}-${Size}`
// "blue-sm" | "blue-md" | "blue-lg" | "purple-sm" | ...This pattern is powerful for typed utility class generators or design system token maps.
4. as const for Readonly Configs
Add as const to config objects to get the narrowest possible type and prevent accidental mutation:
const STATUSES = ['idle', 'loading', 'success', 'error'] as const
type Status = typeof STATUSES[number]
// type Status = "idle" | "loading" | "success" | "error"5. ReturnType and Parameters Utilities
Extract types from existing functions without duplicating them:
async function fetchUser(id: string) {
return { id, name: 'Alex', role: 'admin' }
}
type User = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string; role: string }6. Generic Components That Infer Their Types
A typed Select component that infers the option type from the options array:
function Select<T extends string>({
options,
value,
onChange,
}: {
options: readonly T[]
value: T
onChange: (value: T) => void
}) { ... }
// Usage — TypeScript infers T = "draft" | "published"
<Select options={['draft', 'published']} value={status} onChange={setStatus} />7. Satisfies for Config Objects
The satisfies operator validates a value against a type while preserving the narrower inferred type:
const config = {
theme: 'dark',
fontSize: 16,
} satisfies Record<string, string | number>
// config.theme is still inferred as 'dark', not string8. Exclude and Extract for Type Manipulation
type AllEvents = 'click' | 'hover' | 'focus' | 'blur'
type MouseEvents = Extract<AllEvents, 'click' | 'hover'>
// "click" | "hover"
type NonMouseEvents = Exclude<AllEvents, MouseEvents>
// "focus" | "blur"9. Type Guards for Narrowing
Write type guard functions to narrow types in conditional blocks:
function isError(value: unknown): value is Error {
return value instanceof Error
}
if (isError(result)) {
console.log(result.message) // TypeScript knows this is Error
}10. Readonly for Immutable Props
Mark array and object props as Readonly to prevent accidental mutation:
interface TableProps {
rows: Readonly<Row[]>
columns: ReadonlyArray<Column>
}11. never for Exhaustive Switches
Use never to get a compile-time error when you forget to handle a new enum case:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${value}`)
}
switch (status) {
case 'idle': return <Idle />
case 'loading': return <Loading />
case 'success': return <Success />
case 'error': return <Error />
default: return assertNever(status) // Error if a case is missing
}12. Mapped Types for Form State
Derive error and touched state types directly from your form data type:
interface FormData {
name: string
email: string
message: string
}
type FormErrors = Partial<Record<keyof FormData, string>>
type FormTouched = Partial<Record<keyof FormData, boolean>>Adding a new field to FormData automatically makes it available in errors and touched — no separate type maintenance.
These patterns share a common principle: let the type system do the enforcement so your runtime code does not have to. Every type guard you write is a runtime check you never need. Every discriminated union is a class of bugs that cannot compile. That is the real value of TypeScript in React.