RootState y AppDispatch: inferir los tipos del store
El store ya conoce la forma exacta del estado y las capacidades exactas de dispatch, porque ambas cosas se derivan de los reducers y del middleware que recibio configureStore. Esta leccion sostiene que escribir esos tipos a mano es duplicar una verdad que ya existe, y muestra como extraerlos con ReturnType de typeof store.getState y con typeof store.dispatch. Explica por que un RootState artesanal se desincroniza en silencio, por que AppDispatch no es una funcion cualquiera sino una firma ampliada por los middlewares, como el patron setupStore da el mismo tipo a la app y a los tests, y donde colocar los alias para que los ciclos de importacion no los degraden.
Hay un momento en toda app con Redux y TypeScript en que alguien escribe una interfaz llamada RootState y la rellena a mano con los nombres de los slices. Funciona el primer día y empieza a mentir el segundo. La tesis de este nivel entero, y la de esta lección en particular, es que esa interfaz no debería existir: el store construido con configureStore ya contiene la forma exacta del estado y la firma exacta de dispatch, porque ambas se derivan mecánicamente de los reducers y del middleware que le pasaste. Volver a escribirlas es duplicar una verdad, y toda verdad duplicada acaba convertida en dos verdades que discrepan. La alternativa cabe en dos líneas: dos alias de tipo extraídos del store con typeof, que se recalculan solos cada vez que el estado cambia de forma y que se convierten en la fuente única de la que bebe el resto del proyecto.
- Derivar
RootStateconReturnType<typeof store.getState>en lugar de declararlo a mano. - Derivar
AppDispatchcontypeof store.dispatchy entender qué le añaden los middlewares. - Reconocer las tres formas en que un
RootStateartesanal se desincroniza sin avisar. - Usar el patrón
setupStorepara compartir tipos entre la app y los tests sin ciclos de importación.
Dos verdades que el store ya conoce
Cuando llamas a configureStore con un mapa de reducers, Redux Toolkit los combina en un reducer raíz cuyo tipo de retorno es, literalmente, la forma del estado: un objeto con una clave por slice y, en cada clave, el tipo de estado que ese slice declara. Ese tipo no es una aproximación ni una convención, es el resultado del cálculo que hace el compilador al ver tus reducers. Y como store.getState devuelve exactamente eso, el tipo del estado global está a un ReturnType de distancia. La segunda verdad es más sutil: store.dispatch tampoco es una función genérica cualquiera. El dispatch base solo acepta objetos de acción planos, pero cada middleware que amplía lo despachable amplía también su firma, de modo que el dispatch de un store con thunk acepta además funciones y devuelve lo que esas funciones devuelvan. Esa firma ampliada solo existe en el store real; ningún tipo importado de redux la reproduce.
De ahí nacen las tres desincronizaciones clásicas del RootState escrito a mano. La primera es por omisión: añades un slice al store y olvidas añadirlo a la interfaz, así que un selector legítimo sobre la rama nueva no compila y alguien lo arregla con una aserción. La segunda es por deriva: cambias la forma interna de un slice y la interfaz sigue describiendo la anterior, de modo que todos los selectores compilan contra una mentira y fallan solo en tiempo de ejecución, que es el peor momento posible. La tercera es por empobrecimiento: tipas dispatch como el Dispatch de redux y pierdes las sobrecargas del thunk, así que despachar un thunk deja de compilar y el equipo aprende a escribir as any en cada llamada, abriendo un agujero que la lección quinta de este nivel estudia en detalle.
import { configureStore } from "@reduxjs/toolkit";
import todos from "../features/todos/todosSlice";
import filtros from "../features/filtros/filtrosSlice";
export const store = configureStore({
reducer: { todos, filtros },
});
// los tres alias de los que vivira todo el proyecto
export type AppStore = typeof store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RootState
La forma del estado global, calculada por el compilador a partir del reducer raíz. Cambia sola cuando añades, quitas o remodelas un slice.
AppDispatch
La firma real de dispatch en tu store, con las sobrecargas que le añadieron los middlewares. Es lo que permite despachar thunks sin aserciones.
AppStore
El tipo del store completo. Sirve para tipar utilidades de test, wrappers de render y cualquier código que reciba el store entero.
Inferir es dejar que el compilador copie por ti
El flujo de la inferencia va siempre en la misma dirección: de los reducers al store, del store a los alias, y de los alias a los selectores y los hooks. Nada apunta hacia atrás, y ese detalle es lo que hace que el esquema no pueda mentir. Si mañana renombras la clave filtros a filtrado, RootState cambia solo y cada selector que siga leyendo la clave vieja deja de compilar en el acto. Ese error no es una molestia: es exactamente el trabajo que le encargaste al sistema de tipos, señalar todos los puntos afectados por un cambio de forma antes de que alguien despliegue.
flowchart TD A[reducers que pasas a configureStore] --> B[reducer raiz combinado] B --> C[el store real] C --> D[typeof store.getState] C --> E[typeof store.dispatch] D --> F[RootState] E --> G[AppDispatch] F --> H[selectores y hooks tipados] G --> H style C fill:#fab387,color:#11111b style F fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
El primer consumidor de RootState son los selectores, y ahí el patrón se aprecia entero. Un selector se declara como una función cuyo parámetro se anota con el alias, y su retorno no se anota nunca porque se deduce del cuerpo. Esa asimetría es deliberada: la entrada es un contrato con el store y por eso se declara; la salida es una consecuencia del cálculo y por eso se infiere. Escribir el tipo de retorno a mano reintroduce, a escala pequeña, exactamente el problema que este nivel combate, porque crea una segunda afirmación que hay que mantener sincronizada con el cuerpo de la función.
import type { RootState } from "../../app/store";
// el parametro se declara; el retorno se deduce del cuerpo
export const seleccionarPendientes = (estado: RootState) =>
estado.todos.lista.filter((t) => !t.hecho);
export const seleccionarTotal = (estado: RootState) => estado.todos.lista.length;
// si el slice cambia de forma, estas dos lineas dejan de compilar a la vez
Conviene detenerse en AppDispatch porque es donde más gente se equivoca. Su tipo real no es una función simple sino una intersección: la firma que acepta acciones planas más la firma que los middlewares añadieron, entre ellas la del thunk, que acepta una función y devuelve su resultado con el tipo correcto. Gracias a eso, despachar un thunk creado con createAsyncThunk no solo compila, sino que devuelve un objeto con unwrap cuyo tipo de retorno es el payload de fulfilled. Si en su lugar declaras el tipo como Dispatch, esa información desaparece y con ella la ergonomía entera de la asincronía tipada, tema de la cuarta lección de este nivel.
Es tentador escribir algo como const store: Store<MiEstado> = configureStore(...) para dejar claro qué contiene. Es contraproducente. Una anotación explícita no comprueba el valor contra la inferencia y la conserva, sino que la sustituye: el compilador deja de ver el tipo preciso que calculó y pasa a ver el que tú declaraste, que es más pobre y que además borra las sobrecargas de dispatch que aportaron los middlewares. La regla práctica es que en la línea del store no se anota nada, se deja que el valor hable, y solo después se extraen los alias con typeof. Anotar aquí no añade seguridad; solo tapa la única fuente fiable que tenías.
El patrón setupStore y la frontera de los tipos
Un slice necesita a veces referirse a RootState, por ejemplo para escribir un selector que cruza ramas o para tipar getState dentro de un thunk. Pero RootState vive en el módulo del store, y el módulo del store importa el reducer del slice: importar en la otra dirección cierra un ciclo. La solución tiene dos capas. La primera es usar siempre import type para traer los alias, porque una importación de solo tipos se borra en la compilación y el ciclo nunca llega a existir en tiempo de ejecución. La segunda, más limpia, es calcular la forma del estado desde el reducer raíz y no desde el store, con lo que el tipo deja de depender del módulo que crea la instancia.
Ese mismo movimiento resuelve otro problema, el de los tests. Compartir una única instancia de store entre pruebas contamina unas con otras, así que lo idiomático es una fábrica que construya un store nuevo por test, opcionalmente con estado precargado. Si los alias se derivan de la fábrica, la app y las pruebas comparten exactamente los mismos tipos sin escribirlos dos veces.
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import todos from "../features/todos/todosSlice";
import filtros from "../features/filtros/filtrosSlice";
const rootReducer = combineReducers({ todos, filtros });
// la forma del estado depende del reducer, no de la instancia del store
export type RootState = ReturnType<typeof rootReducer>;
export function setupStore(preloadedState?: Partial<RootState>) {
return configureStore({ reducer: rootReducer, preloadedState });
}
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore["dispatch"];
Merece la pena señalar qué cambia y qué no entre las dos versiones del store. La forma del estado pasa a depender del reducer raíz, que es un valor puro y sin instancias, así que puede importarse desde cualquier parte sin arrastrar la creación del store. AppDispatch, en cambio, sigue dependiendo del store porque las sobrecargas de los middlewares solo existen en la instancia construida, y por eso se deriva del tipo devuelto por la fábrica. Esa separación entre lo que depende de la configuración y lo que depende de la instancia es la que permite que un mismo proyecto tenga varios stores, uno por prueba, sin duplicar un solo tipo.
El preloadedState de la fábrica se tipa como Partial<RootState> porque no hace falta precargar todas las ramas. Pero conviene recordar que ese Partial es superficial: permite omitir slices enteros, no campos dentro de un slice. Si pasas un slice a medias, el compilador lo aceptará solo si su tipo lo permite, y el store arrancará con una rama incompleta que los reducers darán por buena. En pruebas suele bastar con construir el estado del slice con su propio initialState y sobrescribir los campos que interesen, en lugar de inventar objetos parciales que ningún reducer produciría jamás.
Queda un cuarto alias que muchos proyectos exportan junto a los otros tres, útil solo si escribes thunks a mano en lugar de con createAsyncThunk. Un thunk artesanal es una función que recibe dispatch y getState, y para tiparla hace falta decir de qué estado habla y qué devuelve. El tipo utilitario de Redux lo permite, y envolverlo con los alias del proyecto evita repetir cuatro parámetros en cada thunk.
import type { ThunkAction, UnknownAction } from "@reduxjs/toolkit";
import type { RootState } from "./store";
export type AppThunk<R = void> = ThunkAction<R, RootState, unknown, UnknownAction>;
// uso: dispatch y getState llegan tipados sin anotar nada mas
export const alternarSiHayPocos = (): AppThunk => (dispatch, getState) => {
if (getState().todos.lista.length < 5) dispatch(limpiado());
};
Un detalle operativo que rompe proyectos grandes: los alias derivados viven en el módulo del store, y ese módulo crea la instancia como efecto de importarlo. Reexportarlos desde un fichero índice que además exporta valores hace que cualquier archivo que solo quería un tipo acabe arrastrando la construcción del store, con consecuencias en el orden de inicialización y en las pruebas, que de pronto comparten instancia sin quererlo. La disciplina es doble: consumir siempre estos alias con importación de solo tipos, y evitar mezclarlos en barriles que exporten valores. La quinta lección de este nivel muestra qué le ocurre a RootState cuando esa disciplina se relaja y el ciclo se cierra.
La diferencia entre escribir RootState a mano y derivarlo del store parece una preferencia de estilo y no lo es: son dos epistemologías distintas del tipado. Un tipo declarado es una afirmación sobre el programa hecha desde fuera de él, y como toda afirmación externa depende de que alguien la mantenga sincronizada con aquello que describe. En el mejor caso resulta redundante, porque repite lo que el código ya dice; en el peor se vuelve falso, y su falsedad es especialmente dañina porque el compilador la trata como verdad y calla precisamente donde debería gritar. Un tipo derivado es otra cosa: no afirma nada sobre el programa, es una proyección del programa, una vista calculada de una estructura que ya existe. No puede desincronizarse porque no tiene existencia independiente de aquello que proyecta, igual que una sombra no puede contradecir al cuerpo. Ese es el motivo profundo por el que ReturnType<typeof store.getState> es superior a cualquier interfaz escrita con cuidado: no gana por ser más corto ni por ahorrar teclas, gana porque cambia el estatus lógico de la afirmación, de premisa que hay que sostener a conclusión que se recalcula sola. Y una vez entiendes el mecanismo, lo ves gobernar todo el nivel: los hooks tipados derivan de estos alias, el payload de cada acción deriva de la anotación de su reducer, el estado de un thunk deriva de sus genéricos. En cada caso la pregunta de diseño es la misma, y es la única que importa: ¿este tipo lo estoy afirmando o lo estoy deduciendo? Un sistema de tipos lleno de afirmaciones envejece hasta convertirse en documentación desactualizada con sintaxis de compilador. Un sistema de tipos lleno de deducciones envejece con el código, porque es el código visto desde otro ángulo.
- Busca en tu proyecto cualquier interfaz que describa el estado global a mano y compárala rama por rama con
ReturnType<typeof store.getState>. Anota las divergencias antes de borrar nada. - Sustituye esa interfaz por los alias derivados y arregla los errores que aparezcan. Cada error es una desincronización que llevaba tiempo escondida.
- Añade un slice nuevo al store sin tocar ningún tipo y confirma que
RootStateya lo incluye y que el autocompletado lo ofrece. - Tipa
dispatcha propósito como elDispatchderedux, intenta despachar un thunk y lee el error. Luego vuelve atypeof store.dispatchy observa qué información recuperas. - Refactoriza tu store al patrón
setupStoreconpreloadedStatey usaAppStorepara tipar un helper de render en tus pruebas. - Cambia una importación de tipos a importación normal para provocar el ciclo, observa el fallo y restaura
import typeexplicando en una frase por qué lo arregla.