TypeScript in React
TypeScript in React isn't about adding types to shut the compiler up â it's about designing component APIs that are self-documenting and impossible to misuse.
Component Props Typing
Use interface for component props (they're extendable and show better error messages):
interface ButtonProps {
variant: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: ReactNode;
onClick?: () => void;
}
function Button({ variant, size = 'md', loading, children, onClick }: ButtonProps) {
return (
<button onClick={onClick} disabled={loading} data-variant={variant} data-size={size}>
{loading ? <Spinner /> : children}
</button>
);
}Extending HTML Elements
When wrapping native elements, extend their intrinsic props:
interface InputProps extends Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
label: string;
error?: string;
size?: 'sm' | 'md' | 'lg';
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, size = 'md', ...rest }, ref) => {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input ref={ref} id={id} aria-invalid={!!error} {...rest} />
{error && <span role="alert">{error}</span>}
</div>
);
}
);ComponentPropsWithoutRef<'input'> gives you every valid input attribute. Omit removes props you're overriding to avoid type conflicts.
Discriminated Union Props
Model mutually exclusive prop combinations as unions, not optional props:
// Bad: both icon and children are optional â what renders when both are missing?
interface BadButtonProps {
icon?: ReactNode;
children?: ReactNode;
}
// Good: exactly one of two shapes
type IconButtonProps = {
variant: 'icon';
icon: ReactNode;
'aria-label': string;
};
type TextButtonProps = {
variant: 'text';
children: ReactNode;
};
type ButtonProps = (IconButtonProps | TextButtonProps) & {
onClick?: () => void;
};This makes it a compile-time error to create an icon button without an aria-label.
Generic Components
Components that work with any data type while preserving full type inference:
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getKey: (option: T) => string;
}
function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
return (
<select
value={getKey(value)}
onChange={(e) => {
const selected = options.find((o) => getKey(o) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map((opt) => (
<option key={getKey(opt)} value={getKey(opt)}>
{getLabel(opt)}
</option>
))}
</select>
);
}
// Usage: T inferred as Country
<Select
options={countries}
value={selectedCountry}
onChange={setSelectedCountry}
getLabel={(c) => c.name}
getKey={(c) => c.code}
/>Typing Hooks
useState with Complex Types
interface User {
id: string;
name: string;
role: 'admin' | 'user';
}
// Type is inferred when initial value is provided
const [count, setCount] = useState(0);
// Explicit type needed for null/undefined initial values
const [user, setUser] = useState<User | null>(null);useReducer with Discriminated Unions
type State = { items: Item[]; loading: boolean; error: string | null };
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; items: Item[] }
| { type: 'FETCH_ERROR'; error: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { items: action.items, loading: false, error: null };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.error };
}
}Custom Hook Return Types
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // tuple, not array
}
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// theme is string, setTheme is Dispatch<SetStateAction<string>>The as const assertion is crucial â without it, the return type is (string | Dispatch<...>)[] instead of a proper tuple.
Context Typing
interface AuthContext {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContext | null>(null);
function useAuth(): AuthContext {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}The null default + runtime check pattern is better than providing a dummy default value, because it catches missing providers during development instead of producing silent bugs.
Utility Patterns
// Extract component props from any component
type ButtonProps = ComponentProps<typeof Button>;
// Make specific props required
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Polymorphic component "as" prop
type PolymorphicProps<E extends ElementType, P = {}> = P &
Omit<ComponentPropsWithoutRef<E>, keyof P> & {
as?: E;
};Interview Signal
TypeScript-in-React questions test:
- API design taste â using discriminated unions to prevent invalid prop combinations
- Generic fluency â building reusable components that infer types from usage
- Pattern knowledge â
as const,forwardReftyping, context patterns - Pragmatism â knowing when
anyor a type assertion is the right trade-off vs. fighting the compiler for hours