TypeScript has become the standard for modern React development, but most developers only use basic types. As a senior developer, I’ve seen teams struggle with TypeScript patterns that could dramatically improve their code quality and developer experience. Let’s explore the TypeScript patterns that separate junior developers from seniors.
Table of Contents
- Why TypeScript Patterns Matter
- Component Props Patterns
- State Management Types
- Advanced Type Patterns
- Common Pitfalls to Avoid
- Real-World Examples
- Best Practices
- Next Steps
Why TypeScript Patterns Matter
TypeScript is more than just adding types to JavaScript. It’s about:
- Catching errors at compile time instead of runtime
- Better developer experience with IntelliSense
- Self-documenting code that’s easier to maintain
- Refactoring confidence with type safety
The difference between a junior and senior TypeScript developer often comes down to how they leverage the type system.
Component Props Patterns
Basic Props Typing
// ❌ BAD: Any types everywhere
interface UserCardProps {
user: any;
onClick: any;
className?: any;
}
// ✅ GOOD: Specific, meaningful types
interface User {
id: string;
name: string;
email: string;
avatar?: string;
role: "admin" | "user" | "moderator";
}
interface UserCardProps {
user: User;
onClick: (userId: string) => void;
className?: string;
variant?: "default" | "compact" | "detailed";
}
Polymorphic Components
// Polymorphic component that can render as different HTML elements
interface PolymorphicProps<T extends React.ElementType = 'div'> {
as?: T;
children: React.ReactNode;
className?: string;
}
type PolymorphicComponentProps<T extends React.ElementType> =
PolymorphicProps<T> &
Omit<React.ComponentPropsWithoutRef<T>, keyof PolymorphicProps<T>>;
function PolymorphicComponent<T extends React.ElementType = 'div'>({
as,
children,
className,
...props
}: PolymorphicComponentProps<T>) {
const Component = as || 'div';
return (
<Component className={className} {...props}>
{children}
</Component>
);
}
// Usage
<PolymorphicComponent as="button" onClick={() => {}}>
Click me
</PolymorphicComponent>
<PolymorphicComponent as="h1" className="title">
Heading
</PolymorphicComponent>
Event Handler Types
// ❌ BAD: Generic event handlers
interface FormProps {
onSubmit: (event: any) => void;
onChange: (event: any) => void;
}
// ✅ GOOD: Specific event types
interface FormProps {
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
}
// Custom event handler type
type InputChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => void;
type FormSubmitHandler = (event: React.FormEvent<HTMLFormElement>) => void;
State Management Types
useState with Complex Types
// ❌ BAD: Multiple useState calls
function UserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState({});
}
// ✅ GOOD: Single state object with proper typing
interface UserFormState {
name: string;
email: string;
age: number;
isSubmitting: boolean;
errors: Record<string, string>;
}
const initialFormState: UserFormState = {
name: "",
email: "",
age: 0,
isSubmitting: false,
errors: {},
};
function UserForm() {
const [formState, setFormState] = useState<UserFormState>(initialFormState);
const updateField = (field: keyof UserFormState, value: any) => {
setFormState((prev) => ({ ...prev, [field]: value }));
};
}
useReducer with TypeScript
// Define action types
type UserAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_USER"; payload: User }
| { type: "SET_ERROR"; payload: string }
| { type: "RESET" };
// Define state type
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
// Type-safe reducer
const userReducer = (state: UserState, action: UserAction): UserState => {
switch (action.type) {
case "SET_LOADING":
return { ...state, loading: action.payload };
case "SET_USER":
return { ...state, user: action.payload, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, user: null };
case "RESET":
return { user: null, loading: false, error: null };
default:
return state;
}
};
// Usage
function UserProfile() {
const [state, dispatch] = useReducer(userReducer, {
user: null,
loading: false,
error: null,
});
const fetchUser = async (userId: string) => {
dispatch({ type: "SET_LOADING", payload: true });
try {
const user = await api.getUser(userId);
dispatch({ type: "SET_USER", payload: user });
} catch (error) {
dispatch({ type: "SET_ERROR", payload: error.message });
}
};
}
Advanced Type Patterns
Generics
// Generic API hook
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
function useApi<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then((response: ApiResponse<T>) => {
setData(response.data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [url]);
return { data, loading, error };
}
// Usage with specific types
interface User {
id: string;
name: string;
}
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useApi<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return <div>Hello, {user.name}!</div>;
}
Utility Types
// Partial - makes all properties optional
interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
type PartialUser = Partial<User>;
// Equivalent to:
// {
// id?: string;
// name?: string;
// email?: string;
// avatar?: string;
// }
// Pick - select specific properties
type UserBasicInfo = Pick<User, "id" | "name">;
// Equivalent to:
// {
// id: string;
// name: string;
// }
// Omit - exclude specific properties
type UserWithoutId = Omit<User, "id">;
// Equivalent to:
// {
// name: string;
// email: string;
// avatar?: string;
// }
// Record - create object type with specific keys and values
type UserRoles = Record<string, "admin" | "user" | "moderator">;
// Equivalent to:
// {
// [key: string]: 'admin' | 'user' | 'moderator';
// }
Conditional Types
// Conditional type based on input
type NonNullable<T> = T extends null | undefined ? never : T;
// Conditional type for API responses
type ApiResponse<T> = T extends User
? { data: User; type: "user" }
: T extends Post
? { data: Post; type: "post" }
: { data: T; type: "unknown" };
// Conditional type for form validation
type ValidationRule<T> = T extends string
? { minLength?: number; maxLength?: number; pattern?: RegExp }
: T extends number
? { min?: number; max?: number }
: { required?: boolean };
// Usage
const stringValidation: ValidationRule<string> = {
minLength: 3,
maxLength: 50,
pattern: /^[a-zA-Z\s]+$/,
};
const numberValidation: ValidationRule<number> = {
min: 0,
max: 120,
};
Common Pitfalls to Avoid
1. Over-typing
// ❌ BAD: Overly specific types
interface ButtonProps {
children: React.ReactNode;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
className?: string;
disabled?: boolean;
type?: "button" | "submit" | "reset";
form?: string;
formAction?: string;
formEncType?:
| "application/x-www-form-urlencoded"
| "multipart/form-data"
| "text/plain";
formMethod?: "get" | "post";
formNoValidate?: boolean;
formTarget?: "_self" | "_blank" | "_parent" | "_top";
name?: string;
value?: string;
}
// ✅ GOOD: Extend HTML button props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
size?: "small" | "medium" | "large";
}
2. Type Assertions
// ❌ BAD: Unsafe type assertions
function processData(data: unknown) {
const user = data as User; // Unsafe!
return user.name;
}
// ✅ GOOD: Type guards
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"name" in data &&
"email" in data
);
}
function processData(data: unknown) {
if (isUser(data)) {
return data.name; // TypeScript knows data is User
}
throw new Error("Invalid user data");
}
3. Any Types
// ❌ BAD: Using any everywhere
function handleApiResponse(response: any) {
return response.data.map((item: any) => item.name);
}
// ✅ GOOD: Proper typing
interface ApiResponse<T> {
data: T[];
status: number;
message: string;
}
interface ApiItem {
id: string;
name: string;
}
function handleApiResponse(response: ApiResponse<ApiItem>) {
return response.data.map((item) => item.name);
}
Real-World Examples
Form Validation with TypeScript
interface FormField<T> {
value: T;
error?: string;
touched: boolean;
}
interface FormState {
name: FormField<string>;
email: FormField<string>;
age: FormField<number>;
}
type FormErrors<T> = {
[K in keyof T]?: string;
};
function useForm<T extends Record<string, any>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<FormErrors<T>>({});
const [touched, setTouched] = useState<Record<keyof T, boolean>>(
{} as Record<keyof T, boolean>,
);
const setFieldValue = useCallback((field: keyof T, value: T[keyof T]) => {
setValues((prev) => ({ ...prev, [field]: value }));
}, []);
const setFieldError = useCallback((field: keyof T, error: string) => {
setErrors((prev) => ({ ...prev, [field]: error }));
}, []);
const setFieldTouched = useCallback((field: keyof T, touched: boolean) => {
setTouched((prev) => ({ ...prev, [field]: touched }));
}, []);
return {
values,
errors,
touched,
setFieldValue,
setFieldError,
setFieldTouched,
};
}
API Client with TypeScript
interface ApiClient {
get<T>(url: string): Promise<T>;
post<T, D>(url: string, data: D): Promise<T>;
put<T, D>(url: string, data: D): Promise<T>;
delete<T>(url: string): Promise<T>;
}
class TypedApiClient implements ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
async get<T>(url: string): Promise<T> {
const response = await fetch(`${this.baseURL}${url}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async post<T, D>(url: string, data: D): Promise<T> {
const response = await fetch(`${this.baseURL}${url}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async put<T, D>(url: string, data: D): Promise<T> {
const response = await fetch(`${this.baseURL}${url}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async delete<T>(url: string): Promise<T> {
const response = await fetch(`${this.baseURL}${url}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}
// Usage
const api = new TypedApiClient("https://api.example.com");
interface User {
id: string;
name: string;
email: string;
}
interface CreateUserData {
name: string;
email: string;
}
// TypeScript knows the return types
const user = await api.get<User>("/users/1");
const newUser = await api.post<User, CreateUserData>("/users", {
name: "John Doe",
email: "john@example.com",
});
Best Practices
1. Use Strict Mode
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
2. Type Naming Conventions
// Interfaces for objects
interface UserProfile {
/* ... */
}
interface ApiResponse<T> {
/* ... */
}
// Types for unions, primitives, or complex types
type UserRole = "admin" | "user" | "moderator";
type ApiStatus = "idle" | "loading" | "success" | "error";
// Enums for constants
enum HttpStatus {
OK = 200,
CREATED = 201,
BAD_REQUEST = 400,
NOT_FOUND = 404,
}
3. Export Types from Components
// Component with exported types
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
children: React.ReactNode;
}
export function Button({ variant = 'primary', size = 'medium', children, ...props }: ButtonProps) {
return (
<button className={`btn btn-${variant} btn-${size}`} {...props}>
{children}
</button>
);
}
// Usage in other files
import { Button, ButtonProps } from './Button';
Next Steps
1. Advanced TypeScript Features
- Mapped Types for transforming types
- Template Literal Types for string manipulation
- Infer Types for extracting types
2. TypeScript Libraries
- Zod for runtime validation
- TypeScript ESLint for linting
- ts-node for running TypeScript directly
3. Testing with TypeScript
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with correct variant', () => {
render(<Button variant="primary">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-primary');
});
});
4. Related Learning Resources
- React Hooks Patterns - Combine with TypeScript
- React Performance - Type-safe optimizations
- Next.js App Router - Modern TypeScript patterns
Conclusion
TypeScript patterns are what separate junior developers from seniors. The key is understanding not just how to add types, but how to leverage the type system to write better, safer code.
Key Takeaways for Junior Developers:
- Start with strict mode - Catch errors early
- Use utility types - Don’t reinvent the wheel
- Avoid
any
- Use proper types orunknown
- Leverage generics - Write reusable, type-safe code
- Export your types - Share type definitions across your app
Remember: TypeScript is not just about types, it’s about better code. Use the type system to guide your development and catch errors before they reach production.
Related Articles:
- React Hooks Patterns - Combine with TypeScript
- React Performance - Type-safe optimizations
- Next.js App Router - Modern TypeScript patterns
Start writing better TypeScript today! 🚀