Saltar al contenido

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.

TSX
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.

TSX
// 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.

TSX
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.

TSX
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.

TSX
// 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á.

TSX
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 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.

TSX
// 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.