Discriminated union en TypeScript para state y API response

1. Resumen

Muchos errores de interfaz provienen de estados que nunca deberían ocurrir. Por ejemplo, isLoading es true pero error también tiene un valor. O data es undefined aunque la request reportó éxito.

Este artículo te muestra cómo usar discriminated union de TypeScript para modelar state de carga de datos y API response. Después de leerlo, sabrás cómo:

  • definir una union con un campo discriminador,
  • dejar que el compilador reduzca el tipo automáticamente (narrowing),
  • obligar a manejar todas las ramas con exhaustiveness check,
  • validar la response en tiempo de ejecución con Zod,
  • escribir tests para las transiciones de state.

2. El problema con “bolsa de campos opcionales”

La forma familiar es agrupar todo en una interface con campos opcionales:

interface OrderState {
  isLoading: boolean;
  error?: string;
  order?: { id: string; total: number };
}

const state: OrderState = {
  isLoading: true,
  error: "Timeout",
  order: { id: "A1", total: 120 },
};

El código anterior compila sin problemas. Sin embargo, describe un estado sin sentido: cargando a la vez, con error a la vez, y con datos a la vez.

Con tres campos de este tipo, tienes hasta 2 × 2 × 2 = 8 combinaciones. En realidad, solo unas 4 combinaciones son válidas. Cuando lees el state, también tienes que escribir state.order?.total o state.order! por todas partes. Cuando hay un error, el compilador no puede ayudarte.

3. ¿Qué es discriminated union?

Un discriminated union es una unión de varios tipos de objeto. Cada tipo tiene en común un campo con un literal type diferente. Este campo se llama discriminant, o “tag”:

type Order = { id: string; total: number };

type OrderState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; order: Order }
  | { status: "error"; message: string };

Aquí status es el discriminant. Cada rama solo declara los campos que tienen sentido para ella. La rama success tiene order, la rama error tiene message. El state “cargando pero con error” ya no se puede representar:

const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.

3.1. Narrowing según el discriminant

Cuando verificas el valor del discriminant, TypeScript reduce automáticamente el tipo en la rama correspondiente:

function describe(state: OrderState): string {
  if (state.status === "success") {
    return `Orden ${state.order.id}: ${state.order.total}đ`;
  }
  if (state.status === "error") {
    return `Error: ${state.message}`;
  }
  return state.status === "loading" ? "Cargando..." : "No iniciado";
}

console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Orden A1: 120đ
Error: Timeout

Dentro de la rama if (state.status === "success"), el tipo de state es { status: "success"; order: Order }. Por lo tanto, accedes a state.order.id directamente, sin necesidad de ?. o !. Si escribes state.message en esta rama, el compilador reportará un error.

4. Exhaustiveness check: no omitir estados

Una union solo es realmente útil cuando agregar una rama nueva hace que el compilador señale todos los lugares que no manejan esa rama. La forma común es usar switch combinado con el tipo never:

function assertNever(value: never): never {
  throw new Error(`Estado no manejado: ${JSON.stringify(value)}`);
}

function render(state: OrderState): string {
  switch (state.status) {
    case "idle":
      return "<button>Cargar orden</button>";
    case "loading":
      return "<spinner />";
    case "success":
      return `<p>Total: ${state.order.total}đ</p>`;
    case "error":
      return `<p class="error">${state.message}</p>`;
    default:
      return assertNever(state);
  }
}

Después de pasar por los cuatro case, el tipo de state en la rama default es never. La llamada assertNever(state) por lo tanto es válida.

Supongamos que más tarde agregas la rama { status: "cancelled" } a OrderState pero olvidas actualizar render:

error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.

El error aparece en la compilación, antes de que el usuario vea una pantalla en blanco. Si datos extraños aún se filtran en tiempo de ejecución, por ejemplo desde una API no confiable, assertNever lanzará un error claro en lugar de silenciosamente retornar undefined.

Nota: Si activas "strict": true y declaras explícitamente el tipo de retorno (: string), omitir un case también causa un error “Function lacks ending return statement”. Aun así, vale la pena usar assertNever. Da mensajes de error más claros y funciona incluso con funciones que retornan void.

5. Modelar API response

Una API generalmente retorna dos formas: éxito o fracaso. Puedes definir un tipo Result para usar en común:

type Result<T, E = ApiError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type ApiError =
  | { kind: "network"; cause: string }
  | { kind: "http"; status: number }
  | { kind: "invalid"; issues: string[] };

El discriminant no tiene que ser una cadena. ok: true y ok: false son dos literal types de boolean, así que el narrowing sigue funcionando. ApiError también es un discriminated union anidado, distinguido por kind.

5.1. Validar response en tiempo de ejecución con Zod

Los tipos de TypeScript desaparecen después de compilar. Si escribes (await res.json()) as Order, solo estás “prometiendo” al compilador, sin verificar nada en realidad. Para asegurar que los datos del servidor tienen la forma correcta, valida con Zod:

npm install zod

Supongamos que el servidor retorna un payload con un discriminant propio llamado type:

import { z } from "zod";

const OrderSchema = z.object({ id: z.string(), total: z.number() });

const OrderResponseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("found"), order: OrderSchema }),
  z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);

type OrderResponse = z.infer<typeof OrderResponseSchema>;

z.discriminatedUnion("type", [...]) crea un schema basado en el campo type. Zod lee el valor type primero y luego valida según la rama correcta, así que los mensajes de error son más precisos que z.union. z.infer genera el tipo TypeScript correspondiente, así que no tienes que escribir el tipo dos veces.

A continuación está la función que llama la API, envolviendo todos los errores en Result:

async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
  let res: Response;
  try {
    res = await fetch(`/api/orders/${id}`);
  } catch (e) {
    return { ok: false, error: { kind: "network", cause: String(e) } };
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };
  }

  const parsed = OrderResponseSchema.safeParse(await res.json());
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid", issues } };
  }
  return { ok: true, data: parsed.data };
}

Esta función nunca lanza un error hacia afuera. Todas las situaciones se expresan en el tipo de retorno. El mismo safeParse también retorna un discriminated union con discriminant success. Este es exactamente el patrón que estás aprendiendo.

El llamador de la función maneja el resultado así:

async function showOrder(id: string): Promise<string> {
  const result = await fetchOrder(id);
  if (!result.ok) {
    switch (result.error.kind) {
      case "network": return "Conexión perdida, intenta de nuevo más tarde.";
      case "http":    return `El servidor retornó ${result.error.status}.`;
      case "invalid": return "Datos inválidos.";
      default:        return assertNever(result.error);
    }
  }
  return result.data.type === "found"
    ? `Total: ${result.data.order.total}đ`
    : `No se encontró la orden ${result.data.orderId}`;
}

Después de la línea if (!result.ok) { ... }, TypeScript sabe que result es definitivamente la rama ok: true. Por lo tanto, result.data se puede acceder directamente.

6. Transicionar state con reducer

Discriminated union encaja muy bien con reducer, como useReducer de React o un store casero. Tanto state como action son unions:

type OrderAction =
  | { type: "FETCH" }
  | { type: "RESOLVE"; order: Order }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "FETCH":
      return state.status === "loading" ? state : { status: "loading" };
    case "RESOLVE":
      return state.status === "loading"
        ? { status: "success", order: action.order }
        : state;
    case "REJECT":
      return state.status === "loading"
        ? { status: "error", message: action.message }
        : state;
    case "RESET":
      return { status: "idle" };
    default:
      return assertNever(action);
  }
}

Este reducer codifica directamente las reglas de transición de estado. RESOLVE solo tiene efecto cuando está loading. Gracias a esto, una response que llega tarde después de que el usuario ya ejecutó RESET será ignorada. No necesitas banderas adicionales.

7. Escribir tests para el comportamiento principal

El artículo usa Vitest. La sintaxis expect es similar a AssertJ y Jest:

npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";

describe("orderReducer", () => {
  const order = { id: "A1", total: 120 };

  it("transiciona de loading a success cuando RESOLVE", () => {
    const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "success", order });
  });

  it("ignora RESOLVE que llega tarde cuando ya está idle", () => {
    const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "idle" });
  });

  it("render muestra el mensaje de error", () => {
    expect(render({ status: "error", message: "Timeout" }))
      .toBe('<p class="error">Timeout</p>');
  });
});
npx vitest run
 ✓ order.test.ts (3 tests)
   ✓ orderReducer > transiciona de loading a success cuando RESOLVE
   ✓ orderReducer > ignora RESOLVE que llega tarde cuando ya está idle
   ✓ orderReducer > render muestra el mensaje de error

 Test Files  1 passed (1)
      Tests  3 passed (3)

El segundo test es el más importante. Codifica el comportamiento de prevención de race condition, el tipo de error que el modelo de “bolsa de campos opcionales” fácilmente causa.

8. Errores comunes y cuándo no usarlo

El discriminant se “amplía” a string. Cuando creas un objeto en una variable intermedia, TypeScript puede inferir el tipo string en lugar de un literal:

const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.

Tienes tres formas de arreglarlo: declara el tipo desde el principio (const s: OrderState = ...), usa as const, o usa satisfies OrderState.

Destructurar temprano pierde el vínculo. Si escribes let { status, ...rest } = state, TypeScript ya no sabe a qué rama pertenece rest cuando verificas status. El narrowing a través de destructuring solo funciona cuando la variable se declara con const y no la reasignas.

Usar el operador in en lugar del discriminant. Comprobar "order" in state sigue permitiendo narrowing. Sin embargo, este enfoque se rompe fácilmente cuando dos ramas tienen el mismo nombre de campo. Siempre verifica a través del discriminant.

Discriminant opcional o que puede ser modificado. No declares status?: .... Tampoco hagas state.status = "error" en un objeto existente. En cambio, crea un objeto nuevo para cada estado. Puedes marcar el campo como readonly para que el compilador respete estas reglas.

Hacer cast de tipos en lugar de validar. Escribir as OrderResponse para datos de la red eliminará todos los beneficios anteriores. En los límites del sistema, siempre valida datos en tiempo de ejecución.

Nota: No uses discriminated union cuando los campos son realmente independientes. Por ejemplo, un formulario con email?, phone?, note? puede completarse en cualquier combinación. En ese caso, campos opcionales es el modelo correcto. Forzarlos en una union solo aumenta exponencialmente el número de ramas sin añadir más seguridad.

9. Conclusión

Discriminated union te ayuda a convertir estados inválidos en errores de compilación. Has visto cómo:

  • definir una union con un discriminant literal,
  • dejar que TypeScript haga narrowing automático según el discriminant,
  • usar assertNever para obligar a manejar todas las ramas,
  • modelar API response con Result y validar datos con z.discriminatedUnion,
  • aplicar el mismo patrón a reducer y verificar con tests.

La regla simple es: si los campos solo tienen sentido cuando van juntos, colócalos en la misma rama de la union. La próxima vez que estés a punto de escribir isLoading junto a error? y data?, intenta reemplazarlo con un campo status.

typescriptdiscriminated uniontype safetystate managementapi