wandres.dev
CONTEXT · createContext, useContext

Tipar el contexto: valor, default nullable y narrowing

Tipar bien un contexto en TypeScript es codificar su contrato: el tipo del valor —a menudo accesores o un store, no datos planos—, el default nullable que las sobrecargas de createContext imponen cuando no hay valor por defecto, y el estrechamiento en el hook useX que, al lanzar ante undefined, devuelve el tipo no nulo que los consumidores usan sin comprobar.

⏱ 16 min

En Solid, tipar un contexto no es decoración: es donde el sistema de tipos y la reactividad se encuentran. El tipo del valor tiene que describir algo reactivo —accesores, un store—, no una foto inmóvil; la firma de createContext codifica en el tipo si el Provider es opcional; y el hook useX es el punto exacto donde un undefined posible se convierte, por vía de un throw, en un valor garantizado. Bien hecho, el tipo cuenta la misma historia que el runtime: hay un canal, puede faltar el proveedor, y aquí lo exigimos.

🎯 Al terminar esta lección sabrás
  • Definir el tipo del valor de contexto, contemplando accesores y stores reactivos.
  • Entender las dos sobrecargas de createContext y por qué sin default el tipo es nullable.
  • Estrechar el tipo en el hook useX con un throw que elimina el undefined.
  • Rechazar los defaults falsos que mienten al compilador y ocultan el proveedor ausente.

El tipo del valor: describe algo reactivo

El primer error de tipado es describir el contexto como si guardara datos planos. Un contexto de tema no lleva un string; lleva una forma reactiva: un accesor al tema y una acción para cambiarlo. El tipo debe reflejarlo.

import { type Accessor } from "solid-js";

type Tema = "claro" | "oscuro";

interface TemaContextValor {
  tema: Accessor<Tema>; // un getter reactivo, no un Tema suelto
  alternar: () => void; // una accion que muta la fuente
}

Que el campo sea Accessor<Tema> —una función () => Tema— y no Tema es toda la diferencia: comunica que el consumidor debe llamarlo para leer y suscribirse, y hace imposible pasar por error un valor congelado. Cuando el estado es más rico, el tipo suele ser una tupla [estado, acciones] con un store, que tiparemos al final. La regla: el tipo del contexto es el tipo de su interfaz reactiva, no el de sus datos.

Entre una interfaz de campos nombrados y una tupla [estado, acciones] la elección es de estilo, con un matiz práctico: la tupla permite renombrar al desestructurar —const [carrito, acciones] = useCarrito()—, cómodo cuando varios contextos exponen un estado; la interfaz gana en legibilidad cuando hay muchos campos y el orden posicional cansa. Ambas tipan con el mismo rigor; lo que no vale es un valor plano sin envoltura reactiva.

Las dos sobrecargas de createContext

createContext tiene dos firmas, y elegir entre ellas es una decisión de diseño con consecuencias en el tipo.

// Sin valor por defecto: el tipo del contexto incluye undefined
const TemaContext = createContext<TemaContextValor>();
//    tipo: Context<TemaContextValor | undefined>

// Con valor por defecto: el tipo NO incluye undefined
const TemaContext2 = createContext<TemaContextValor>(valorPorDefecto);
//    tipo: Context<TemaContextValor>

Sin argumento, la firma es createContext<T>(): Context<T | undefined>: el compilador añade undefined al tipo porque, sin default, un consumidor sin Provider recibirá justo eso. Con argumento, createContext<T>(def: T): Context<T>: al haber un fondo garantizado, el tipo es limpio. El tipo, por sí solo, te dice si el Provider es obligatorio: si ves | undefined, alguien tendrá que lidiar con su ausencia.

⚠️
No falsifiques el default para callar al compilador

La tentación ante el | undefined es rellenar createContext({} as TemaContextValor) o un objeto de mentira, para que el tipo quede sin undefined y los consumidores no protesten. Es un fraude al sistema de tipos: prometes una forma que en tiempo de ejecución no existe, y el crash por proveedor ausente vuelve, ahora sin el aviso del compilador que lo habría delatado. Si el contexto es obligatorio, deja el tipo nullable y resuélvelo en el hook. El tipo debe decir la verdad.

El narrowing en el hook: de nullable a garantizado

El tipo nullable es honesto pero incómodo: nadie quiere escribir sesion?.usuario en cien sitios. El patrón useX resuelve el runtime y el tipo de una vez. Al lanzar cuando el valor es undefined, TypeScript estrecha el tipo de retorno: después del throw, el control solo continúa si el valor existe, así que el compilador elimina undefined del tipo devuelto.

const SesionContext = createContext<SesionValor>(); // SesionValor | undefined

export function useSesion(): SesionValor {
  const ctx = useContext(SesionContext); // SesionValor | undefined
  if (!ctx) {
    throw new Error("useSesion requiere un SesionProvider por encima");
  }
  return ctx; // aqui el tipo ya es SesionValor, sin undefined
}

El if (!ctx) throw es un control flow narrowing clásico: al no retornar en esa rama, TypeScript deduce que en la línea siguiente ctx no puede ser undefined. Anotar el retorno como SesionValor documenta y verifica el contrato. Los consumidores reciben un tipo limpio y acceden a sus campos sin encadenar interrogaciones.

flowchart LR
A[createContext sin default] --> B[tipo Valor o undefined]
B --> C[useContext dentro del hook]
C --> D[comprueba si es undefined]
D -->|es undefined| E[throw error nombrado]
D -->|tiene valor| F[return tipo Valor garantizado]
style A fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b

Existe la variante con funciones de aserción —una firma del tipo asserts ctx is SesionValor— pero para el caso del contexto el hook que estrecha por throw es más simple y directo: no necesitas un predicado aparte cuando el propio hook es el cuello de botella por el que todos pasan.

Tipar el valor reactivo: accesores y stores

El caso más frecuente en producción mete un store en el contexto y expone la tupla [estado, acciones]. Tiparla con precisión aprovecha los tipos que Solid exporta.

import { type Store } from "solid-js/store";

interface Linea { id: string; precio: number; cantidad: number }
interface Carrito { lineas: Linea[] }

// El estado es de solo lectura para el consumidor; las acciones lo mutan
type CarritoValor = [
  estado: Store<Carrito>,
  acciones: {
    agregar: (l: Linea) => void;
    vaciar: () => void;
  },
];

const CarritoContext = createContext<CarritoValor>();

Exponer Store<Carrito> —no SetStoreFunction— a los consumidores comunica que el estado es de solo lectura para ellos: mutar es privilegio de las acciones, que encapsulan el setStore. Es la misma disciplina de un reducer: el estado se lee, las acciones lo cambian, y el tipo lo hace cumplir. Cuando construyas el Provider que puebla este contexto —la próxima lección— el valor que pases satisfará este tipo, y un satisfies CarritoValor sobre el objeto te avisará si la forma se desvía. Si empaquetas el store con el patrón factory del nivel anterior, puedes incluso derivar el tipo con ReturnType en vez de escribirlo a mano.

📝
Store ya es de solo lectura en el tipo

El tipo Store<T> que Solid exporta marca la estructura como de solo lectura en profundidad: intentar estado.lineas.push(linea) no compila. No necesitas envolverlo en Readonly a mano; el tipo del proxy ya niega la escritura directa y encauza toda mutación por setStore. Exponerlo tal cual a los consumidores es, por tanto, seguro por construcción: el tipo garantiza que la única puerta a la mutación son las acciones.

🧬

Interfaz reactiva

Tipa Accessor o Store, no datos planos: el tipo empuja a leer llamando y prohíbe congelar el valor.

✳️

Dos sobrecargas

Sin default el tipo es T | undefined; con default es T. El tipo revela si el Provider es obligatorio.

🚫

Default falso

Un {} as T miente al compilador y devuelve el crash por proveedor ausente, ahora sin aviso.

⤵️

Narrowing

El throw del hook elimina undefined del tipo de retorno: los consumidores no comprueban nada.

El tipo del contexto es el contrato del canal, y el hook es donde se firma

Tipar un contexto con rigor es codificar tres verdades que el runtime ya cumple, para que el compilador las custodie. La primera: el valor es una interfaz reactiva, no un dato. Si tipas un Accessor o un Store en vez de un valor plano, el sistema de tipos te obliga a leer llamando —a suscribirte— y te prohíbe pasar una foto muerta; el tipo empuja hacia la reactividad correcta. La segunda: la opcionalidad del Provider vive en el tipo. Las sobrecargas de createContext no son un detalle: sin default, el | undefined es el compilador diciéndote la pura verdad —puede que nadie haya provisto esto—, y falsificar un default para silenciarlo es cambiar un error en compilación por un crash en producción, el peor trato posible. La tercera, y la que cierra el círculo: el hook useX es el único punto donde ese undefined se convierte en garantía, y lo hace con la herramienta más honesta que existe, un throw que estrecha el tipo por flujo de control. Después de esa línea, el valor no puede ser nulo, ni para TypeScript ni para el runtime, porque la única forma de pasar de largo era existiendo. Fíjate en la elegancia: no hay casts, no hay operadores de aserción no nula, no hay afirmaciones que mientan; hay una comprobación real cuyo efecto de tipo coincide exactamente con su efecto de ejecución. Cuando el tipo y el runtime cuentan la misma historia —hay un canal, puede faltar, aquí lo exigimos— has tipado el contexto como un profesional, y los consumidores heredan esa certeza sin escribir una sola comprobación.

⚔️ Haz que el tipo diga la verdad
  1. Declara TemaContextValor con un Accessor<Tema> y una acción; crea el contexto sin default y observa el | undefined en el tipo inferido.
  2. Escribe useTema con retorno anotado y el throw; comprueba en el editor que tras la guarda el tipo ya no incluye undefined.
  3. Intenta el default falso createContext({} as TemaContextValor) y razona qué garantía pierdes y qué crash reaparece sin aviso.
  4. Tipa un CarritoValor como tupla [Store<Carrito>, acciones] y verifica que el consumidor no puede mutar el estado directamente, solo vía acciones.
  5. Añade satisfies al objeto de valor que planeas proveer y provoca una desviación de forma para ver al compilador atraparla.