Когда говорят “TypeScript понимает action внутри switch”, это часто называют type guard. Технически точнее говорить о discriminated union: у каждого action есть общее поле type, и именно по нему TypeScript сужает тип внутри конкретной ветки.

Это особенно хорошо видно в reducer. Начнем с минимальных типов:

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export type FetchTodosAction = {
  type: "todos/fetched";
  payload: Todo[];
};

export type DeleteTodoAction = {
  type: "todos/deleted";
  payload: number;
};

export type Action = FetchTodosAction | DeleteTodoAction;

Теперь сам reducer:

export function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  switch (action.type) {
    case "todos/fetched":
      return action.payload;
    case "todos/deleted":
      return state.filter((todo) => todo.id !== action.payload);
    default:
      return state;
  }
}

Здесь и происходит главное. В ветке case "todos/fetched" TypeScript знает, что action имеет тип FetchTodosAction. В ветке case "todos/deleted" тот же action уже сужается до DeleteTodoAction.

Это значит, что payload тоже получает правильный тип без ручных проверок:

export function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  switch (action.type) {
    case "todos/fetched":
      action.payload; // Todo[]
      return action.payload;
    case "todos/deleted":
      action.payload; // number
      return state.filter((todo) => todo.id !== action.payload);
    default:
      return state;
  }
}

Почему это работает

TypeScript умеет сужать объединение, если у каждой ветки есть общий дискриминатор. В Redux этим дискриминатором почти всегда выступает строковое поле type.

Поэтому важна не сама библиотека Redux, а форма action:

  • у каждого action должен быть стабильный type;
  • у каждого type должен быть свой корректный payload;
  • reducer должен ветвиться по action.type, а не пытаться угадывать форму объекта косвенно.

Если это соблюдено, TypeScript делает остальную работу сам.

Где здесь настоящий type guard

Пользовательский type guard нужен, когда одного switch недостаточно и ты хочешь вынести проверку в отдельную функцию:

function isDeleteTodoAction(action: Action): action is DeleteTodoAction {
  return action.type === "todos/deleted";
}

export function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  if (isDeleteTodoAction(action)) {
    return state.filter((todo) => todo.id !== action.payload);
  }

  if (action.type === "todos/fetched") {
    return action.payload;
  }

  return state;
}

Но в обычном reducer switch (action.type) обычно проще и читабельнее.