Most React developers use TypeScript for props and useState and stop there.
Senior engineers use TypeScript as an architectural constraint system.
The difference is not syntax knowledge. It is modeling invariants at the type level.
This article focuses on the patterns that eliminate entire bug classes in production React codebases.
1. Discriminated Unions: Model State Machines, Not Optional Props
If your component has many optional props, you likely encoded invalid states.
Instead of:
type Props = {
variant?: "success" | "error"
message: string
onRetry?: () => void
action?: { label: string; onClick: () => void }
}
Model mutually exclusive states:
type NotificationProps =
| { variant: "success"; message: string; action?: { label: string; onClick: () => void } }
| { variant: "error"; message: string; onRetry: () => void }
Now:
-
variant: "error"requiresonRetry -
actioncannot exist in the error branch - Impossible combinations are compile errors
You are not typing props. You are encoding a state machine.
2. Generics for Reusable Infrastructure
Reusable components without generics are usually unsafe, duplicated per data shape, or loosely typed.
Example: a fully type safe table.
type TableProps<T> = {
data: T[]
columns: {
key: keyof T
header: string
}[]
onRowClick?: (row: T) => void
}
function Table<T>({ data, columns, onRowClick }: TableProps<T>) {
return null
}
Usage:
<Table
data={users}
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{ key: "doesNotExist", header: "Oops" } // Compile error
]}
/>
Generics make infrastructure components aware of domain models without coupling them to specific types.
3. Constrained Generics for Structural Guarantees
Sometimes generics are too permissive.
Constrain them:
type ListProps<T extends { id: string | number }> = {
items: T[]
renderItem: (item: T) => React.ReactNode
}
Now all list items must have id. React key safety is enforced at compile time.
You move runtime warnings into compile time guarantees.
4. Polymorphic Components With Proper Prop Inference
Design systems need semantic flexibility:
<Button as="a" href="/pricing" />
<Button onClick={handleClick} />
Type safe polymorphism:
type PolymorphicProps<E extends React.ElementType> = {
as?: E
} & Omit<React.ComponentPropsWithoutRef<E>, "as">
function Button<E extends React.ElementType = "button">(
props: PolymorphicProps<E>
) {
const { as, ...rest } = props
const Component = as || "button"
return <Component {...rest} />
}
When as="a", href becomes available.
When omitted, it behaves like a button with correct event types.
This is foundational for serious component libraries.
5. Template Literal Types for Structured APIs
TypeScript can encode structured strings:
type Page = "home" | "product"
type Action = "view" | "click"
type AnalyticsEvent = `${Page}_${Action}`
Now:
track("home_view") // OK
track("home_hover") // Compile error
You eliminate typo driven data corruption.
Template literal types are powerful for analytics events, design tokens, and structured configuration systems.
6. Safe Context Pattern
Raw createContext introduces null into your tree.
Instead of unsafe assertions:
const ThemeContext = React.createContext<Theme | null>(null)
Create a narrowing hook:
function useTheme(): Theme {
const ctx = React.useContext(ThemeContext)
if (!ctx) throw new Error("useTheme must be used within ThemeProvider")
return ctx
}
Consumers now receive a non nullable type.
You remove an entire category of undefined access bugs.
7. satisfies for Configuration Integrity
satisfies validates without widening types:
type RouteConfig = {
path: string
auth?: boolean
}
const routes = {
home: { path: "/" },
dashboard: { path: "/dashboard", auth: true }
} satisfies Record<string, RouteConfig>
You get validation and preserved literal types at the same time.
Perfect for route maps, feature flags, and design tokens.
8. Runtime Validation at API Boundaries
TypeScript does not validate runtime data.
Use schema driven typing:
import { z } from "zod"
const UserSchema = z.object({
id: z.string(),
email: z.string().email()
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: string): Promise<User> {
const data = await fetch(`/api/users/${id}`).then(r => r.json())
return UserSchema.parse(data)
}
The schema becomes both runtime validator and type source of truth.
External data enters your system safely.
What Changes at Senior Level
Junior TypeScript usage:
- Add interfaces
- Silence errors
- Use
aswhen stuck
Senior TypeScript usage:
- Encode invariants
- Model state transitions
- Constrain generic boundaries
- Remove invalid states entirely
The goal is not stronger typing.
The goal is removing entire bug classes before they exist.
When your type system encodes architectural constraints, React becomes predictable at scale.
Top comments (0)