Seis técnicas de TypeScript que convierten los tipos en una verdadera prevención de errores
Aprenda cómo las uniones con etiquetas, los tipos derivados y los IDs marcados permiten que TypeScript detecte errores reales en tiempo de compilación, en lugar de en producción.
Fuentes: TypeScript Can Already Do This. Almost Nobody Uses It.
Un proyecto puede listar TypeScript en package.json, declarar interfaces con una docena de campos opcionales y, aun así, fallar con el mensaje “cannot read properties of undefined”. Tener instalado el compilador no es lo mismo que lograr que funcione para uno, y añadir : string a cada variable por sí solo sirve de poco. Las seis técnicas que se presentan a continuación son las que utilizan los equipos experimentados para darle verdadero peso al verificador de tipos, cada una con un fragmento de código y la razón detrás de ella.
Validar objetos con satisfies en lugar de anotaciones
La forma habitual de asegurarse de que un objeto de configuración tenga la estructura correcta es anotarlo:
const config: Record<string, number> = {
timeout: 3000,
retries: 3,
};
config.timeout.toFixed(2); // fine
Esto se compila, pero la anotación reemplaza todo lo que TypeScript habría inferido. El tipo de la variable es ahora exactamente Record<string, number>, por lo que el compilador ya no sabe qué claves tiene realmente el objeto. config.timeout es simplemente “un número bajo una clave de cadena”, un error tipográfico como config.timout se acepta sin problemas, y con noUncheckedIndexedAccess habilitado, cada propiedad leída se convierte en number | undefined.
El operador satisfies, disponible desde TypeScript 4.9, verifica el objeto contra el tipo sin reemplazar el tipo inferido:
const config = {
timeout: 3000,
retries: 3,
} satisfies Record<string, number>;
config.timeout; // TypeScript still knows this is literally 3000
Se obtiene la validación (un valor de cadena sería rechazado) y se mantiene la estructura exacta: TypeScript sabe que el objeto tiene precisamente timeout y retries, y las claves escritas incorrectamente generan errores. Una corrección a una afirmación común, incluyendo el comentario en el fragmento: satisfies por sí solo no mantiene timeout como el tipo literal 3000. Las propiedades de un objeto mutable siguen ampliándose a number. Si también necesita los valores literales, combine los dos operadores como as const satisfies Record<string, number>, lo que hace que el objeto sea de solo lectura y preserva cada valor literal. Muchos desarrolladores siguen utilizando esta anotación simplemente porque fue la única opción durante años.
Variantes de modelo como uniones etiquetadas, no campos opcionales
Cuando un valor puede adoptar varias formas, un atajo tentador es hacer que cada campo sea opcional:
interface ApiResponse {
data?: User[];
error?: string;
loading?: boolean;
}
El tipo ahora permite toda combinación, incluyendo data y error juntos, o ninguno de ellos con loading en valor falso. La regla de que solo se puede aplicar un estado a la vez está en la mente de las personas, y el compilador no puede hacerla cumplir.
Una unión discriminada (unión etiquetada) codifica esa regla. Cada variante lleva una etiqueta literal status y solo los campos que le corresponden:
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />; // data exists, guaranteed
case 'error':
return <ErrorBanner message={response.error} />;
}
}
Dentro de cada case, TypeScript restringe response a la variante correspondiente. response.data solo es accesible en la rama success, ya que las demás variantes no tienen propiedad data en absoluto. Toda una categoría de errores del tipo “¿está definido aquí?” desaparece antes de que se ejecute el código, y el mismo patrón funciona para las acciones del reducer, los estados de formulario y los resultados de la API.
Hacer que los casos faltantes provoquen el fallo de la compilación con never
Las uniones etiquetadas tienen un problema pendiente. Supongamos que, meses después, alguien agrega un estado 'forbidden'. Nada obliga a que se actualice cada switch sobre ApiResponse, por lo que el nuevo caso se ejecuta en silencio y termina siendo un error en producción.
Una verificación de exhaustividad cierra esa brecha. La rama default asigna el valor reducido a una variable de tipo never:
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorBanner message={response.error} />;
default:
const exhaustiveCheck: never = response;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
Cuando se manejan todas las variantes, no queda nada para que response esté en la rama default, por lo que su tipo es never y la asignación se compila. Si se agrega 'forbidden' sin un case correspondiente, response pasa a ser { status: 'forbidden' }, lo cual no se puede asignar a never, por lo que la compilación falla y señala directamente a la rama olvidada. El throw también te protege en tiempo de ejecución si, de todos modos, llegan datos inesperados. Rodear el cuerpo de default entre llaves mantiene satisfechos a los analizadores que marcan declaraciones dentro de las cláusulas case.
Utiliza unknown en los límites de tu aplicación
any desactiva la verificación de tipos. unknown la mantiene activa y obliga a demostrar cuál es el valor antes de usarlo. Ese es exactamente el comportamiento que se desea cuando entran datos no confiables: respuestas de API, resultados de JSON.parse, cargas de webhook.
Aquí, la función auxiliar fetch devuelve deliberadamente Promise<unknown>. Leer .name directamente genera un error de compilación, y solo es válido dentro de una protección de tipo:
async function fetchUser(id: string): Promise<unknown> {
const res = await fetch(`/api/user/${id}`);
return res.json();
}
const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
raw.name; // fine, narrowed to User inside the guard
}
isUser es un guardián de tipo definido por el usuario con la firma function isUser(x: unknown): x is User, y su cuerpo debe verificar realmente la estructura en tiempo de ejecución. Con unknown, el compilador rechaza el acceso a las propiedades hasta que algo haya verificado los datos. Con any, la validación es opcional, y las verificaciones opcionales suelen omitirse hasta que una respuesta mal formada llegue a un componente que asumió que siempre existiría un campo. Para cargas más grandes, una biblioteca de esquemas como Zod puede generar tanto la verificación en tiempo de ejecución como el tipo correspondiente. Se tratan más patrones al respecto en seis alternativas seguras desde el punto de vista tipológico para reemplazar any.
Derivar tipos a partir del código en lugar de mantener copias
Si una función ya genera un valor, no es necesario contar con una interfaz escrita manualmente para su resultado. TypeScript puede extraerlo:
function createOrderPayload(order: Order) {
return {
id: order.id,
total: order.total,
lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
};
}
type OrderPayload = ReturnType<typeof createOrderPayload>;
ReturnType<typeof createOrderPayload> lee el tipo de retorno inferido por la función. Al modificar la función, OrderPayload se actualiza en todos los lugares donde se utilice. Por el contrario, una interfaz mantenida manualmente se desvía: después de algunos reestructuramientos, indica que existe un campo que la función dejó de devolver hace tiempo, y el compilador confía en esa interfaz obsoleta.
La misma idea se aplica a los componentes de bibliotecas que no son propias. React.ComponentProps<typeof Button> devuelve exactamente las propiedades que acepta un Button de terceros, tomadas de sus propias definiciones de tipo, por lo que no existe una copia oculta que actualizar cuando la biblioteca agrega una nueva propiedad. Ayudas relacionadas como Parameters, Awaited y tipos de acceso indexado (OrderPayload['lineItems'][number]) amplían este enfoque; consulte los tipos de utilidad integrados de TypeScript para obtener una visión general.
Tipos primitivos de marca para que los IDs no puedan intercambiarse
Por defecto, tanto un UserId como un OrderId son cadenas de texto simples, por lo que pasar un ID de pedido a una función que espera un ID de usuario se compila sin problemas. La consulta simplemente devuelve nada, y alguien tiene que perder tiempo averiguando por qué.
El branding intersecta con lo primitivo mediante una propiedad fantasma, lo que hace que ambos se vuelvan incompatibles:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId
La propiedad __brand nunca existe en tiempo de ejecución; solo existe en el sistema de tipos, lo cual es suficiente para que TypeScript trate a los dos tipos como distintos. Los valores se crean mediante un pequeño constructor o función de validación (el cast as OrderId en el ejemplo representa eso), y a partir de entonces cualquier confusión accidental genera un error de compilación sin costo alguno en tiempo de ejecución. Mantenga los casts dentro de esos pocos constructores para que el término “brand” signifique realmente “este valor fue verificado”.
Conclusión
Nada de esto requiere un nuevo framework o biblioteca; se trata del TypeScript que ya tiene, utilizado de manera intencionada:
satisfiesvalida sin descartar la forma inferida; añadaas constcuando los valores literales sean importantes.
never convierte un caso olvidado en un error de compilación.unknown en los límites obliga a realizar una validación antes del uso.Elija una técnica y aplíquela en su próxima solicitud de integración. Cada una traslada un tipo de errores del tiempo de ejecución al tiempo de compilación, que es la diferencia práctica entre tener instalado TypeScript y contar con él para proteger su código.
Lecturas relacionadas
- Respuestas a entrevistas sobre React y JavaScript que demuestran un conocimiento profundo — Explore respuestas más sólidas y detalladas a las preguntas comunes en entrevistas sobre React y JavaScript, desde el Virtual DOM hasta el diseño de sistemas, que reflejan un juicio ingenieril más profundo.
- Por qué las abstracciones frontend se convierten silenciosamente en deuda técnica — Aprenda por qué las abstracciones frontend prematuras añaden complejidad oculta y cómo determinar cuándo realmente vale la pena crear componentes compartidos, hooks o utilidades.