Tipar stores en TypeScript: forma, setter y parcialidad
Declaras la forma del estado como una interfaz y la pasas como genérico a createStore para evitar la inferencia demasiado estrecha. El proxy de lectura es Store de T y el setter es SetStoreFunction de T, un tipo sobrecargado que verifica cada ruta contra la forma. Los campos opcionales y los stores parciales exigen estrechar antes de leer hondo e inicializar ramas antes de escribir en ellas.
Un store sin tipos es una bomba de relojería: las rutas de setEstado son cadenas, y una clave mal escrita o un valor del tipo equivocado no fallan hasta que revientan en runtime. TypeScript convierte esas rutas en un contrato verificado. Declaras la forma del estado una vez, y a partir de ahí cada lectura conoce su tipo, cada escritura por ruta se comprueba contra la estructura, y los campos que pueden no existir te obligan a tratarlos con el cuidado que merecen. Tipar un store no es ceremonia: es cerrar la única grieta por la que el grano fino podría colarte un error silencioso.
- Declarar la forma del estado como interfaz y pasarla como genérico a
createStore. - Evitar la inferencia demasiado estrecha en arrays y objetos vacíos.
- Usar
SetStoreFunctionyStorepara tipar el setter y el proxy al pasarlos por props o contexto. - Modelar campos opcionales y stores parciales estrechando antes de leer e inicializando antes de escribir.
La forma del estado como tipo
El punto de partida es declarar la estructura como una interfaz y entregársela a createStore como parámetro genérico. Así el proxy que recibes está tipado hoja por hoja, y cualquier ruta que escribas se contrasta con esa forma.
import { createStore } from "solid-js/store";
interface Estado {
usuario: { nombre: string; edad: number };
tags: string[];
sesion: Sesion | null;
}
const [estado, setEstado] = createStore<Estado>({
usuario: { nombre: "Ada", edad: 36 },
tags: [],
sesion: null,
});
Si omites el genérico, TypeScript infiere el tipo del objeto inicial, y ahí acechan dos trampas. Un array vacío se infiere como never[], que no admite ningún elemento; un null inicial se infiere como null a secas, sin la parte del tipo que llegará después. Anota la forma explícitamente o el store nacerá con tipos que rechazan justo los valores que piensas ponerle.
// Sin anotar, la inferencia es demasiado estrecha
const [malo] = createStore({ tags: [] }); // tags: never[] -> no admite strings
// Solución: genérico explícito, o anotar la semilla
const [bien] = createStore<{ tags: string[] }>({ tags: [] });
SetStoreFunction: el tipo del setter por ruta
El setter tiene su propio tipo, SetStoreFunction<Estado>, y es un tipo sobrecargado: cada longitud de ruta es una firma distinta que encadena las claves válidas nivel a nivel y termina exigiendo un valor del tipo de esa hoja. Escribir setEstado("usuario", "edad", "treinta") no compila, porque edad es number; escribir setEstado("usuario", "correo", "x") tampoco, porque correo no existe en la forma.
import { type SetStoreFunction, type Store } from "solid-js/store";
// El tipo del setter, útil para pasarlo por props o contexto
function editarNombre(set: SetStoreFunction<Estado>, v: string) {
set("usuario", "nombre", v); // ruta verificada contra la forma
}
// El proxy de lectura tiene su propio tipo: Store<Estado>
function leerEdad(estado: Store<Estado>) {
return estado.usuario.edad; // number
}
Estos dos tipos son los que te permiten transportar el store con seguridad. Un createContext que lleve el par completo se declara con ambos, y así cualquier consumidor recibe lectura y escritura plenamente tipadas, sin perder el contrato al cruzar la frontera del contexto.
import { createContext } from "solid-js";
// El contexto transporta el par tipado de extremo a extremo
const EstadoCtx = createContext<[Store<Estado>, SetStoreFunction<Estado>]>();
flowchart LR I[interface Estado] --> S[Store de Estado proxy de solo lectura] I --> W[SetStoreFunction de Estado] S --> R[lecturas tipadas hoja por hoja] W --> P[escrituras por ruta verificadas] style I fill:#89b4fa,color:#11111b style S fill:#a6e3a1,color:#11111b style W fill:#a6e3a1,color:#11111b
Los actualizadores funcionales también viajan tipados: en setEstado("usuario", "edad", (n) => n + 1), el parámetro n se infiere como number porque el tipo del setter sabe qué hoja estás tocando. Lo mismo ocurre dentro de produce: el borrador que recibes es un Estado mutable, y editar s.usuario.edad respeta su tipo, con el mismo error de compilación si le asignas algo incompatible.
Store de T
El tipo del proxy de lectura. Preserva la forma de T hoja por hoja: leer una ruta te da su tipo real, nunca un any difuso.
SetStoreFunction de T
El tipo del setter. Sobrecargado por longitud de ruta: encadena las claves válidas nivel a nivel y exige en la hoja el tipo correcto.
Cuando encapsulas la creación del store en una factoría, ReturnType te ahorra volver a escribir la firma del par: derivas su tipo del propio constructor y lo reutilizas allí donde transportes el store. Es el patrón que evita que la forma del estado se duplique en varios sitios y se desincronice con el tiempo.
// Una factoría tipada encapsula la creación y da un nombre al par
function crearEstado() {
return createStore<Estado>({
usuario: { nombre: "Ada", edad: 36 },
tags: [],
sesion: null,
});
}
type EstadoStore = ReturnType<typeof crearEstado>;
// EstadoStore es [Store<Estado>, SetStoreFunction<Estado>]
Stores parciales y campos opcionales
El estado real no siempre está completo desde el principio: hay campos que pueden no existir todavía, ramas que se llenan más tarde, uniones con null. Modelarlos bien tiene dos consecuencias de tipo que hay que respetar. La primera: para leer hondo en una hoja que puede faltar, TypeScript te obliga a estrechar antes. La segunda: para escribir en una rama opcional, esa rama debe existir o el tipo de la ruta lo impedirá.
interface Perfil {
nombre: string;
bio?: string; // puede no existir
redes: Partial<Record<"x" | "gh", string>>; // claves conocidas, todas opcionales
}
const [perfil, setPerfil] = createStore<Perfil>({
nombre: "Ada",
redes: {},
});
setPerfil("bio", "investigadora"); // asigna la hoja opcional
setPerfil("redes", "gh", "ada"); // clave parcial conocida
Cuando una hoja es una unión como Sesion | null, leer un campo hijo sin estrechar es un error de tipo, y es un error sano: te está recordando que la sesión podría no existir. La forma idiomática es un guard que estreche la unión antes de leer hondo.
// sesion: Sesion | null -> hay que estrechar antes de leer su token
if (estado.sesion) {
estado.sesion.token; // aquí TS lo estrecha a Sesion
}
Para un store que se rellena por fases, Partial<T> describe con honestidad que cualquier campo de primer nivel puede faltar al principio. El precio es que cada lectura arrastra el undefined en su tipo, lo cual es correcto: el sistema te obliga a comprobar la existencia justo donde el dato podría no estar aún.
La inferencia desde el objeto inicial es cómoda para un store de juguete, pero para estado real casi siempre quieres declarar una interfaz. Te da un nombre para la forma que puedes reutilizar al tipar el setter, el contexto y las funciones que reciben el store; evita las sorpresas de never[] y de las uniones aplastadas; y documenta la estructura en un solo lugar en vez de dejarla implícita en la semilla. La interfaz es la fuente de verdad de la forma; el objeto inicial es solo un valor que la cumple.
La grieta que TypeScript cierra en un store es exactamente la que el grano fino abre: al escribir por rutas de cadenas, ganas granularidad y ergonomía, pero pierdes la comprobación estática que tendrías con asignaciones directas. Una ruta mal escrita es una cadena válida en JavaScript y un desastre en runtime. El tipado del store devuelve esa comprobación sin sacrificar la granularidad, y lo hace con una fidelidad que conviene apreciar: Store<T> preserva la forma exacta de T a través del proxy de lectura, de modo que leer una hoja te da su tipo real y no un any difuso; SetStoreFunction<T> codifica en un tipo sobrecargado todas las rutas legales, encadenando clave a clave los niveles de la estructura y exigiendo en la hoja final el tipo correcto, de manera que una ruta imposible o un valor del tipo equivocado no compilan. Esto transforma la forma del estado de una convención que vive en tu cabeza a un contrato que el compilador verifica en cada escritura. Y el contrato es honesto con la incertidumbre: los campos opcionales y las uniones con null no se esconden, se propagan al tipo de cada lectura, obligándote a estrechar antes de leer hondo e a inicializar antes de escribir en una rama que aún no existe. Lejos de estorbar, esa fricción es la que atrapa en tiempo de compilación la clase de error —acceder a la sesión que todavía es nula, escribir en la rama que nadie creó— que de otro modo esperaría agazapada hasta producción. Tipar un store no es decorarlo: es hacer que la forma que ya tenías en la cabeza sea la misma que el compilador defiende en cada línea.
- Declara una interfaz para un estado con al menos una rama anidada y un array, y crea el store con el genérico explícito.
- Provoca el error de inferencia con
tags: []sin anotar y arréglalo de dos formas distintas. - Escribe una función que reciba
SetStoreFunction<T>, edite por ruta y pásala por props a un hijo; comprueba que una ruta inválida no compila. - Modela un campo opcional y observa el error de tipo al leer una hoja hija sin estrechar antes con un guard.
- Explica por qué el tipo del setter tiene tantas sobrecargas y qué representa cada nivel de la ruta.