Tipando reducers e actions sem Redux Toolkit
1. Por que tipar manualmente reducers e actions?
Quando trabalhamos com gerenciamento de estado em TypeScript, a tipagem forte oferece benefícios significativos: autocompletar no editor, detecção de erros em tempo de compilação e documentação viva do código. O Redux Toolkit simplifica esse processo com createSlice, que gera automaticamente tipos para actions e reducers. No entanto, existem situações onde optar pela tipagem manual é vantajoso:
- Controle fino sobre tipos específicos: você define exatamente como cada action se comporta
- Projetos legados: migração gradual de JavaScript para TypeScript sem dependências adicionais
- Aprendizado profundo: entender como o TypeScript lida com discriminated unions e narrowing
- Ambientes restritos: projetos que não podem adicionar o Redux Toolkit como dependência
2. Definindo tipos para actions com union types
A base da tipagem manual de actions é criar um tipo união que represente todas as actions possíveis. Usamos type em vez de interface porque precisamos de discriminated unions:
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' };
A escolha entre type e interface é importante aqui: type permite criar uniões de objetos literais, enquanto interface não suporta essa sintaxe diretamente.
3. Adicionando payloads com generics e discriminated unions
Para actions que carregam dados, adicionamos propriedades específicas mantendo o type como discriminador:
type Action =
| { type: 'ADD_TODO'; payload: { id: string; text: string; completed: boolean } }
| { type: 'TOGGLE_TODO'; payload: { id: string } }
| { type: 'REMOVE_TODO'; payload: { id: string } }
| { type: 'CLEAR_COMPLETED' };
O TypeScript usa o campo type como discriminador, permitindo narrowing preciso dentro do reducer.
4. Tipando o reducer com TypeScript
A assinatura completa do reducer é tipada com o estado e a união de actions:
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface State {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
function todoReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, action.payload]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'REMOVE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
Para garantir exaustividade, use o tipo never no default:
function todoReducer(state: State, action: Action): State {
switch (action.type) {
// ...cases
default:
const _exhaustiveCheck: never = action;
return state;
}
}
Se uma nova action for adicionada à união Action mas não tratada no switch, o TypeScript apontará erro de compilação.
5. Criando action creators tipados
Action creators são funções que retornam o tipo exato da action. Use as const para inferência automática:
import { v4 as uuidv4 } from 'uuid';
const addTodo = (text: string) => ({
type: 'ADD_TODO' as const,
payload: {
id: uuidv4(),
text,
completed: false
}
});
const toggleTodo = (id: string) => ({
type: 'TOGGLE_TODO' as const,
payload: { id }
});
const removeTodo = (id: string) => ({
type: 'REMOVE_TODO' as const,
payload: { id }
});
const clearCompleted = () => ({
type: 'CLEAR_COMPLETED' as const
});
Com as const, o TypeScript infere o tipo literal exato para type, permitindo que o discriminador funcione corretamente.
6. Lidando com estado complexo e actions assíncronas
Para requisições assíncronas, criamos actions para cada estado da requisição:
interface User {
id: number;
name: string;
email: string;
}
interface AsyncState {
users: User[];
loading: boolean;
error: string | null;
}
type AsyncAction =
| { type: 'FETCH_USERS_START' }
| { type: 'FETCH_USERS_SUCCESS'; payload: User[] }
| { type: 'FETCH_USERS_ERROR'; payload: string }
| { type: 'ADD_USER'; payload: User }
| { type: 'REMOVE_USER'; payload: { id: number } };
function asyncReducer(state: AsyncState, action: AsyncAction): AsyncState {
switch (action.type) {
case 'FETCH_USERS_START':
return { ...state, loading: true, error: null };
case 'FETCH_USERS_SUCCESS':
return { ...state, loading: false, users: action.payload };
case 'FETCH_USERS_ERROR':
return { ...state, loading: false, error: action.payload };
case 'ADD_USER':
return { ...state, users: [...state.users, action.payload] };
case 'REMOVE_USER':
return {
...state,
users: state.users.filter(user => user.id !== action.payload.id)
};
default:
return state;
}
}
Para estado aninhado, podemos usar sub-reducers tipados:
interface AppState {
auth: { user: User | null; token: string | null };
todos: Todo[];
ui: { theme: 'light' | 'dark'; sidebar: boolean };
}
type AppAction = AuthAction | TodoAction | UIAction;
7. Boas práticas e patterns avançados
Extraindo tipos de action com Extract
type AddTodoAction = Extract<Action, { type: 'ADD_TODO' }>;
// { type: 'ADD_TODO'; payload: { id: string; text: string; completed: boolean } }
Criando um tipo genérico Reducer
type Reducer<S, A> = (state: S, action: A) => S;
const createReducer = <S, A extends { type: string }>(
initialState: S,
handlers: { [K in A['type']]?: (state: S, action: Extract<A, { type: K }>) => S }
): Reducer<S, A> => {
return (state = initialState, action: A) => {
const handler = handlers[action.type];
return handler ? handler(state, action as any) : state;
};
};
Testando a tipagem
Use o TypeScript Playground ou testes unitários para verificar a segurança de tipos:
// Teste de compilação
const testReducer = createReducer<State, Action>(initialState, {
'ADD_TODO': (state, action) => {
// action.payload tem tipo { id: string; text: string; completed: boolean }
return { ...state, todos: [...state.todos, action.payload] };
}
});
Dicas para manutenção em projetos grandes
- Separe actions por domínio: crie arquivos como
authActions.ts,todoActions.ts - Centralize tipos de estado: use um arquivo
types.tspara definir interfaces de estado e uniões de action - Use enums para tipos de action: facilita a manutenção e evita erros de digitação
- Documente actions complexas: adicione comentários JSDoc para payloads não óbvios
export enum TodoActionType {
ADD = 'ADD_TODO',
TOGGLE = 'TOGGLE_TODO',
REMOVE = 'REMOVE_TODO',
CLEAR_COMPLETED = 'CLEAR_COMPLETED'
}
type TodoAction =
| { type: TodoActionType.ADD; payload: { id: string; text: string } }
| { type: TodoActionType.TOGGLE; payload: { id: string } }
| { type: TodoActionType.REMOVE; payload: { id: string } }
| { type: TodoActionType.CLEAR_COMPLETED };
A tipagem manual de reducers e actions em TypeScript oferece controle granular e aprofundamento técnico. Embora o Redux Toolkit simplifique o processo, entender os fundamentos permite criar soluções mais flexíveis e adaptadas a necessidades específicas.
Referências
- TypeScript Handbook: Discriminated Unions — Documentação oficial sobre discriminated unions, base para tipagem de actions
- Redux Style Guide: Type Safety — Guia oficial do Redux sobre boas práticas de tipagem TypeScript
- TypeScript Deep Dive: Redux Patterns — Tutorial aprofundado sobre patterns de Redux com TypeScript
- Egghead: TypeScript with Redux — Curso prático sobre TypeScript com Redux sem dependências adicionais
- TypeScript Playground: Redux Example — Exemplo interativo no TypeScript Playground demonstrando tipagem de reducers
- Redux Documentation: Reducing Boilerplate — Discussão sobre padrões para reduzir boilerplate sem Redux Toolkit