TypeScript adds a compiler — it doesn't automatically make your React code correct. A codebase littered with any, unchecked as casts, and ! non-null assertions has types in name only. Real TypeScript proficiency means using the type system to catch real bugs, not just satisfying the compiler.
These are the patterns that actually matter in production React applications.
The most common mistake: using any or overly broad types for props.
// BAD — type system does nothing for you
interface ButtonProps {
onClick: any
variant: string
children: any
}
// GOOD — precise types catch mistakes at compile time
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
interface ButtonProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
variant: ButtonVariant
children: React.ReactNode
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}With the precise version, passing variant="primry" (typo) is a compile error. With string, it silently fails at runtime.
One of the most powerful patterns for modeling complex component states:
// Instead of multiple optional fields that can be in impossible combinations:
interface DataState {
loading: boolean
data?: User[]
error?: string
}
// Problem: what does { loading: false, data: undefined, error: undefined } mean?
// Use a discriminated union — each state is unambiguous:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function UserList() {
const [state, setState] = useState<AsyncState<User[]>>({ status: 'idle' })
// TypeScript narrows the type in each branch:
if (state.status === 'loading') return <Spinner />
if (state.status === 'error') return <ErrorMessage message={state.error} />
if (state.status === 'idle') return <button onClick={fetchUsers}>Load Users</button>
// state.status === 'success' is the only remaining case
// TypeScript knows state.data is User[] here — no undefined check needed
return <ul>{state.data.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}When you find yourself writing nearly identical components for different data types, use generics:
// Without generics — duplicate code for every type
interface UserSelectProps {
options: User[]
value: User | null
onChange: (value: User | null) => void
}
interface PostSelectProps {
options: Post[]
value: Post | null
onChange: (value: Post | null) => void
}
// With generics — one component, fully type-safe for any type
interface SelectProps<T> {
options: T[]
value: T | null
onChange: (value: T | null) => void
getLabel: (item: T) => string
getKey: (item: T) => string
}
function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
return (
<select
value={value ? getKey(value) : ''}
onChange={e => {
const selected = options.find(o => getKey(o) === e.target.value) ?? null
onChange(selected)
}}
>
<option value="">Select...</option>
{options.map(option => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Usage — TypeScript infers T = User automatically
<Select
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={user => user.name}
getKey={user => user.id}
/>Custom hooks deserve precise return types:
// Return tuple type — matches useState pattern
function useToggle(initial = false): [boolean, () => void, () => void, () => void] {
const [value, setValue] = useState(initial)
const on = useCallback(() => setValue(true), [])
const off = useCallback(() => setValue(false), [])
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle, on, off]
}
// Return object type — better for hooks with many values
interface UseFetchResult<T> {
data: T | null
loading: boolean
error: string | null
refetch: () => void
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
const fetch_ = useCallback(async () => {
setState({ status: 'loading' })
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as T
setState({ status: 'success', data })
} catch (e) {
setState({ status: 'error', error: e instanceof Error ? e.message : 'Unknown error' })
}
}, [url])
useEffect(() => { fetch_() }, [fetch_])
return {
data: state.status === 'success' ? state.data : null,
loading: state.status === 'loading',
error: state.status === 'error' ? state.error : null,
refetch: fetch_,
}
}as Casts Are Type Lies// This compiles but crashes at runtime if the element isn't an input
const input = document.getElementById('search') as HTMLInputElement
input.value = 'hello' // Crashes if element is a div
// Better: use a type guard
const element = document.getElementById('search')
if (element instanceof HTMLInputElement) {
element.value = 'hello' // TypeScript knows it's HTMLInputElement here
}// This compiles but crashes if user is undefined
const username = user!.name
// Better: handle the undefined case
const username = user?.name ?? 'Anonymous'any in API Responses// BAD — you lose all type safety after this fetch
const data: any = await fetch('/api/users').then(r => r.json())
// GOOD — validate and type the response
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const UsersSchema = z.array(UserSchema)
type User = z.infer<typeof UserSchema>
const raw = await fetch('/api/users').then(r => r.json())
const users = UsersSchema.parse(raw) // Throws if shape doesn't match
// users is typed as User[] with guaranteed shape// Pick — select specific fields
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>
// Omit — exclude specific fields
type UserWithoutPassword = Omit<User, 'password' | 'salt'>
// Partial — all fields optional (useful for update payloads)
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>
// Required — all fields required
type RequiredConfig = Required<Config>
// Record — object with specific key/value types
const statusMessages: Record<'active' | 'inactive' | 'banned', string> = {
active: 'Your account is active',
inactive: 'Your account is inactive',
banned: 'Your account has been banned',
}
// ReturnType — extract return type from a function
type FetchResult = ReturnType<typeof fetchUser>any for API responses — validate with Zod and infer types from the schemaas casts and ! operators are red flags — use type guards insteadPick, Omit, Partial, Record) cover most common type transformations