Errores frecuentes: el estado que se ensancha y el any que se cuela
El tipado de Redux falla casi siempre por dos vias opuestas. Una es el ensanchamiento, cuando initialState infiere tipos demasiado amplios o demasiado estrechos y el estado deja de describir lo que de verdad guarda. La otra es la infiltracion de any, que no rompe nada visible pero desactiva la comprobacion alli por donde pasa, y que entra por el JSON de la red, por las aserciones de conveniencia y por los ciclos de importacion que degradan RootState. Esta leccion cataloga ambos fallos con sus sintomas, distingue any de unknown, y cierra con un blindaje en tres capas: opciones del compilador, validacion en la frontera de red y reglas de lint que hacen la disciplina verificable.
Un proyecto con Redux y TypeScript rara vez falla porque alguien escriba un tipo incorrecto; falla porque en algún punto deja de haber tipo. Los dos mecanismos son casi opuestos y merecen nombres distintos. El primero es el ensanchamiento: el estado se declara implícitamente a partir de un valor inicial descuidado y acaba describiendo algo más ancho o más estrecho de lo que la aplicación guarda, con lo que el compilador aprueba asignaciones absurdas o rechaza asignaciones legítimas. El segundo es la infiltración de any, que no produce ningún error y por eso mismo es peor: allí donde entra, la comprobación se apaga en silencio y se propaga hacia dentro del estado sin dejar rastro. Esta lección cataloga ambos, muestra sus síntomas característicos y termina con las tres capas de blindaje que impiden que vuelvan.
- Diagnosticar el ensanchamiento de
initialStatey elegir entre anotar la constante o usaras const. - Rastrear las vías por las que
anyentra en el estado y distinguirlo deunknown. - Reconocer el ciclo de importación que degrada
RootStatea un tipo implícito. - Blindar el proyecto con opciones del compilador, validación en la frontera y reglas de lint.
El ensanchamiento: cuando el estado inicial miente
TypeScript infiere del valor inicial, y un valor inicial es casi siempre un caso particular pobre del conjunto de valores que el campo albergará. De ahí los tres síntomas clásicos. Un array vacío se infiere como array de nada, así que la primera inserción no compila. Un null se infiere como el tipo del propio null, así que asignar el objeto real tampoco compila. Y una cadena literal como el estado inactivo se ensancha a cadena genérica, con lo que la unión de estados que creías tener desaparece y el compilador acepta alegremente cualquier texto, incluida una errata. Los dos primeros molestan enseguida; el tercero es el peligroso, porque no da error nunca y solo se manifiesta como una condición que jamás se cumple.
La corrección canónica no es esparcir aserciones sobre los campos problemáticos sino declarar una interfaz para el estado y anotar con ella la constante inicial. Hacerlo así resuelve los tres casos a la vez y convierte esa interfaz en el documento de referencia del slice. Existe una alternativa que conviene entender para no usarla mal: comprobar el valor contra el tipo conservando los literales. Esa variante es excelente para constantes de configuración inmutables, pero contraproducente para un estado inicial, porque conservar el literal significa que el campo del estado queda fijado a su valor de partida y cualquier transición posterior deja de compilar.
interface Estado {
items: Item[];
seleccionado: Item | null;
fase: "inactivo" | "cargando" | "listo";
}
// MAL: items es array de nada, seleccionado es null, fase es una cadena cualquiera
const malo = { items: [], seleccionado: null, fase: "inactivo" };
// BIEN: la anotacion fija la forma y conserva la union de fase
const inicial: Estado = { items: [], seleccionado: null, fase: "inactivo" };
// la variante que conserva literales: util para configuracion, nefasta para estado
const config = { fase: "inactivo", reintentos: 3 } satisfies Partial<Estado>;
// config.fase queda fijada al literal inactivo, asi que esto ya no compila
// config.fase = "cargando";
El contraste entre las dos formas ilustra una regla que va más allá de Redux: conservar literales sirve cuando el valor no va a cambiar nunca, y estorba cuando el valor es el punto de partida de una serie de transiciones. Un estado inicial pertenece siempre al segundo caso, porque su razón de ser es dejar de ser el inicial. Por eso la anotación con la interfaz es la herramienta correcta aquí, y la comprobación que preserva literales pertenece a los ficheros de configuración, a las tablas de rutas y a las constantes que se leen pero no se escriben.
Escribir el array vacío con una aserción de tipo funciona y por eso se propaga como remedio. El problema es que una aserción no comprueba nada: afirma. Si mañana el elemento del array cambia de forma, la aserción seguirá compilando mientras el contenido real del estado ya no corresponda, y el error aparecerá lejos del punto donde se introdujo, típicamente en un selector o en un componente. Anotar la constante inicial, en cambio, es una comprobación: si el valor no encaja con la interfaz, falla exactamente ahí. La regla práctica del nivel es preferir siempre la anotación que verifica sobre la aserción que impone, y reservar las aserciones para las fronteras donde de verdad sabes algo que el compilador no puede saber.
Las vías por las que se cuela any
any tiene una propiedad que lo hace distinto de cualquier otro tipo: es contagioso y silencioso. Un valor de tipo any puede asignarse a cualquier destino sin queja, y todo lo que se derive de él lo hereda, de modo que basta con un punto de entrada para que una rama entera del estado deje de estar comprobada. Sus puertas habituales son cuatro. La primera y más frecuente es la red: el método que convierte una respuesta HTTP en objeto devuelve any, y ese valor viaja como payload de un thunk hasta instalarse en el estado. La segunda son las aserciones de conveniencia, que suelen aparecer justamente donde el tipado fallaba por las razones de las lecciones anteriores. La tercera son las dependencias con declaraciones pobres o inexistentes. Y la cuarta, más traicionera, es el ciclo de importación: cuando un slice importa RootState del store y el store importa el reducer del slice sin usar importación de solo tipos, el compilador puede no resolver el tipo y degradarlo a implícito, con lo que todos tus selectores dejan de comprobar nada mientras siguen compilando.
flowchart TD A[respuesta de red convertida a objeto] -->|el metodo devuelve any| B[payload sin validar] C[asercion de conveniencia] --> B D[ciclo de importacion con RootState] --> E[tipo implicito] B --> F[estado contaminado] E --> F F --> G[selectores que compilan y mienten] H[validacion en la frontera] --> I[payload tipado de verdad] I --> J[estado fiable] style F fill:#f38ba8,color:#11111b style J fill:#a6e3a1,color:#11111b
La distinción con unknown es la clave conceptual y merece enunciarse con precisión. any significa que el compilador renuncia a saber y te deja hacer cualquier cosa; unknown significa que el compilador no sabe y por eso no te deja hacer nada hasta que lo estreches. Ambos representan la misma ignorancia, pero uno la resuelve a tu favor y el otro a favor de la comprobación. Por eso la conversión correcta de un valor externo es a unknown seguida de una validación, y por eso los hooks modernos de react-redux prefieren dejar el estado como desconocido antes que como cualquiera.
// sonda para detectar any: solo any satisface esta condicion
type EsAny<T> = 0 extends 1 & T ? true : false;
type Sospecha = EsAny<ReturnType<typeof JSON.parse>>; // true, hay un agujero
// la conversion honesta: unknown y despues validacion
async function traerUsuario(id: string): Promise<Usuario> {
const res = await fetch(`/api/usuarios/${id}`);
const crudo: unknown = await res.json();
return esquemaUsuario.parse(crudo); // aqui nace el tipo real
}
El caso del ciclo merece verse escrito porque su síntoma no se parece a nada. El slice importa el alias del store para tipar un selector, y el store importa el reducer del slice para construirse. Si esa importación de tipos no está marcada como tal, el compilador entra en una definición circular donde el tipo del estado depende del reducer que depende del tipo del estado, y el resultado no es un error rotundo sino una degradación: el alias se resuelve a un tipo implícito y todos los selectores que lo usan dejan de comprobar nada. El síntoma característico es que el autocompletado enmudece en un fichero concreto mientras el proyecto entero sigue compilando.
// features/todos/todosSlice.ts
import { RootState } from "../../app/store"; // importacion de valor: cierra el ciclo
export const seleccionarTotal = (estado: RootState) => estado.todos.lista.length;
// arreglo minimo: que la importacion se borre al compilar
import type { RootState } from "../../app/store";
Blindaje en tres capas
La primera capa es el compilador. El modo estricto es el punto de partida y no es negociable, pero hay dos opciones adicionales que cambian mucho el tipado de un estado Redux. Una obliga a tratar como posiblemente ausente todo acceso por índice, lo que afecta directamente al estado normalizado donde las entidades se leen por identificador y la ausencia es un caso real que la gente olvida. La otra distingue una propiedad ausente de una propiedad presente con valor indefinido, distinción que importa cuando se precarga estado parcial en pruebas. La segunda capa es la frontera: todo dato que llega de la red se valida con un esquema en el punto de entrada, de forma que el tipo del payload no sea una afirmación optimista sino el resultado de una comprobación real. La tercera capa es el lint, que convierte la disciplina en algo verificable.
// las tres opciones que mas cambian el tipado de un estado Redux
{
"strict": true,
"noUncheckedIndexedAccess": true, // entities por id devuelve posible ausencia
"exactOptionalPropertyTypes": true, // ausente no es lo mismo que indefinido
"verbatimModuleSyntax": true // obliga a marcar las importaciones de tipos
}
La segunda de esas opciones merece un comentario propio porque es la que más código rompe al activarla y también la que más errores reales destapa. En un estado normalizado, leer una entidad por su identificador puede no devolver nada, y sin esa opción el compilador finge que siempre devuelve algo: cada selector que lee un campo de la entidad recuperada está asumiendo una existencia que nadie garantizó. Activarla convierte esa suposición en un error de compilación en cada punto de lectura, y el trabajo de arreglarlos no es ruido, es escribir por fin la ramificación que la aplicación siempre necesitó para el caso de la entidad borrada o todavía no cargada.
Compilador
Modo estricto, accesos por índice como posiblemente ausentes y propiedades opcionales exactas. Reglas globales, coste cero de mantenimiento.
Frontera
Un esquema valida cada respuesta antes de que entre al estado. El tipo deja de ser una promesa y pasa a ser una conclusión.
Lint
Prohibir any explícito, prohibir los hooks crudos e imponer las importaciones de solo tipos que evitan los ciclos.
Muchos de los ciclos que degradan RootState se disuelven al marcar como importación de solo tipos todo lo que solo se usa en posición de tipo, porque esas importaciones se borran al compilar y el ciclo nunca existe en ejecución. Conviene imponerlo con una regla de lint que lo aplique automáticamente en todo el proyecto, y activar la opción del compilador que obliga a ser explícito al respecto. Aun así, es higiene y no cura: si un módulo importa valores en las dos direcciones, el ciclo es real y el arreglo es arquitectónico, normalmente extraer la forma del estado al módulo del reducer raíz como se vio en la primera lección de este nivel.
A veces la fuente de any es una dependencia sin tipos que no vas a reescribir. La respuesta no es rendirse ni salpicar aserciones por los puntos de uso, sino construir un módulo delgado que envuelva esa dependencia, exponga una superficie tipada y concentre en su interior todas las conversiones necesarias. Así el agujero existe, pero tiene fronteras conocidas y un único fichero donde auditar. Es la misma estrategia del envoltorio de hooks aplicada a otro problema: cuando una decisión o un riesgo no pueden eliminarse, al menos pueden localizarse en un punto, y un riesgo localizado es un riesgo que el equipo puede razonar.
La pregunta que ordena todo este nivel es de dónde procede la autoridad de un tipo. Dentro del programa, la respuesta es sencilla: procede del compilador, que ha comprobado cada paso desde la definición hasta el uso, y por eso los tipos derivados del store son fiables sin más ceremonia. Pero en el borde del programa esa cadena se corta, porque un valor que llega por la red no ha sido comprobado por nadie: es una secuencia de bytes que un servidor prometió con cierta forma, y la promesa puede ser vieja, parcial o falsa. Cuando escribes una aserción sobre esa respuesta no estás tipando, estás firmando un aval en nombre del compilador sobre algo que él no vio, y a partir de ahí todo lo que el sistema de tipos diga corriente abajo es una deducción impecable a partir de una premisa sin justificar. Ese es el sentido profundo de validar en la frontera y de preferir el desconocimiento explícito a la ignorancia complaciente: no se trata de añadir una comprobación más, sino de que la afirmación de tipo tenga detrás una verificación real, de modo que el edificio entero descanse sobre algo comprobado en lugar de sobre una costumbre. Visto así, el ensanchamiento y la infiltración de any son la misma enfermedad en dos estadios. En el primero el tipo dice algo verdadero pero demasiado laxo, y admite mundos que tu aplicación no habita. En el segundo el tipo ha dejado de decir nada, y por eso ya no puede ser refutado por ningún valor. Un sistema de tipos vale exactamente lo que valen sus afirmaciones más débiles, porque basta un eslabón sin comprobar para que las conclusiones que dependen de él sean tan sólidas como su premisa. Blindar el tipado no consiste en escribir más tipos: consiste en asegurarse de que cada tipo que escribes sigue siendo una afirmación que algo, en alguna parte, se molestó en verificar.
- Busca todos los
initialStatedel proyecto y comprueba cuáles infieren tipos ensanchados. Corrige anotando la constante con una interfaz explícita. - Fija un campo de fase con la forma que conserva literales y observa qué transición deja de compilar. Explica en una frase por qué esa forma no sirve para un estado.
- Añade la sonda que detecta
anyy aplícala al retorno de tus funciones de red. Anota cuántos agujeros encuentras. - Sustituye una conversión directa de respuesta HTTP por conversión a desconocido más validación con esquema, y confirma que el payload del thunk queda tipado de verdad.
- Activa la opción que trata los accesos por índice como posiblemente ausentes y arregla los selectores de tu estado normalizado que asumían que la entidad existía.
- Provoca a propósito el ciclo de importación entre un slice y el store, comprueba en qué se degrada
RootStatey arréglalo con importación de solo tipos o extrayendo el reducer raíz. - Añade reglas de lint contra el
anyexplícito y a favor de las importaciones de solo tipos, y ejecútalas sobre toda la base de código.