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.
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 estrechaconst [malo] = createStore({ tags: [] }); // tags: never[] -> no admite strings// Solución: genérico explícito, o anotar la semillaconst [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 contextofunction 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 extremoconst EstadoCtx = createContext<[Store<Estado>, SetStoreFunction<Estado>]>();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.
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 parfunction 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 opcionalsetPerfil("redes", "gh", "ada"); // clave parcial conocidaCuando 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 tokenif (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.