Back to blog
TypeScript Patterns for Scalable Frontend Applications
Advanced TypeScript techniques for building type-safe, maintainable React applications with real-world examples.
9 min read·Talha Bilal
Share:

Introduction
TypeScript has become the de facto standard for modern frontend development, but using it effectively requires more than just adding types to your JavaScript. The difference between okay TypeScript and great TypeScript is the difference between catching bugs at compile-time versus runtime.
Over the years, I've built several large-scale React applications with TypeScript. In this post, I'll share the patterns and techniques that have helped me write code that's both type-safe and maintainable.
Discriminated Unions for State Management
One of the most powerful TypeScript features is discriminated unions. They're perfect for modeling UI state:
typescript
1// ❌ Bad: Boolean flags lead to impossible states2interface UserState {3 loading: boolean;4 error: string | null;5 data: User | null;6}7
8// This allows impossible states:9// { loading: true, error: "Error", data: user } ❌10
11// ✅ Good: Discriminated union enforces valid states only12type UserState =13 | { status: "idle" }14 | { status: "loading" }15 | { status: "error"; error: string }16 | { status: "success"; data: User };17
18// Now TypeScript enforces valid state transitions19function UserProfile({ state }: { state: UserState }) {20 switch (state.status) {21 case "idle":22 return null;23 case "loading":24 return <Spinner />;25 case "error":26 return <ErrorMessage error={state.error} />; // ✓ error exists27 case "success":28 return <Profile user={state.data} />; // ✓ data exists29 }30}TypeScript narrows the type in each case, preventing runtime errors.
Generic Components with Proper Constraints
Generics make components reusable without sacrificing type safety:
typescript
1// Dropdown component that works with any data type2interface DropdownProps<T> {3 items: T[];4 value: T | null;5 onChange: (value: T) => void;6 getLabel: (item: T) => string;7 getId: (item: T) => string | number;8}9
10function Dropdown<T>({11 items,12 value,13 onChange,14 getLabel,15 getId,16}: DropdownProps<T>) {17 return (18 <select19 value={value ? getId(value) : ""}20 onChange={(e) => {21 const item = items.find((i) => getId(i) === e.target.value);22 if (item) onChange(item);23 }}24 >25 <option value="">Select...</option>26 {items.map((item) => (27 <option key={getId(item)} value={getId(item)}>28 {getLabel(item)}29 </option>30 ))}31 </select>32 );33}34
35// Usage: Fully type-safe!36interface User {37 id: string;38 name: string;39 email: string;40}41
42function UserSelector() {43 const [user, setUser] = useState<User | null>(null);44 45 return (46 <Dropdown<User>47 items={users}48 value={user}49 onChange={setUser}50 getLabel={(u) => u.name} // ✓ TypeScript knows u is User51 getId={(u) => u.id} // ✓ Autocomplete works52 />53 );54}Type-Safe API Client
Strongly type your API calls to catch integration bugs early:
typescript
1// Define API response types2interface User {3 id: string;4 name: string;5 email: string;6}7
8interface Ticket {9 id: string;10 title: string;11 status: "open" | "closed";12 assignee: User;13}14
15// Generic API client with typed responses16class APIClient {17 private async request<T>(18 endpoint: string,19 options?: RequestInit20 ): Promise<T> {21 const response = await fetch(`/api${endpoint}`, {22 ...options,23 headers: {24 "Content-Type": "application/json",25 ...options?.headers,26 },27 });28
29 if (!response.ok) {30 throw new Error(`API Error: ${response.statusText}`);31 }32
33 return response.json();34 }35
36 async getUser(id: string): Promise<User> {37 return this.request<User>(`/users/${id}`);38 }39
40 async getTickets(): Promise<Ticket[]> {41 return this.request<Ticket[]>("/tickets");42 }43
44 async createTicket(data: Omit<Ticket, "id">): Promise<Ticket> {45 return this.request<Ticket>("/tickets", {46 method: "POST",47 body: JSON.stringify(data),48 });49 }50}51
52export const api = new APIClient();53
54// Usage: Fully typed!55const user = await api.getUser("123"); // Type: User56const tickets = await api.getTickets(); // Type: Ticket[]57console.log(tickets[0].assignee.name); // ✓ Autocomplete worksReact Hook with Generic Type Inference
Create reusable hooks that infer types automatically:
typescript
1// Generic data fetching hook2interface UseQueryState<T> {3 data: T | null;4 loading: boolean;5 error: Error | null;6 refetch: () => Promise<void>;7}8
9function useQuery<T>(10 fetcher: () => Promise<T>11): UseQueryState<T> {12 const [state, setState] = useState<{13 data: T | null;14 loading: boolean;15 error: Error | null;16 }>({17 data: null,18 loading: true,19 error: null,20 });21
22 const execute = useCallback(async () => {23 setState({ data: null, loading: true, error: null });24 try {25 const data = await fetcher();26 setState({ data, loading: false, error: null });27 } catch (error) {28 setState({29 data: null,30 loading: false,31 error: error instanceof Error ? error : new Error("Unknown error"),32 });33 }34 }, [fetcher]);35
36 useEffect(() => {37 execute();38 }, [execute]);39
40 return { ...state, refetch: execute };41}42
43// Usage: Type automatically inferred!44function UserProfile({ userId }: { userId: string }) {45 const { data, loading, error } = useQuery(() => api.getUser(userId));46 // data is typed as User | null ✓47
48 if (loading) return <Spinner />;49 if (error) return <Error message={error.message} />;50 if (!data) return null;51
52 return <div>{data.name}</div>; // ✓ TypeScript knows data is User53}Branded Types for Stronger Type Safety
Prevent mixing up similar primitive types:
typescript
1// Without branding: easy to mix up2function getUser(id: string) { /* ... */ }3function getTicket(id: string) { /* ... */ }4
5const userId = "user-123";6const ticketId = "ticket-456";7
8getUser(ticketId); // ❌ Compiles but wrong!9
10// With branding: type-safe11type UserId = string & { readonly __brand: "UserId" };12type TicketId = string & { readonly __brand: "TicketId" };13
14function getUserId(id: string): UserId {15 return id as UserId;16}17
18function getTicketId(id: string): TicketId {19 return id as TicketId;20}21
22function getUser(id: UserId) { /* ... */ }23function getTicket(id: TicketId) { /* ... */ }24
25const userId = getUserId("user-123");26const ticketId = getTicketId("ticket-456");27
28getUser(ticketId); // ✅ TypeScript error! Type mismatchUtility Types for Transformations
TypeScript's utility types are powerful for deriving new types:
typescript
1interface User {2 id: string;3 name: string;4 email: string;5 password: string;6 createdAt: Date;7 updatedAt: Date;8}9
10// Pick: Select specific properties11type UserPreview = Pick<User, "id" | "name">;12// { id: string; name: string; }13
14// Omit: Exclude specific properties15type UserInput = Omit<User, "id" | "createdAt" | "updatedAt">;16// { name: string; email: string; password: string; }17
18// Partial: Make all properties optional19type UserUpdate = Partial<UserInput>;20// { name?: string; email?: string; password?: string; }21
22// Required: Make all properties required23type UserRequired = Required<Partial<User>>;24
25// Record: Create an object type with specific keys26type UserMap = Record<UserId, User>;27// { [key: UserId]: User }28
29// Example usage30function updateUser(id: UserId, updates: UserUpdate): Promise<User> {31 // Only allows valid User properties as updates32 return api.patch(`/users/${id}`, updates);33}34
35updateUser(userId, { name: "New Name" }); // ✓36updateUser(userId, { invalid: "field" }); // ❌ TypeScript errorConst Assertions for Literal Types
Use
as const to preserve literal types:typescript
1// Without const assertion2const colors = ["red", "blue", "green"];3type Color = typeof colors[number]; // type Color = string ❌4
5// With const assertion6const colors = ["red", "blue", "green"] as const;7type Color = typeof colors[number]; // type Color = "red" | "blue" | "green" ✓8
9// Object const assertion10const config = {11 apiUrl: "https://api.example.com",12 timeout: 5000,13 retries: 3,14} as const;15
16type Config = typeof config;17// {18// readonly apiUrl: "https://api.example.com";19// readonly timeout: 5000;20// readonly retries: 3;21// }22
23// Practical use: Route definitions24const routes = {25 home: "/",26 profile: "/profile",27 settings: "/settings",28 ticket: (id: string) => `/tickets/${id}`,29} as const;30
31type RouteKey = keyof typeof routes; // "home" | "profile" | "settings" | "ticket"Type Guards for Runtime Validation
Bridge the gap between runtime and compile-time types:
typescript
1// Type predicate2function isUser(value: unknown): value is User {3 return (4 typeof value === "object" &&5 value !== null &&6 "id" in value &&7 "name" in value &&8 "email" in value9 );10}11
12// Usage13function handleApiResponse(data: unknown) {14 if (isUser(data)) {15 // TypeScript knows data is User here16 console.log(data.name); // ✓17 } else {18 console.error("Invalid user data");19 }20}21
22// More robust: with validation library (Zod)23import { z } from "zod";24
25const UserSchema = z.object({26 id: z.string().uuid(),27 name: z.string().min(1),28 email: z.string().email(),29});30
31type User = z.infer<typeof UserSchema>;32
33function parseUser(data: unknown): User {34 return UserSchema.parse(data); // Throws if invalid35}36
37// Safe parsing (returns Result type)38const result = UserSchema.safeParse(data);39if (result.success) {40 const user = result.data; // Type: User41} else {42 console.error(result.error); // Type: ZodError43}Mapped Types for Transformations
Create new types by transforming existing ones:
typescript
1// Make all properties readonly2type Readonly<T> = {3 readonly [P in keyof T]: T[P];4};5
6// Make all properties nullable7type Nullable<T> = {8 [P in keyof T]: T[P] | null;9};10
11// Pick properties by type12type PickByType<T, ValueType> = {13 [P in keyof T as T[P] extends ValueType ? P : never]: T[P];14};15
16interface User {17 id: string;18 name: string;19 age: number;20 email: string;21}22
23// Get only string properties24type UserStrings = PickByType<User, string>;25// { id: string; name: string; email: string }26
27// Make specific properties optional28type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;29
30type UserWithOptionalEmail = Optional<User, "email">;31// { id: string; name: string; age: number; email?: string }Event Handler Types
Type-safe event handlers in React:
typescript
1// Generic event handler type2type EventHandler<T = Element> = (event: React.FormEvent<T>) => void;3
4// Form events5function LoginForm() {6 const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {7 e.preventDefault();8 const formData = new FormData(e.currentTarget);9 // ...10 };11
12 const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {13 console.log(e.target.value); // ✓ Typed as string14 };15
16 return (17 <form onSubmit={handleSubmit}>18 <input name="email" onChange={handleChange} />19 <button type="submit">Login</button>20 </form>21 );22}23
24// Custom event handler with payload25interface SelectEvent<T> {26 value: T;27 label: string;28}29
30interface SelectProps<T> {31 options: T[];32 onChange: (event: SelectEvent<T>) => void;33}34
35function Select<T>({ options, onChange }: SelectProps<T>) {36 return (37 <select38 onChange={(e) => {39 const value = options[e.target.selectedIndex];40 onChange({ value, label: e.target.value });41 }}42 >43 {/* ... */}44 </select>45 );46}Context with Type Safety
Avoid
null checks with proper Context typing:typescript
1// ❌ Bad: Requires null checks everywhere2const UserContext = createContext<User | null>(null);3
4function useUser() {5 const user = useContext(UserContext);6 if (!user) throw new Error("useUser must be used within UserProvider");7 return user;8}9
10// ✅ Good: Type-safe from the start11interface UserContextValue {12 user: User;13 updateUser: (user: User) => void;14}15
16const UserContext = createContext<UserContextValue | undefined>(undefined);17
18function useUser() {19 const context = useContext(UserContext);20 if (!context) {21 throw new Error("useUser must be used within UserProvider");22 }23 return context; // Type: UserContextValue (never undefined here)24}25
26// Usage: No null checks needed27function Profile() {28 const { user, updateUser } = useUser(); // ✓ Always defined29 return <div>{user.name}</div>;30}Key Takeaways
- Use discriminated unions for state management to prevent impossible states
- Leverage generics to create reusable, type-safe components
- Type API responses to catch integration issues early
- Use utility types (Pick, Omit, Partial) for type transformations
- Const assertions preserve literal types for stricter typing
- Type guards bridge runtime and compile-time safety
- Branded types prevent mixing up similar primitives
- Proper Context typing eliminates unnecessary null checks
TypeScript is more than syntax—it's a tool for encoding invariants and preventing bugs before they reach production. Master these patterns and you'll write code that's both safer and more maintainable.
Questions about TypeScript patterns? Reach out.
Continue reading
Related Articles

Building Real-Time Features with WebSockets in Next.js
How to implement WebSocket communication for live notifications, collaborative editing, and real-time dashboards in modern web applications.
Read more

Backend Architecture for Modern SaaS Applications
A deep dive into scalable backend patterns, database design, and API architecture that power production SaaS platforms.
Read more

Database Schema Design: Lessons from Production Systems
Practical patterns for designing maintainable, scalable PostgreSQL schemas for SaaS applications with real-world examples.
Read more
