DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Stop Fighting TypeScript in React: Common Frustrations and How to Fix Them

You copied an event handler from JavaScript to TypeScript. Red squiggly lines everywhere. The error message is incomprehensible. You try a few things. Nothing works. You google. Stack Overflow suggests as any. It works, but you feel dirty.

I've been there. We've all been there.

TypeScript in React can feel like fighting the compiler instead of the bugs. The type system that's supposed to help you seems to actively work against you. Simple things in JavaScript become battles in TypeScript.

But here's the thing: most TypeScript frustrations in React have simple solutions. You just need to know the patterns. This is your field guide to those patterns—organized by the error messages that drive us crazy.

Frustration 1: "Type 'X' is not assignable to parameter of type 'Y'"

This is the granddaddy of TypeScript frustrations. You'll see it everywhere.

The Scenario: Event Handlers

// ❌ This drives people crazy
const handleClick = (event) => {
  // Parameter 'event' implicitly has an 'any' type
  console.log(event.target.value);
};

<button onClick={handleClick}>Click me</button>
Enter fullscreen mode Exit fullscreen mode

The Error:

Parameter 'event' implicitly has an 'any' type.
Enter fullscreen mode Exit fullscreen mode

Why it happens: TypeScript doesn't know what type event should be.

The Solution:

// ✅ Tell TypeScript the event type
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
  console.log(event.currentTarget);
};
Enter fullscreen mode Exit fullscreen mode

The Pattern:

React.[EventType]<HTML[ElementType]>
Enter fullscreen mode Exit fullscreen mode

Quick Reference:

// Clicks
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {}}

// Input changes
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {}}

// Form submits
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {}}

// Keyboard
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {}}

// Focus
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {}}
Enter fullscreen mode Exit fullscreen mode

The Even Better Solution

Let TypeScript infer when you can:

// ✅ Inline - type is inferred!
<button onClick={(e) => console.log(e.currentTarget)}>
  Click me
</button>

// ✅ Only type when extracting the function
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.currentTarget);
};
Enter fullscreen mode Exit fullscreen mode

Frustration 2: "Object is possibly 'null'"

This one makes developers want to throw their keyboards.

The Scenario: Refs

const inputRef = useRef<HTMLInputElement>(null);

// ❌ Object is possibly 'null'
inputRef.current.focus();
Enter fullscreen mode Exit fullscreen mode

The Error:

Object is possibly 'null'.
Enter fullscreen mode Exit fullscreen mode

Why it happens: Refs start as null before the component mounts. TypeScript is protecting you from a runtime error.

The Solutions:

// ✅ Option 1: Optional chaining (cleanest)
inputRef.current?.focus();

// ✅ Option 2: Check explicitly
if (inputRef.current) {
  inputRef.current.focus();
}

// ✅ Option 3: Non-null assertion (use sparingly!)
// Only if you KNOW it's mounted
inputRef.current!.focus();

// ❌ Don't do this
(inputRef.current as any).focus();
Enter fullscreen mode Exit fullscreen mode

When to use each:

  • Optional chaining: Default choice, handles null gracefully
  • Explicit check: When you need to do something else if null
  • Non-null assertion: In useEffect or event handlers where you know it's mounted
  • Type assertion to any: Never (defeats the purpose of TypeScript)

The Scenario: State

const [user, setUser] = useState<User | null>(null);

// ❌ Object is possibly 'null'
return <div>{user.name}</div>;
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Option 1: Early return
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;

// ✅ Option 2: Optional chaining with fallback
return <div>{user?.name ?? 'Guest'}</div>;

// ✅ Option 3: Conditional rendering
return <div>{user ? user.name : 'Loading...'}</div>;
Enter fullscreen mode Exit fullscreen mode

Frustration 3: "Type 'string' is not assignable to type 'never'"

This cryptic error often appears with arrays and state.

The Scenario: Empty Array State

// ❌ TypeScript thinks this is never[]
const [items, setItems] = useState([]);

// Later...
setItems(['item1', 'item2']);
// Type 'string' is not assignable to type 'never'
Enter fullscreen mode Exit fullscreen mode

Why it happens: TypeScript infers never[] from an empty array (an array that can never have items).

The Solution:

// ✅ Provide the type explicitly
const [items, setItems] = useState<string[]>([]);

// ✅ Or with objects
interface Todo {
  id: string;
  text: string;
}

const [todos, setTodos] = useState<Todo[]>([]);
Enter fullscreen mode Exit fullscreen mode

The Pattern: Always type empty array state explicitly.

Frustration 4: "Property 'X' does not exist on type 'Y'"

This usually happens when spreading props or working with events.

The Scenario: Event.target.value

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  // ❌ Property 'files' does not exist on type 'HTMLInputElement'
  console.log(e.target.files);
};
Enter fullscreen mode Exit fullscreen mode

Why it happens: You typed it as HTMLInputElement but it's actually a file input.

The Solution:

// ✅ Use the right element type
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const files = e.currentTarget.files; // Use currentTarget for type safety
  if (files) {
    console.log(files[0]);
  }
};

// ✅ Or type assert if you know the element type
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const target = e.target as HTMLInputElement;
  if (target.type === 'file') {
    console.log(target.files);
  }
};
Enter fullscreen mode Exit fullscreen mode

The Scenario: Spreading Props

interface ButtonProps {
  label: string;
  onClick: () => void;
}

function MyButton(props: ButtonProps) {
  // ❌ Property 'className' does not exist on type 'ButtonProps'
  return <button {...props} />;
}

// Usage
<MyButton label="Click" onClick={handleClick} className="btn" />
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Extend the HTML button attributes
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  label: string;
}

function MyButton({ label, ...props }: ButtonProps) {
  return <button {...props}>{label}</button>;
}

// Now this works!
<MyButton label="Click" onClick={handleClick} className="btn" disabled />
Enter fullscreen mode Exit fullscreen mode

Common extends:

React.ButtonHTMLAttributes<HTMLButtonElement>
React.InputHTMLAttributes<HTMLInputElement>
React.HTMLAttributes<HTMLDivElement>
React.AnchorHTMLAttributes<HTMLAnchorElement>
Enter fullscreen mode Exit fullscreen mode

Frustration 5: "No overload matches this call"

This cryptic error often appears with generics.

The Scenario: Generic Components

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

// ❌ No overload matches this call
function List<T>(props: ListProps<T>) {
  return <div>{props.items.map(props.renderItem)}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Why it happens: TypeScript struggles with generic function components.

The Solution:

// ✅ Option 1: Type the function properly
function List<T>({ items, renderItem }: ListProps<T>): React.ReactElement {
  return <div>{items.map(renderItem)}</div>;
}

// ✅ Option 2: Use React.FC (though it's fallen out of favor)
const List: <T>(props: ListProps<T>) => React.ReactElement = ({ items, renderItem }) => {
  return <div>{items.map(renderItem)}</div>;
};

// ✅ Option 3: Arrow function with explicit type
const List = <T,>({ items, renderItem }: ListProps<T>) => {
  return <div>{items.map(renderItem)}</div>;
};
// Note the comma after T - prevents JSX parsing issues
Enter fullscreen mode Exit fullscreen mode

Usage:

<List
  items={users}
  renderItem={(user) => <div>{user.name}</div>}
/>
Enter fullscreen mode Exit fullscreen mode

Frustration 6: "Argument of type 'X' is not assignable to parameter of type 'SetStateAction'"

This happens when updating state with a complex object.

The Scenario: Updating Nested State

interface User {
  name: string;
  email: string;
  address: {
    street: string;
    city: string;
  };
}

const [user, setUser] = useState<User>(/* ... */);

// ❌ Type error nightmare
setUser({
  name: 'John',
  email: 'john@example.com'
  // Missing 'address'
});
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Option 1: Spread the previous state
setUser(prev => ({
  ...prev,
  name: 'John'
}));

// ✅ Option 2: Spread nested objects carefully
setUser(prev => ({
  ...prev,
  address: {
    ...prev.address,
    city: 'New York'
  }
}));

// ✅ Option 3: Use immer for complex updates
import { produce } from 'immer';

setUser(prev => produce(prev, draft => {
  draft.address.city = 'New York';
}));
Enter fullscreen mode Exit fullscreen mode

Frustration 7: "Cannot invoke an object which is possibly 'undefined'"

This happens constantly with optional props and callbacks.

The Scenario: Optional Callbacks

interface Props {
  onClick?: () => void;
}

function Button({ onClick }: Props) {
  // ❌ Cannot invoke an object which is possibly 'undefined'
  return <button onClick={onClick}>Click</button>;
}
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Option 1: Optional chaining
return <button onClick={() => onClick?.()}>Click</button>;

// ✅ Option 2: Guard clause
const handleClick = () => {
  if (onClick) {
    onClick();
  }
};
return <button onClick={handleClick}>Click</button>;

// ✅ Option 3: Default value
function Button({ onClick = () => {} }: Props) {
  return <button onClick={onClick}>Click</button>;
}
Enter fullscreen mode Exit fullscreen mode

Frustration 8: ForwardRef and useImperativeHandle Hell

ForwardRef is notoriously difficult to type correctly.

The Scenario: ForwardRef

// ❌ This looks right but has issues
const Input = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Type both props and ref
interface InputProps {
  placeholder?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />;
});

// Usage
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} placeholder="Enter text" />
Enter fullscreen mode Exit fullscreen mode

The Scenario: useImperativeHandle

interface InputMethods {
  focus: () => void;
  clear: () => void;
}

const Input = forwardRef<InputMethods, InputProps>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current?.focus();
    },
    clear: () => {
      if (inputRef.current) {
        inputRef.current.value = '';
      }
    }
  }));

  return <input ref={inputRef} {...props} />;
});

// Usage
const inputRef = useRef<InputMethods>(null);
inputRef.current?.focus(); // ✅ TypeScript knows these methods exist
inputRef.current?.clear();
Enter fullscreen mode Exit fullscreen mode

Frustration 9: "Type 'ReactNode' is not assignable to type 'ReactElement'"

This happens when mixing different children types.

The Scenario: Restrictive Children

interface LayoutProps {
  children: React.ReactElement;
}

function Layout({ children }: LayoutProps) {
  return <div className="layout">{children}</div>;
}

// ❌ Error: Type 'string' is not assignable to type 'ReactElement'
<Layout>Hello World</Layout>

// ❌ Error: Type 'Element[]' is not assignable to type 'ReactElement'
<Layout>
  <div>One</div>
  <div>Two</div>
</Layout>
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Use ReactNode for any renderable content
interface LayoutProps {
  children: React.ReactNode;
}

// ✅ Use ReactElement only when you truly need a single element
interface WrapperProps {
  children: React.ReactElement;
}

// ✅ Use ReactElement[] for multiple elements
interface TabsProps {
  children: React.ReactElement[];
}

// ✅ Or be very specific
interface TabsProps {
  children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[];
}
Enter fullscreen mode Exit fullscreen mode

Frustration 10: "Expression produces a union type that is too complex to represent"

This monster appears with complex state or reducers.

The Scenario: Complex Reducer Actions

// ❌ Gets too complex quickly
type Action =
  | { type: 'SET_USER'; payload: User }
  | { type: 'SET_POSTS'; payload: Post[] }
  | { type: 'SET_COMMENTS'; payload: Comment[] }
  // ... 20 more actions
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Split into smaller action groups
type UserAction =
  | { type: 'USER_LOGIN'; payload: User }
  | { type: 'USER_LOGOUT' };

type PostAction =
  | { type: 'POST_CREATE'; payload: Post }
  | { type: 'POST_DELETE'; payload: string };

type Action = UserAction | PostAction;

// ✅ Or use discriminated unions with a common field
type Action = {
  domain: 'user' | 'post' | 'comment';
  type: string;
  payload?: any;
};
Enter fullscreen mode Exit fullscreen mode

Frustration 11: Third-Party Libraries Without Types

This is common with older or poorly maintained libraries.

The Scenario: No Type Definitions

// ❌ Could not find a declaration file for module 'old-library'
import something from 'old-library';
Enter fullscreen mode Exit fullscreen mode

The Solutions:

// ✅ Option 1: Check DefinitelyTyped
npm install --save-dev @types/old-library

// ✅ Option 2: Create a declaration file
// Create: src/types/old-library.d.ts
declare module 'old-library' {
  export function something(param: string): void;
}

// ✅ Option 3: Quick and dirty (for prototyping only)
declare module 'old-library';
// Now you can import, but no type safety

// ✅ Option 4: Type specific imports
// src/types/old-library.d.ts
declare module 'old-library' {
  const library: any;
  export default library;
}
Enter fullscreen mode Exit fullscreen mode

Frustration 12: "Type instantiation is excessively deep and possibly infinite"

This cryptic error appears with deeply nested types.

The Scenario: Recursive Components

interface TreeNode {
  value: string;
  children: TreeNode[];
}

// ❌ Can cause "excessively deep" errors with deep trees
interface TreeProps {
  node: TreeNode;
}
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ Add a depth limit or simplify the type
interface TreeNode {
  value: string;
  children?: TreeNode[]; // Make optional
}

// ✅ Or use unknown for very deep structures
interface TreeNode {
  value: string;
  children?: unknown; // Type more loosely
}

// ✅ Or limit recursion depth
type TreeNode = {
  value: string;
  children?: Array<{
    value: string;
    children?: Array<{
      value: string;
      // Stop here
    }>;
  }>;
};
Enter fullscreen mode Exit fullscreen mode

The Escape Hatches (Use Wisely)

Sometimes you need to tell TypeScript "trust me."

The any Escape Hatch

// ❌ Never do this everywhere
const handleChange = (e: any) => {
  e.target.value; // No safety at all
};

// ✅ Use any surgically for truly dynamic code
const processData = (data: any) => {
  // External API with unknown shape
  return data;
};

// ✅ Better: use unknown and narrow
const processData = (data: unknown) => {
  if (typeof data === 'object' && data !== null) {
    // Type narrowing
  }
};
Enter fullscreen mode Exit fullscreen mode

The as Assertion

// ❌ Don't lie to TypeScript
const value = someValue as CompletelyDifferentType;

// ✅ Use for widening or narrowing you're certain about
const input = e.target as HTMLInputElement;
const value = response.data as User;

// ✅ Better: use type guards
function isUser(data: unknown): data is User {
  return typeof data === 'object' && data !== null && 'name' in data;
}

if (isUser(response.data)) {
  // TypeScript knows it's User here
}
Enter fullscreen mode Exit fullscreen mode

The Non-Null Assertion

// ❌ Don't use when you're not sure
const value = maybeNull!.property;

// ✅ Use only when you have proof
useEffect(() => {
  // Inside useEffect, ref is guaranteed to be set
  inputRef.current!.focus();
}, []);

// ✅ Better: handle the null case
inputRef.current?.focus();
Enter fullscreen mode Exit fullscreen mode

Debugging TypeScript Errors

When you see a massive error, here's how to decode it:

1. Start at the End

Type 'X' is not assignable to type 'Y'.
  Type 'A' is not assignable to type 'B'.
    Type 'C' is not assignable to type 'D'.
      Type 'string' is not assignable to type 'number'. <-- Start here
Enter fullscreen mode Exit fullscreen mode

The last line usually tells you the actual problem.

2. Hover Over Everything

Hover over variables in VSCode to see what TypeScript thinks the type is. Often reveals mismatches.

3. Simplify

Comment out code until the error goes away, then add back piece by piece.

4. Check the Documentation

React type definitions are well-documented. Ctrl/Cmd + Click on types to see their definitions.

5. Use Type Utilities

// See what TypeScript infers
type WhatIsThis = typeof myVariable;

// Extract types
type PropsType = React.ComponentProps<typeof MyComponent>;
type ReturnType = ReturnType<typeof myFunction>;
Enter fullscreen mode Exit fullscreen mode

The "Why Does This Work?" Examples

Sometimes TypeScript just works, and you're not sure why. Here's the magic:

Type Inference in Event Handlers

// TypeScript knows e is React.MouseEvent<HTMLButtonElement>
<button onClick={(e) => console.log(e.currentTarget)}>
  Click
</button>
Enter fullscreen mode Exit fullscreen mode

Why: The onClick prop is typed in React's button definition, so TypeScript infers the event type.

Destructuring Props

function Button({ onClick, children }: ButtonProps) {
  // TypeScript knows the types of onClick and children
}
Enter fullscreen mode Exit fullscreen mode

Why: Destructuring maintains types from the interface.

Return Type Inference

function useCounter(initial: number) {
  const [count, setCount] = useState(initial);
  return { count, setCount }; // TypeScript infers the return type
}
Enter fullscreen mode Exit fullscreen mode

Why: TypeScript can infer complex return types from the implementation.

Quick Fixes Cheat Sheet

// ❌ Problem → ✅ Solution

// Parameter implicitly has 'any' type
const handler = (e) => {}
const handler = (e: React.ChangeEvent<HTMLInputElement>) => {}

// Object is possibly 'null'
ref.current.focus()
ref.current?.focus()

// Type 'never[]' is not assignable
const [items, setItems] = useState([])
const [items, setItems] = useState<string[]>([])

// Property 'X' does not exist
interface Props { label: string }
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> { label: string }

// Cannot invoke possibly 'undefined'
<button onClick={onClick}>
<button onClick={() => onClick?.()}>

// Type 'string' is not assignable to 'ReactElement'
children: React.ReactElement
children: React.ReactNode

// Could not find declaration file
import 'library'
npm install @types/library OR create library.d.ts

// Excessive depth
type Deep = { children: Deep[] }
type Deep = { children?: unknown }
Enter fullscreen mode Exit fullscreen mode

When to Stop Fighting

Sometimes TypeScript is telling you something important:

Listen to TypeScript when:

  • It catches actual bugs (null references, wrong types)
  • It prevents runtime errors
  • It enforces your own type definitions

Push back on TypeScript when:

  • The types don't match reality (external APIs)
  • You're writing throw-away prototype code
  • The complexity outweighs the benefit

The Golden Rules

  1. Let TypeScript infer when it can - Don't over-annotate
  2. Type at boundaries - Props, API responses, state initialization
  3. Use unknown over any - Force yourself to narrow types
  4. Read the error from bottom to top - The real problem is usually at the end
  5. Trust the compiler - If it's hard to type, maybe the design needs work
  6. Use escape hatches sparingly - They're for last resort, not first choice

Conclusion

TypeScript frustration in React usually comes from not knowing the patterns. Once you've seen the pattern, the error transforms from "WHAT DOES THIS MEAN?!" to "Oh right, I need to type that."

Keep this guide bookmarked. Next time you see Type 'X' is not assignable to type 'Y', come back here. Find your error. Copy the solution. Move on with your life.

TypeScript in React should make you more productive, not less. These patterns are your shortcuts to that productivity.

Stop fighting. Start flowing.


What TypeScript error drives you the craziest? Share in the comments—I'll add solutions for the most common ones.

Top comments (0)