Steve Kinney

Type Inference

You’ve probably noticed TypeScript can be pretty smart about figuring out types on its own. But when should you let it work its magic, and when should you be explicit? Let’s master the art of type inference to write cleaner React code with less boilerplate.

The Golden Rule

Here’s the thing about type inference: be explicit at boundaries, implicit within implementations. What does that mean? Let’s see it in action:

// ❌ Over-annotating everything
const MyComponent = () => {
  const [count, setCount]: [number, React.Dispatch<React.SetStateAction<number>>] = useState<number>(0);
  const doubled: number = count * 2;
  const message: string = `Count is ${count}`;

  return <div>{message}</div>;
};

// ✅ Let inference do its job
const MyComponent = () => {
  const [count, setCount] = useState(0); // TypeScript knows it's number
  const doubled = count * 2;              // Inferred as number
  const message = `Count is ${count}`;    // Inferred as string

  return <div>{message}</div>;
};

When TypeScript Already Knows

TypeScript is surprisingly good at figuring things out from context. Here are the situations where you can trust inference:

Variable Initialization

// TypeScript infers these perfectly
const name = 'Alice'; // string
const age = 30; // number
const isActive = true; // boolean
const items = [1, 2, 3]; // number[]
const user = { id: 1, name }; // { id: number; name: string }

// Even complex expressions
const doubled = items.map((x) => x * 2); // number[]
const names = users.map((u) => u.name); // string[]

Function Return Types (Sometimes)

// Return type is inferred from implementation
function calculateTotal(items: Item[]) {
  return items.reduce((sum, item) => sum + item.price, 0);
  // TypeScript knows this returns number
}

// React components have inferred return types
const Button = ({ onClick, children }: ButtonProps) => {
  return <button onClick={onClick}>{children}</button>;
  // TypeScript knows this returns JSX.Element
};

Array Methods and Callbacks

const numbers = [1, 2, 3, 4, 5];

// TypeScript infers all the callback parameters and return types
const doubled = numbers.map((n) => n * 2);
const evens = numbers.filter((n) => n % 2 === 0);
const sum = numbers.reduce((acc, n) => acc + n, 0);

// Even with objects
const users = [
  { id: 1, name: 'Alice', age: 30 },
  { id: 2, name: 'Bob', age: 25 },
];

const names = users.map((user) => user.name); // string[]
const adults = users.filter((user) => user.age >= 18); // same type as users

When to Be Explicit

Now let’s talk about when you SHOULD write types explicitly:

Function Parameters

// ❌ This won't work - parameters need types
function greet(name) {
  // Error: Parameter 'name' implicitly has an 'any' type
  return `Hello, ${name}`;
}

// ✅ Always type parameters
function greet(name: string) {
  return `Hello, ${name}`; // Return type is inferred as string
}

Component Props

// ❌ Don't rely on inference for props
const Button = ({ onClick, children }) => {  // Any types!
  return <button onClick={onClick}>{children}</button>;
};

// ✅ Always type your props
interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}

const Button = ({ onClick, children }: ButtonProps) => {
  return <button onClick={onClick}>{children}</button>;
};

Empty Arrays and Objects

// ❌ TypeScript can't know what you'll put in here
const items = []; // any[]
items.push('string');
items.push(123); // No error!

// ✅ Be explicit about empty collections
const items: string[] = [];
items.push('string'); // OK
items.push(123); // Error!

// Same with objects
const cache: Record<string, User> = {};

When You Want to Constrain Types

// You want to ensure this can only be specific values
type Status = 'pending' | 'success' | 'error';

// ❌ Without annotation, it's just string
const status = 'pending'; // string

// ✅ With annotation, it's constrained
const status: Status = 'pending'; // Status

// This prevents mistakes
const status2: Status = 'complete'; // Error!

Advanced Inference Patterns

Const Assertions

Sometimes you want TypeScript to be more specific:

// Without const assertion
const config = {
  endpoint: '/api/users',
  method: 'GET',
};
// Type: { endpoint: string; method: string }

// With const assertion
const config = {
  endpoint: '/api/users',
  method: 'GET',
} as const;
// Type: { readonly endpoint: '/api/users'; readonly method: 'GET' }

// Useful for arrays too
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']

Contextual Typing

TypeScript uses context to infer types in many situations:

// Event handlers in React
<button onClick={e => {
  // TypeScript knows e is MouseEvent<HTMLButtonElement>
  console.log(e.currentTarget.disabled);
}}>
  Click me
</button>

// Array.forEach callbacks
['a', 'b', 'c'].forEach((letter, index) => {
  // TypeScript knows letter is string, index is number
  console.log(`${index}: ${letter.toUpperCase()}`);
});

Generic Inference

TypeScript is great at inferring generic types:

// Generic function
function identity<T>(value: T): T {
  return value;
}

// You don't need to specify T
const num = identity(42); // T is inferred as number
const str = identity('hello'); // T is inferred as string

// With React hooks
const [user, setUser] = useState<User | null>(null);
// But if you have an initial value:
const [count, setCount] = useState(0); // Generic inferred as number

React-Specific Inference

useState Inference

// TypeScript infers from initial value
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [items, setItems] = useState<string[]>([]); // Need explicit type for empty array

// Complex state
const [user, setUser] = useState({
  id: 1,
  name: 'Alice',
}); // { id: number; name: string }

useReducer Inference

// TypeScript infers a lot from your reducer
const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
};

// TypeScript knows the state type and dispatch signature
const [state, dispatch] = useReducer(reducer, { count: 0 });

Event Handler Inference

const Form = () => {
  // TypeScript infers the event type from usage
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Process form
  };

  // Or let inference handle it in JSX
  return (
    <form onSubmit={e => {
      // e is inferred as React.FormEvent<HTMLFormElement>
      e.preventDefault();
    }}>
      <input onChange={e => {
        // e is inferred as React.ChangeEvent<HTMLInputElement>
        console.log(e.target.value);
      }} />
    </form>
  );
};

Control Flow Inference

TypeScript gets smarter as your code narrows types:

function processValue(value: string | number | null) {
  if (value === null) {
    return 'No value';
  }
  // TypeScript knows value is string | number here

  if (typeof value === 'string') {
    // TypeScript knows value is string here
    return value.toUpperCase();
  }

  // TypeScript knows value is number here
  return value.toFixed(2);
}

Destructuring and Inference

// TypeScript infers through destructuring
const { name, age } = user; // Types inferred from user

// In function parameters
function greet({ name, age }: User) {
  // name is string, age is number
  return `${name} is ${age} years old`;
}

// With arrays
const [first, second] = [1, 2, 3]; // both are number
const [str, num] = ['hello', 42]; // str is string, num is number

The satisfies Operator

TypeScript 4.9 introduced satisfies for better inference with constraints:

// Without satisfies - loses specific types
const config: Record<string, string | number> = {
  port: 3000,
  host: 'localhost',
};
config.port.toFixed(); // Error! port is string | number

// With satisfies - keeps specific types
const config = {
  port: 3000,
  host: 'localhost',
} satisfies Record<string, string | number>;
config.port.toFixed(); // Works! port is number

Common Inference Pitfalls

Widening

// TypeScript widens types by default
let status = 'pending'; // string, not 'pending'
status = 'complete'; // Allowed

// Prevent widening with const
const status = 'pending'; // 'pending'

// Or with type annotation
let status: 'pending' | 'complete' = 'pending';

Object Property Inference

// Properties are widened
const config = {
  retries: 3,
  timeout: 1000,
};
// Type: { retries: number; timeout: number }

// Use as const for literal types
const config = {
  retries: 3,
  timeout: 1000,
} as const;
// Type: { readonly retries: 3; readonly timeout: 1000 }

Function Return Type Issues

// Sometimes you need explicit return types
function createUser(name: string) {
  if (!name) {
    return null; // Oops, now return type is User | null
  }
  return { id: Math.random(), name };
}

// Be explicit when the return type matters
function createUser(name: string): User {
  if (!name) {
    throw new Error('Name required');
  }
  return { id: Math.random(), name };
}

Best Practices for React

Let Hooks Infer When Possible

// ✅ Good - let TypeScript infer
const [count, setCount] = useState(0);
const mounted = useRef(false);
const id = useId();

// ❌ Unnecessary - TypeScript already knows
const [count, setCount] = useState<number>(0);
const mounted = useRef<boolean>(false);
const id: string = useId();

Be Explicit at Component Boundaries

// ✅ Always type props
interface CardProps {
  title: string;
  description?: string;
}

const Card = ({ title, description }: CardProps) => {
  // Let inference handle the rest
  const hasDescription = !!description;
  const charCount = title.length;

  return (
    <div>
      <h2>{title}</h2>
      {hasDescription && <p>{description}</p>}
    </div>
  );
};

Use Inference for Event Handlers

const SearchForm = () => {
  return (
    <form
      onSubmit={e => {
        // Let TypeScript infer e as React.FormEvent<HTMLFormElement>
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        // Process form
      }}
    >
      <input
        onChange={e => {
          // Let TypeScript infer e as React.ChangeEvent<HTMLInputElement>
          console.log(e.target.value);
        }}
      />
    </form>
  );
};

Performance Considerations

Type inference doesn’t affect runtime performance, but it can affect TypeScript compilation speed:

// Faster - explicit types
interface UserListProps {
  users: User[];
  onSelect: (user: User) => void;
}

const UserList = ({ users, onSelect }: UserListProps) => {
  // Implementation
};

// Slower - complex inference
const UserList = ({
  users,
  onSelect,
}: {
  users: Array<{
    id: number;
    name: string;
    email: string;
    profile: {
      avatar: string;
      bio: string;
    };
  }>;
  onSelect: (user: {
    id: number;
    name: string;
    email: string;
    profile: {
      avatar: string;
      bio: string;
    };
  }) => void;
}) => {
  // Implementation
};

Real-World Example

Let’s put it all together with a real component:

// Define types at boundaries
interface Task {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

interface TaskListProps {
  initialTasks?: Task[];
  onTaskComplete?: (taskId: string) => void;
}

const TaskList = ({ initialTasks = [], onTaskComplete }: TaskListProps) => {
  // Let inference handle internal state
  const [tasks, setTasks] = useState(initialTasks);
  const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');

  // Inference for computed values
  const filteredTasks = tasks.filter(task => {
    if (filter === 'all') return true;
    if (filter === 'active') return !task.completed;
    return task.completed;
  });

  const stats = {
    total: tasks.length,
    completed: tasks.filter(t => t.completed).length,
    active: tasks.filter(t => !t.completed).length
  };

  // Event handlers with inferred types
  const handleToggle = (taskId: string) => {
    setTasks(prev => prev.map(task =>
      task.id === taskId
        ? { ...task, completed: !task.completed }
        : task
    ));

    const task = tasks.find(t => t.id === taskId);
    if (task && !task.completed) {
      onTaskComplete?.(taskId);
    }
  };

  return (
    <div>
      <div>
        {/* Let TypeScript infer event types */}
        <select onChange={e => setFilter(e.target.value as typeof filter)}>
          <option value="all">All ({stats.total})</option>
          <option value="active">Active ({stats.active})</option>
          <option value="completed">Completed ({stats.completed})</option>
        </select>
      </div>

      <ul>
        {filteredTasks.map(task => (
          <li key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.completed}
                onChange={() => handleToggle(task.id)}
              />
              <span>{task.title}</span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
};

Guidelines Summary

When to Let TypeScript Infer:

  1. Variable initialization with values
  2. Return types of simple functions
  3. Callback parameters in array methods
  4. Event handlers in JSX
  5. Generic type parameters with clear context
  6. Computed values and transformations

When to Be Explicit:

  1. Function parameters
  2. Component props
  3. Empty arrays and objects
  4. Union types and constraints
  5. Public API boundaries
  6. Complex return types
  7. When inference would be any

The Balance

The key is finding the right balance. Too many type annotations make your code verbose and harder to read. Too few, and you lose type safety. Follow these principles:

  1. Be explicit at boundaries - Function parameters, component props, module exports
  2. Let inference work internally - Local variables, computed values, simple transforms
  3. Add types when they add value - Constraints, documentation, preventing errors
  4. Remove types that don’t - Redundant annotations, obvious inferences

Remember: TypeScript’s inference is there to help you write cleaner, more maintainable code. Use it wisely, and your React components will be both type-safe and readable.

Last modified on .