Tipaste tu función con number, todo bien en el editor, y el primer día con datos de verdad llega un precio escrito como "12,50" desde un formulario, o un null porque el producto no tiene tarifa. Ahora tienes dos salidas, y las dos son malas: poner any y volver al punto de partida, o llenar el código de comprobaciones a ciegas que luego nadie entiende.
En el post de introducción a TypeScript viste cómo poner tipos básicos, interfaces y por qué any es una puerta trasera. Aquí vamos a lo que viene justo después: qué haces cuando un dato no siempre es del mismo tipo, que es lo normal en cualquier aplicación que hable con un formulario, el DOM o una API. TypeScript tiene herramientas muy concretas para eso, y entenderlas cambia cómo escribes código.
Está escrito pensando en TypeScript 7.0, la versión estable actual (Microsoft la anunció en julio de 2026, reescrita en Go y bastante más rápida al compilar). Los ejemplos los he compilado con TypeScript 6.0. Según el anuncio oficial, el comprobador de tipos de la 7.0 es compatible con el de la 6.0, así que no deberías notar diferencias con el código de este post.
Cuando un dato puede ser de dos tipos a la vez
Piensa en un campo de precio en una tienda online. Si el dato viene de un <input>, llega como texto: "12,50". Si viene de tu base de datos, llega como número: 12.5. Y si el producto está sin tarifar, puede no llegar nada. Un mismo dato, tres formas posibles. Es algo que ya sabes de las validaciones de formularios con JavaScript: todo lo que sale de un campo HTML es un string, aunque el usuario haya escrito un número.
TypeScript lo resuelve con los union types (tipos unión): una lista de tipos permitidos separados por una barra vertical. Se leen como una "o".
function formatearPrecio(precio: number | string): string {
return precio.toFixed(2) + " €";
}
Ese number | string significa "un número o un texto". Hasta aquí parece cómodo, pero el compilador se queja en cuanto intentas usar el dato:
error TS2339: Property 'toFixed' does not exist on type 'string | number'.
Property 'toFixed' does not exist on type 'string'.
Aquí es donde la mayoría se atasca la primera vez, porque parece que TypeScript te pone trabas. Lo que hace es protegerte: toFixed existe en los números pero no en los textos, y como el parámetro podría ser un texto, no te deja llamarlo. Si lo dejara pasar, el día que llegue "12,50" tendrías un TypeError en producción.
Narrowing: demostrarle a TypeScript qué tienes delante
La solución es comprobar el tipo antes de usarlo. Esa comprobación se llama narrowing (estrechar el tipo). Es como el control de un aeropuerto: mientras no hayas pasado el control, el compilador solo sabe que eres "pasajero"; cuando enseñas el billete, ya sabe en qué vuelo vas y te deja entrar a la puerta correspondiente.
La herramienta más habitual es typeof, la misma que ya usas en JavaScript. La diferencia es que ahora TypeScript la lee y se entera de lo que significa:
function formatearPrecio(precio: number | string): string {
if (typeof precio === "string") {
// Aquí dentro TypeScript sabe que precio es string
precio = Number(precio.replace(",", "."));
}
// Aquí fuera sabe que ya es number
return precio.toFixed(2).replace(".", ",") + " €";
}
console.log(formatearPrecio(12.5)); // 12,50 €
console.log(formatearPrecio("12,50")); // 12,50 €
Fíjate en el replace(",", "."). Una persona en España escribe 12,50 con coma, y Number("12,50") devuelve NaN porque JavaScript solo entiende el punto decimal. Ese tropiezo no te lo arregla TypeScript, es del lenguaje, pero es justo el tipo de dato real que justifica el union type.
Lo bonito es que no tienes que hacer nada especial: TypeScript sigue el flujo de tu código. Dentro del if trata a precio como string, y al salir sabe que, si el código llegó hasta ahí, solo puede ser un número. Por eso se llama estrechar: el abanico de tipos posibles se va cerrando a medida que avanzas.
null y undefined: el error más común con el DOM
Si has seguido el post sobre el DOM, sabes que document.querySelector busca un elemento en la página. Lo que quizá no te habías planteado es qué pasa cuando no lo encuentra: devuelve null. En JavaScript puro eso explota en tiempo de ejecución con el famoso "Cannot read properties of null". En TypeScript lo ves antes de ejecutar nada:
const input = document.querySelector("#precio");
console.log(input.value);
error TS18047: 'input' is possibly 'null'.
error TS2339: Property 'value' does not exist on type 'Element'.
Hay dos problemas mezclados. El primero es el null: el tipo de retorno de querySelector es en realidad Element | null, otro union type. El segundo es que un Element genérico no tiene .value, porque solo los inputs lo tienen. Se arreglan con dos pasos: decirle qué elemento esperas con un genérico y comprobar que existe.
const campo = document.querySelector<HTMLInputElement>("#precio");
if (campo !== null) {
console.log(campo.value);
}
// Versión corta: ?. se detiene si campo es null, ?? da un valor por defecto
const texto = campo?.value ?? "";
El ?. (encadenamiento opcional) significa "si es null o undefined, para aquí y devuelve undefined". El ?? (fusión nula) significa "si lo de la izquierda es null o undefined, usa lo de la derecha". Combinados cubren la mayoría de los casos con el DOM sin escribir un if cada vez. Ojo: si lo que quieres es cortar el programa cuando el elemento falta (porque sin él no tiene sentido seguir), el if con un aviso claro sirve mejor que callar el problema con un texto vacío.
💡 Esto antes no pasaba
Desde TypeScript 6.0 la opción strict viene activada por defecto. Con versiones anteriores solo te saltaban estos avisos de null si habías puesto "strict": true en el tsconfig.json. Si ves código antiguo que "compila bien" con un querySelector sin comprobar, probablemente lo hacía sin strict.
Tipos literales: valores concretos en vez de "cualquier texto"
Hasta ahora los union types combinaban tipos distintos. Pero también puedes combinar valores exactos. Piensa en el estado de un pedido de tu tienda: solo puede ser pendiente, pagado o cancelado. Si lo tipas como string, TypeScript acepta cualquier texto, incluido uno con una errata. Y esa errata llega a producción en silencio, sin que nada falle.
type Estado = "pendiente" | "pagado" | "cancelado";
let estadoPedido: Estado = "pagdo";
error TS2820: Type '"pagdo"' is not assignable to type 'Estado'. Did you mean '"pagado"'?
El compilador no solo te dice que algo va mal, te sugiere la palabra correcta. Es la ventaja que no se ve en un ejemplo pequeño: en un proyecto con 40 sitios que comparan el estado, una errata en uno solo de ellos es una de esas cosas que cuesta horas encontrar sin ayuda del compilador.
Además, el editor te autocompleta los tres valores posibles cuando escribes las comillas. Es documentación que no se queda desactualizada, porque es el propio código.
function describirEstado(estado: Estado): string {
switch (estado) {
case "pendiente":
return "Esperando el pago";
case "pagado":
return "Pago recibido";
case "cancelado":
return "Pedido cancelado";
}
}
console.log(describirEstado("pagado")); // Pago recibido
Fíjate en que la función no tiene un return final fuera del switch, y TypeScript no protesta. Sabe que los tres casos cubren todas las posibilidades de Estado, así que no hay forma de salir sin devolver un string.
Cuando cada caso trae datos distintos: uniones discriminadas
El caso más útil de todo el post. Imagina que tu tienda acepta tres formas de pago: tarjeta, Bizum y transferencia. Cada una guarda datos diferentes (los últimos dígitos, un teléfono, un IBAN). Si metes todo en un único objeto con campos opcionales, acabas con algo así: "un pago tiene un teléfono, a veces, y un IBAN, a veces, y no sé cuál toca". Es un lío, y el compilador no puede ayudarte.
La alternativa es que cada forma de pago sea su propio tipo de objeto, con un campo común (tipo) que lo identifica. Se llama unión discriminada (el campo común es el "discriminante"). Si has visto objetos en JavaScript, es lo mismo que un objeto normal, solo que declarando varios moldes posibles.
type Pago =
| { tipo: "tarjeta"; ultimosDigitos: string }
| { tipo: "bizum"; telefono: string }
| { tipo: "transferencia"; iban: string };
function resumirPago(pago: Pago): string {
switch (pago.tipo) {
case "tarjeta":
return "Tarjeta acabada en " + pago.ultimosDigitos;
case "bizum":
return "Bizum al " + pago.telefono;
case "transferencia":
return "Transferencia a " + pago.iban;
}
}
console.log(resumirPago({ tipo: "bizum", telefono: "600123456" }));
// Bizum al 600123456
Dentro de cada case, el narrowing funciona por sí solo: en el de "bizum" TypeScript sabe que el objeto tiene telefono, y en el de "tarjeta" sabe que tiene ultimosDigitos. Y si intentas leer pago.telefono fuera de un caso donde se haya comprobado, te avisa:
error TS2339: Property 'telefono' does not exist on type 'Pago'.
Property 'telefono' does not exist on type '{ tipo: "tarjeta"; ultimosDigitos: string; }'.
Venga, ahora el truco que más me gusta de las uniones discriminadas. Supón que dentro de tres meses añades PayPal como cuarta forma de pago, pero se te olvida actualizar la función resumirPago. En JavaScript, un pago de PayPal pasaría por el switch sin coincidir con nada y devolvería undefined. En TypeScript puedes montar una red de seguridad con el tipo never ("esto no debería ocurrir nunca"):
function resumirPago(pago: Pago): string {
switch (pago.tipo) {
case "tarjeta":
return "Tarjeta acabada en " + pago.ultimosDigitos;
case "bizum":
return "Bizum al " + pago.telefono;
case "transferencia":
return "Transferencia a " + pago.iban;
default: {
// Si añades un tipo nuevo a Pago y olvidas tratarlo, falla aquí
const sinCubrir: never = pago;
return sinCubrir;
}
}
}
Si añades { tipo: "paypal"; email: string } a Pago, el compilador marca la línea del never con Type '{ tipo: "paypal"; email: string; }' is not assignable to type 'never'. Te está diciendo: "hay un caso que no has tratado". No hace falta ejecutar nada ni esperar a que un cliente pague con PayPal para enterarte.
Otras formas de estrechar: Array.isArray y comparaciones
typeof no sirve para todo. Con arrays, typeof devuelve "object", igual que con cualquier objeto, así que no distingue. Para eso existe Array.isArray, y TypeScript también lo entiende. Un caso típico: una función de cupones de descuento que acepta un único código o una lista de ellos.
function normalizarCupones(cupones: string | string[]): string[] {
if (Array.isArray(cupones)) {
return cupones;
}
return cupones.split(",").map((c) => c.trim());
}
console.log(normalizarCupones("VERANO10, ENVIOGRATIS")); // [ 'VERANO10', 'ENVIOGRATIS' ]
console.log(normalizarCupones(["VERANO10"])); // [ 'VERANO10' ]
Fíjate en cómo el if con return deja el resto de la función con un único tipo posible. Es un patrón muy práctico: tratas primero el caso especial, sales de la función, y lo que queda ya es el caso general sin ramas anidadas.
Las comparaciones directas también cuentan. Un if (precio === null) estrecha igual que un typeof, y otro tanto con if (!campo) o con "telefono" in pago, que comprueba si un objeto tiene esa propiedad. Con las uniones discriminadas del apartado anterior casi nunca hará falta in, pero te lo vas a encontrar en código ajeno.
El atajo que te perjudica: as y any
Cuando el compilador protesta y no ves por qué, la tentación es silenciarlo. Hay dos formas de hacerlo, y las dos te esconden el fallo en lugar de resolverlo. La primera ya la conoces: any. La segunda es la aserción de tipo con as, que le dice al compilador "confía en mí, esto es un X".
type Producto = { nombre: string; precio: number };
// Supón que la API responde { "nombre": "Teclado", "precio": "49,90" }
const dato = JSON.parse('{ "nombre": "Teclado", "precio": "49,90" }') as Producto;
console.log(dato.precio.toFixed(2)); // TypeError en ejecución
El compilador no se queja porque tú le aseguraste que precio era un número. Pero los tipos de TypeScript desaparecen al compilar: en ejecución solo queda JavaScript, y JavaScript no sabe nada de tu as. El precio es un texto, toFixed no existe en los textos, y la aplicación se cae exactamente como si no usaras TypeScript.
❌ Error común:
Usar as sobre datos que vienen de fuera (una respuesta de fetch, un localStorage, un JSON). TypeScript solo puede comprobar lo que ve al compilar, no los datos reales que llegan después. Para esos datos, lo seguro es tipar la entrada como number | string (o lo que corresponda) y estrechar con las técnicas de este post.
Mi regla práctica es que as se usa cuando tú sabes algo que el compilador no puede saber (por ejemplo, que un elemento del DOM es un HTMLInputElement porque lo escribiste tú en el HTML). Nunca para datos que no controlas.
Poner TypeScript 7 a funcionar con un tsconfig
Si quieres probar todo esto en tu máquina, crea una carpeta, ejecuta npm init -y y luego npm install --save-dev typescript. Con la versión 7 el comando sigue siendo tsc, así que nada cambia al usarlo. Lo que sí merece una pausa es el archivo de configuración, porque aquí es donde la gente se atasca con las versiones nuevas.
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true
}
}
Pon el rootDir sí o sí. Si tu código está en src/ y no lo indicas, TypeScript 6 protesta con el error TS5011 (pide que lo definas de forma explícita) y el resultado acaba en dist/src/index.js en lugar de dist/index.js. Esto me costó pillarlo la primera vez: el primer día no sabes por qué node dist/index.js no encuentra nada.
Otro detalle: si tienes un tsconfig.json en la carpeta, ejecutar tsc archivo.ts falla en las versiones 6 y 7 (error TS5112) salvo que añadas --ignoreConfig. Lo normal es compilar todo el proyecto con npx tsc y ejecutar el resultado con node dist/index.js.
Dato para tu editor:
VS Code ya incluye su propio TypeScript para mostrarte los errores mientras escribes. Si el subrayado rojo no coincide con lo que dice npx tsc, mira qué versión usa el editor (en la barra inferior, al abrir un archivo .ts).
Ejercicio: una función de precio que no se rompe con nada
Para fijar todo lo anterior, junta las piezas en una sola función. El objetivo es formatear un precio que puede llegar como número, como texto con coma o punto, o sin valor, y devolver siempre un texto listo para enseñar en la tienda, con el IVA del 21 % ya aplicado.
Intenta escribirla tú antes de mirar la solución. Las pistas: son tres tipos en el parámetro (number | string | null), un if para el null, otro para el string, y hay que tratar el caso en que Number() devuelva NaN.
function precioConIva(precio: number | string | null): string {
if (precio === null) {
return "Sin precio";
}
if (typeof precio === "string") {
precio = Number(precio.replace(",", "."));
}
if (Number.isNaN(precio)) {
return "Precio no válido";
}
const conIva = precio * 1.21;
return conIva.toFixed(2).replace(".", ",") + " €";
}
console.log(precioConIva(100)); // 121,00 €
console.log(precioConIva("49,90")); // 60,38 €
console.log(precioConIva("doce")); // Precio no válido
console.log(precioConIva(null)); // Sin precio
Fíjate en el orden. Primero se descarta el null, luego se convierte el texto, y solo después se opera con el número. Si cambias el orden y haces la multiplicación antes de convertir, el compilador te lo impide, y esa es precisamente la ayuda que buscábamos.
Siguiente paso: tipar los datos de verdad que ya manejas
Coge un proyecto que ya tengas, como el carrito de la compra con localStorage o el de enviar un formulario al backend con fetch, y busca los puntos donde un dato puede ser de más de un tipo: lo que lees de localStorage, lo que recibes de una API, lo que escribe el usuario. En cada uno, pregúntate qué formas puede tener, ponle un union type y deja que el compilador te enseñe los casos que te faltaban. Seguro que encuentras alguno en la primera media hora.
Si el proyecto es tuyo y está en JavaScript, no hace falta reescribirlo entero: TypeScript permite ir archivo por archivo. Empieza por uno que te dé problemas y deja el resto como está.
Qué aprender ahora
Con los union types ya puedes tipar los datos que llegan de fuera. El siguiente paso lógico es ver de dónde vienen: cómo se pide un dato a una API y se trata la respuesta.
JavaScript Fetch API: tu primera llamada