React Development · Lesson 7 of 15
TypeScript Patterns
Why TypeScript Transforms React Development
TypeScript catches bugs at compile time — before your code runs. In React specifically:
- Catch wrong prop types before runtime
- Get autocomplete on props and event handlers
- Refactor with confidence — TS tells you every call site that breaks
- Self-documenting APIs — the types ARE the documentation
Typing Component Props
// Basic props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean; // Optional: ? makes it undefined | boolean
variant: "primary" | "secondary" | "danger"; // Union literal type
}
// Extending HTML element props — the correct way to build UI components
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
loading?: boolean;
icon?: React.ReactNode;
}
// Now ButtonProps has ALL native button props (disabled, type, onClick, etc.)
// plus your custom ones
function Button({ variant = "primary", loading, icon, children, ...rest }: ButtonProps) {
return (
<button {...rest}> {/* ...rest spreads all native HTML props */}
{icon}
{children}
</button>
);
}Event Handler Types
// Common event types you'll use daily
function FormExample() {
// Input events
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleTextArea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
console.log(e.target.value);
};
const handleSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
console.log(e.target.value);
};
// Form submission
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
};
// Mouse events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files);
};
// Keyboard events
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleSubmit(e as any);
if (e.key === "Escape") clearInput();
};
// Focus events
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select(); // Select all text on focus
};
return (
<form onSubmit={handleSubmit}>
<input
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
/>
</form>
);
}Typing useState
// TypeScript infers simple types
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
const [active, setActive] = useState(false); // boolean
// Provide explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
// Complex initial state
interface FormState {
email: string;
password: string;
rememberMe: boolean;
}
const [form, setForm] = useState<FormState>({
email: "",
password: "",
rememberMe: false,
});
// Discriminated union state — when multiple shapes are possible
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
const [state, setState] = useState<AsyncState<User>>({ status: "idle" });
// TypeScript now knows which properties exist based on status
if (state.status === "success") {
console.log(state.data); // ✅ data is available
// console.log(state.error); // ✅ TS error: error doesn't exist on success state
}Generic Components — The Most Powerful Pattern
Generics let you write components that work with any data type while preserving type information.
// Generic List component — works with any item type
interface ListProps<T> {
items: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T) => React.ReactNode;
emptyState?: React.ReactNode;
className?: string;
}
function List<T>({
items,
keyExtractor,
renderItem,
emptyState,
className,
}: ListProps<T>) {
if (items.length === 0) {
return emptyState ? <>{emptyState}</> : null;
}
return (
<ul className={className}>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage: TypeScript infers T from the items prop
<List
items={users} // T = User
keyExtractor={(u) => u.id} // u is User — autocomplete works!
renderItem={(u) => (
<UserCard user={u} /> // u is User — no type assertion needed
)}
/>
<List
items={products} // T = Product
keyExtractor={(p) => p.sku} // p is Product
renderItem={(p) => <ProductCard product={p} />}
emptyState={<EmptyProducts />}
/>Generic Select / Combobox
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
placeholder?: string;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder = "Select...",
}: SelectProps<T>) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = options.find((o) => getValue(o) === e.target.value);
if (selected) onChange(selected);
};
return (
<select value={value ? getValue(value) : ""} onChange={handleChange}>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage
<Select
options={countries}
value={selectedCountry}
onChange={setSelectedCountry}
getLabel={(c) => c.name} // c is Country — type safe!
getValue={(c) => c.code}
/>Discriminated Unions — Model State Correctly
This pattern eliminates impossible states and makes TypeScript verify your logic.
// ❌ Bad: any combination of these could be true simultaneously
interface BadState {
loading: boolean;
data: User | null;
error: string | null;
}
// This is valid TypeScript but impossible in reality:
const state: BadState = { loading: true, data: someUser, error: "also an error" };
// ✅ Good: discriminated union — only valid combinations are possible
type UserState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; message: string };
function UserDisplay({ state }: { state: UserState }) {
switch (state.status) {
case "idle":
return <button>Load User</button>;
case "loading":
return <Skeleton />;
case "success":
return <Profile user={state.user} />; // TypeScript knows user exists here
case "error":
return <Alert message={state.message} />; // And message here
}
// TypeScript ensures you handled all cases (exhaustive check)
}Real-World: Form Submission States
type SubmitState =
| { kind: "idle" }
| { kind: "submitting" }
| { kind: "success"; message: string }
| { kind: "error"; errors: Record<string, string> };
function ContactForm() {
const [submitState, setSubmitState] = useState<SubmitState>({ kind: "idle" });
const handleSubmit = async (data: FormData) => {
setSubmitState({ kind: "submitting" });
try {
await api.post("/contact", data);
setSubmitState({ kind: "success", message: "Message sent! We'll be in touch." });
} catch (err: any) {
setSubmitState({ kind: "error", errors: err.fieldErrors ?? {} });
}
};
return (
<form>
{submitState.kind === "success" && (
<SuccessBanner message={submitState.message} />
)}
{submitState.kind === "error" && (
<FieldErrors errors={submitState.errors} />
)}
<button disabled={submitState.kind === "submitting"}>
{submitState.kind === "submitting" ? "Sending..." : "Send"}
</button>
</form>
);
}Utility Types You'll Use Constantly
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: string;
role: "admin" | "user";
}
// Partial — all fields optional (good for update DTOs)
type UpdateUserDto = Partial<User>;
// Required — all fields required
type StrictUser = Required<User>;
// Pick — take specific fields
type UserPreview = Pick<User, "id" | "name" | "email">;
// Equivalent to: { id: string; name: string; email: string }
// Omit — remove specific fields
type PublicUser = Omit<User, "password">;
// Equivalent to: { id, name, email, createdAt, role }
// Readonly — prevent mutation
type ImmutableUser = Readonly<User>;
// Record — typed object map
type RolePermissions = Record<User["role"], string[]>;
// { admin: string[], user: string[] }
// ReturnType — type of what a function returns
type UserQueryResult = ReturnType<typeof useQuery<User>>;
// Parameters — types of function parameters
type FetchOptions = Parameters<typeof fetch>[1];
// NonNullable — remove null/undefined
type NonNullUser = NonNullable<User | null | undefined>; // User
// Practical combinations
type CreateUserDto = Omit<User, "id" | "createdAt"> & {
password: string; // explicitly include password for creation
};
type UserCardProps = Pick<User, "name" | "email" | "role"> & {
onEdit: () => void;
};Typing useRef
// DOM element ref — always start as null
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Type-safe DOM access
const focusInput = () => {
inputRef.current?.focus(); // Optional chaining handles null
};
const getFormData = () => {
if (formRef.current) {
return new FormData(formRef.current);
}
};
// Mutable ref (not a DOM element) — use MutableRefObject pattern
const countRef = useRef<number>(0);
countRef.current++; // This is fine — no TypeScript error
const intervalRef = useRef<NodeJS.Timeout | null>(null);Typing Context
interface ThemeContextType {
theme: "light" | "dark" | "system";
setTheme: (theme: "light" | "dark" | "system") => void;
resolvedTheme: "light" | "dark"; // What's actually applied
}
// null default — forces consumers to verify the context was provided
const ThemeContext = createContext<ThemeContextType | null>(null);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark" | "system">("system");
const resolvedTheme: "light" | "dark" = useMemo(() => {
if (theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return theme;
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook with type guard — throws a helpful error instead of silent null
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within <ThemeProvider>");
}
return context;
}
// Usage — fully typed
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
// resolvedTheme is "light" | "dark" — TypeScript knows this
return (
<button onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}>
Toggle
</button>
);
}Typing Custom Hooks
// Return a tuple (like useState)
function useToggle(initial = false): [boolean, () => void, (v: boolean) => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle, setValue];
}
// Return an object (more flexible, no order dependency)
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json() as T;
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage — T is inferred from how you use .data
function UserPage({ id }: { id: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${id}`);
// user is User | null — TypeScript knows the shape
}forwardRef with TypeScript
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
// forwardRef needs two generics: <RefType, PropsType>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, ...props }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
{error && <span>{error}</span>}
</div>
);
}
);
Input.displayName = "Input";
// Usage
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} label="Email" error="Required" />Type Guards — Runtime Safety
// Type guard function: returns type predicate
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value &&
typeof (value as any).id === "string"
);
}
// Usage: safely handle unknown API responses
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("API returned unexpected shape");
}
return data; // TypeScript knows this is User now
}
// Zod for runtime validation (better than manual type guards)
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
createdAt: z.string(),
});
type User = z.infer<typeof UserSchema>; // Type is derived from schema
async function fetchUserSafe(id: string): Promise<User> {
const data = await fetch(`/api/users/${id}`).then((r) => r.json());
return UserSchema.parse(data); // Throws if shape doesn't match
}Interview Questions Answered
Q: What is the difference between interface and type in TypeScript?
interface is extendable (open for declaration merging, extends with extends). type supports unions, intersections, mapped types, and conditional types — more powerful for complex type algebra. For React props: use interface for simple objects, type for unions or when you need Omit/Pick/Partial combinations. Either works; pick one and be consistent.
Q: What does React.FC do and should you use it?
React.FC<Props> was the standard way to type function components. It implicitly adds children to props and sets the return type. However, in modern React (17+), React.FC is often avoided because: (1) it adds implicit children even when you don't want it, (2) it doesn't support generics well. Prefer explicitly typing props with an interface and letting TypeScript infer the return type.
Q: How do you type a component that accepts any HTML element's props?
Use React.ComponentPropsWithoutRef<"div"> (or React.ComponentPropsWithRef<"div"> if forwarding refs). This gives you all the native props for that element. Combine with extends: interface CardProps extends React.ComponentPropsWithoutRef<"div"> { title: string; }.
Q: What is a discriminated union and why is it useful in React?
A discriminated union is a union of object types that share a common "discriminant" field (like status or kind). TypeScript uses it to narrow types in conditional branches. In React, it eliminates impossible states — instead of loading: boolean, data: T | null, error: string | null (which allows loading=true AND error simultaneously), you model it as { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: string } — only valid combinations exist.