Saltar al contenido

El operador satisfies: tipos seguros sin perder inferencia

El operador satisfies valida que un valor cumpla un tipo sin perder la inferencia de literales: por qué esa distinción importa y dónde aplicarla.

5 min de lectura
Código TypeScript que muestra el operador satisfies preservando tipos literales en un objeto de configuración

El operador satisfies llegó en TypeScript 4.9 y sigue siendo una de las funciones menos aprovechadas del lenguaje. La mayoría de las bases de código que podrían beneficiarse de él todavía recurren a anotaciones de tipo —que ensanchan los tipos— o a aserciones as —que le mienten al compilador—. satisfies resuelve el dilema: valida que un valor cumpla con un tipo sin cambiar lo que el compilador sabe sobre la forma específica de ese valor.

Entender la diferencia entre validación y ensanchamiento desbloquea una familia de patrones que hacen que los objetos de configuración, las tablas de búsqueda y los registros sean genuinamente más seguros y expresivos.

El problema del ensanchamiento con las anotaciones de tipo

Cuando anotas una variable, TypeScript usa esa anotación como fuente de verdad. Cualquier información más específica que la anotación se descarta —de forma permanente—.

tstypescript
// ❌ Annotation widens — literal types are gone
const config: Record<string, { method: "GET" | "POST" | "PUT" | "DELETE" }> = {
  getUser:    { method: "GET" },
  createUser: { method: "POST" },
};
 
// config.getUser.method is "GET" | "POST" | "PUT" | "DELETE"
// You can't use it where only "GET" is accepted without another assertion
 
// ✅ satisfies validates the shape and preserves literals
const config = {
  getUser:    { method: "GET" },
  createUser: { method: "POST" },
} satisfies Record<string, { method: "GET" | "POST" | "PUT" | "DELETE" }>;
 
// config.getUser.method is "GET" — the literal is preserved
type GetMethod = typeof config.getUser.method; // "GET"

La misma validación estructural. Tipos más ricos en el resto del código. Ese es el compromiso central.

Dónde importa esto en la práctica

Los registros de rutas son el ejemplo canónico. Una base de código que genera clientes de API tipados o usa tablas de enrutamiento para verificar permisos necesita algo más que validación de forma: necesita que los valores literales sobrevivan.

tstypescript
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
 
interface RouteDefinition {
  path: string;
  method: HttpMethod;
  auth: boolean;
}
 
// ❌ After this annotation, specificity is gone
const ROUTES: Record<string, RouteDefinition> = {
  listUsers:  { path: "/users",     method: "GET",    auth: true },
  createUser: { path: "/users",     method: "POST",   auth: true },
  deleteUser: { path: "/users/:id", method: "DELETE", auth: true },
};
 
// ROUTES.listUsers.method is HttpMethod — you've lost "GET"
// Every consumer has to assert or runtime-check method values
 
// ✅ Shape validated, literals preserved
const ROUTES = {
  listUsers:  { path: "/users",     method: "GET",    auth: true },
  createUser: { path: "/users",     method: "POST",   auth: true },
  deleteUser: { path: "/users/:id", method: "DELETE", auth: true },
} satisfies Record<string, RouteDefinition>;
 
// ROUTES.listUsers.method is "GET"
// ROUTES.createUser.method is "POST"
type ListMethod = typeof ROUTES.listUsers.method; // "GET"

Cuando generas wrappers de fetch tipados, esquemas OpenAPI o mapas de permisos a partir de este registro, los tipos literales pasan a formar parte de tu superficie de API a nivel de tipos, sin necesidad de comprobaciones en tiempo de ejecución.

satisfies vs as: no son lo mismo

as es una aserción: le estás diciendo al compilador que confíe en ti sin importar lo que vea. Suprime errores; no valida. Confundir ambos es como los errores a nivel de tipos terminan llegando a producción.

tstypescript
// ❌ as bypasses validation — the compiler accepts nonsense
const broken = {
  getUser: { method: "INVALID_METHOD" },
} as Record<string, RouteDefinition>; // No error, but wrong at runtime
 
// ✅ satisfies catches the mistake at compile time
const broken = {
  getUser: { method: "INVALID_METHOD" },
} satisfies Record<string, RouteDefinition>;
// Error: Type '"INVALID_METHOD"' is not assignable to type 'HttpMethod'

Reserva as para los casos en los que realmente sabes algo que el compilador no puede saber —el resultado de una consulta al DOM donde el tipo de elemento es seguro, o un payload deserializado tras una validación explícita en tiempo de ejecución—. Usa satisfies cuando quieras validación con inferencia preservada.

Combinando con as const

Cuando necesitas preservar literales y inmutabilidad profunda a la vez, as const y satisfies se combinan sin fricciones.

tstypescript
const STATUS_CODES = {
  ok:           200,
  created:      201,
  noContent:    204,
  badRequest:   400,
  unauthorized: 401,
  notFound:     404,
  serverError:  500,
} as const satisfies Record<string, number>;
 
// Every value is its literal numeric type: 200, 201, 204...
// AND TypeScript enforces that all values are numbers
type OkCode = typeof STATUS_CODES.ok; // 200 — not number
 
function isSuccess(code: typeof STATUS_CODES[keyof typeof STATUS_CODES]): boolean {
  return code >= 200 && code < 300;
}
~

El orden importa: escribe as const satisfies T, no satisfies T as const. TypeScript evalúa de izquierda a derecha: as const reduce los tipos a literales primero, y luego satisfies valida ese tipo ya reducido contra la restricción.

Manteniendo precisas las uniones discriminadas

satisfies resulta especialmente útil cuando cada entrada de un objeto de búsqueda es un miembro de una unión discriminada y necesitas que el literal discriminante sobreviva más adelante en el flujo.

tstypescript
type NotificationHandler =
  | { type: "email"; to: string; subject: string }
  | { type: "sms"; to: string }
  | { type: "push"; deviceToken: string; title: string };
 
// ❌ Annotation collapses discriminants to the full union
const handlers: Record<string, NotificationHandler> = {
  email: { type: "email", to: "", subject: "" },
  sms:   { type: "sms",   to: "" },
  push:  { type: "push",  deviceToken: "", title: "" },
};
// handlers.email.type is "email" | "sms" | "push"
 
// ✅ satisfies keeps each member precise
const handlers = {
  email: { type: "email", to: "",   subject: "" },
  sms:   { type: "sms",   to: "" },
  push:  { type: "push",  deviceToken: "", title: "" },
} satisfies Record<string, NotificationHandler>;
 
// handlers.email.type is "email"
// handlers.sms.type is "sms"
// Downstream switch statements can prove exhaustiveness without assertions

Esto importa cuando los objetos manejadores fluyen hacia funciones que despachan según type. Con una anotación que ensancha los tipos, el compilador no puede ayudar. Con satisfies, puede demostrar que cada rama es alcanzable y que el switch es exhaustivo.

Un patrón completo: registro de feature flags con seguridad de tipos

Aquí tienes un patrón de nivel productivo que junta todas las piezas. El registro valida la estructura, preserva los literales y deriva automáticamente su unión de claves.

tstypescript
interface FeatureFlag {
  defaultValue: boolean;
  description: string;
  rolloutPercentage: number;
}
 
const FLAGS = {
  newDashboard: {
    defaultValue: false,
    description: "Enables the redesigned analytics dashboard",
    rolloutPercentage: 0,
  },
  streamingExport: {
    defaultValue: true,
    description: "Uses streaming for large CSV exports",
    rolloutPercentage: 100,
  },
  betaSearch: {
    defaultValue: false,
    description: "Experimental vector-powered search",
    rolloutPercentage: 10,
  },
} satisfies Record<string, FeatureFlag>;
 
// Derived automatically — no manual union to maintain
type FlagName = keyof typeof FLAGS;
// "newDashboard" | "streamingExport" | "betaSearch"
 
function isEnabled(flag: FlagName, userPercentile: number): boolean {
  const { defaultValue, rolloutPercentage } = FLAGS[flag];
  return defaultValue || userPercentile <= rolloutPercentage;
}

FlagName se actualiza cada vez que cambia FLAGS. Añade una clave y la unión se expande. Elimina una y cualquier referencia obsoleta falla en tiempo de compilación. Una entrada de flag malformada es un error de compilación, no una sorpresa en tiempo de ejecución descubierta en un despliegue de staging.

Puntos clave

  1. Las anotaciones ensanchan, satisfies preserva — usa satisfies cuando quieras validación de forma y que el código posterior vea los tipos literales.
  2. satisfies valida; as suprime — nunca uses as para tapar un desajuste de forma que satisfies habría rechazado correctamente.
  3. as const satisfies T es la combinación completa — literales inmutables que siguen validándose contra una restricción estructural.
  4. Deriva uniones de claves a partir de los registros — keyof typeof myObject después de satisfies te da una unión precisa y autosuficiente, sin sincronización manual.
  5. Los miembros de uniones discriminadas se mantienen específicos — los discriminantes literales sobreviven, lo que hace que los switch posteriores sean totalmente exhaustivos sin aserciones adicionales.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX