DNA🔷 TypeScriptTypeScript in React
ðŸĢHatchlingTypeScriptReactPatterns

TypeScript in React

Typing React components, hooks, and patterns correctly is what makes TypeScript actually useful instead of just annoying. Here's how senior engineers do it.

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:

  1. API design taste — using discriminated unions to prevent invalid prop combinations
  2. Generic fluency — building reusable components that infer types from usage
  3. Pattern knowledge — as const, forwardRef typing, context patterns
  4. Pragmatism — knowing when any or a type assertion is the right trade-off vs. fighting the compiler for hours