Skip to main content
website logo savvydev
TypeScript Patterns for React: From Junior to Senior Developer

TypeScript Patterns for React: From Junior to Senior Developer

Master TypeScript patterns that will transform your React development. Learn type safety, generics, utility types, and advanced patterns that senior developers use daily.

TypeScript React JavaScript Frontend Type Safety Generics

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

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

2. TypeScript Libraries

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');
  });
});

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:

  1. Start with strict mode - Catch errors early
  2. Use utility types - Don’t reinvent the wheel
  3. Avoid any - Use proper types or unknown
  4. Leverage generics - Write reusable, type-safe code
  5. 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:

Start writing better TypeScript today! 🚀