Los datos reales vienen sucios. Un héroe recién creado no tiene stats; a otro le falta el rango;
un novato tiene 0 partidas, que es un dato real, no un hueco. Acceder a esos datos a lo bruto
—heroe.stats.rango— revienta en cuanto algo falta, y poner valores por defecto con || te
estropea los ceros. JavaScript moderno tiene dos operadores para esto, y los usarás en cada línea de
React: ?. para leer sin romper y ?? para dar defaults sin pisar valores legítimos.
Seguimos con el Overwatch Team Builder, pero esta vez con un roster a propósito incompleto: héroes sin
stats, sinrango, y un novato con0partidas. El terreno perfecto para?.y??.
El problema: leer algo que puede no estar#
Si stats puede faltar, esto es una bomba:
// este héroe no trae stats
const heroe = { nombre: "Mercy" };
// stats es undefined → acceder a .rango sobre undefined LANZA un error y corta el programa.
// TypeError: Cannot read properties of undefined
console.log(heroe.stats.rango);Durante años, la defensa fue encadenar comprobaciones con &&: si lo de la izquierda existe, sigue.
// "Si stats existe, dame stats.rango; si no, no sigas (da undefined)."
// && devuelve el primer operando que sea falsy — aquí undefined — no false.
const rango = heroe.stats && heroe.stats.rango;
// undefined (no revienta, pero el código se llena de stats &&)
console.log(rango);Funciona, pero es ruidoso, sobre todo si bajas varios niveles. La sintaxis moderna lo resuelve en un operador.
Optional chaining: ?.#
El optional chaining ?. lee una propiedad solo si lo de la izquierda no es null ni
undefined. Si lo es, corta la cadena y devuelve undefined, sin lanzar error.
// sin stats
const heroe = { nombre: "Mercy" };
// Demuestra el caso crítico: stats no existe; ?. corta la cadena sin lanzar TypeError.
// Si stats es null/undefined, la cadena corta aquí y devuelve undefined.
// undefined (no revienta)
console.log(heroe?.stats?.rango);
// Demuestra que con datos completos ?. no interfiere: funciona igual que el acceso normal.
const tracer = { nombre: "Tracer", stats: { rango: "Diamante" } };
// Diamante
console.log(tracer?.stats?.rango);El ?. también protege llamadas y accesos por índice:
const equipo = { capitan: null };
// Demuestra la llamada opcional: no lanza "toUpperCase is not a function" cuando capitan es null.
// Llamada opcional: solo invoca toUpperCase() si capitan no es null/undefined.
// undefined (no "no es una función")
console.log(equipo.capitan?.toUpperCase());
const lista = null;
// Demuestra el acceso por índice opcional: no lanza error aunque lista sea null.
// Índice opcional: solo accede a [0] si lista existe.
// undefined (no revienta)
console.log(lista?.[0]);Lectura mental: ?. es “accede si hay algo; si no, devuelve undefined y para”.
Nullish coalescing: ??#
?. evita el error, pero te deja un undefined. Para poner un valor por defecto está el
nullish coalescing ??: devuelve lo de la derecha solo si lo de la izquierda es null o
undefined.
// sin stats
const heroe = { nombre: "Mercy" };
// Si heroe.stats.rango no existe, usa "Sin clasificar".
const rango = heroe?.stats?.rango ?? "Sin clasificar";
// Sin clasificar
console.log(rango);
// Si SÍ existe, gana el valor real; el defecto se ignora.
const tracer = { stats: { rango: "Diamante" } };
// Diamante
console.log(tracer?.stats?.rango ?? "Sin clasificar");?. y ?? son pareja: uno accede sin romper, el otro rellena el hueco. heroe?.stats?.rango ?? "Sin clasificar"
es el patrón que repetirás mil veces.
?? frente a ||: cuidado con el cero#
Quizá pienses: “esto ya lo hacía con ||”. Casi. La diferencia es crucial y es un bug clásico.
|| usa el valor por defecto ante cualquier valor falsy: 0, "", false, además de null y
undefined. ?? solo lo usa ante null/undefined. Mira qué pasa con un novato que tiene 0
partidas de verdad:
// 0 es un dato real, no un hueco
const novato = { partidas: 0 };
// || trata el 0 como "vacío" y lo pisa: PIERDES el dato.
// "sin datos" (mal)
console.log(novato.partidas || "sin datos");
// ?? respeta el 0, porque no es null ni undefined.
// 0 (correcto)
console.log(novato.partidas ?? "sin datos");La regla: para valores por defecto sobre datos donde 0, "" o false son legítimos, usa ??.
Reserva || para cuando de verdad quieras tratar cualquier valor falsy como “vacío”.
Asignación lógica: ??=#
Una vuelta de tuerca cómoda: ??= asigna un valor por defecto solo si la variable está
null/undefined. Es el atajo de if (x == null) x = ....
// falta el tema
const config = { tema: null };
// como tema es null, le pone "oscuro"
config.tema ??= "oscuro";
// oscuro
console.log(config.tema);
// ahora tema ya vale "oscuro": NO se toca
config.tema ??= "claro";
// oscuro
console.log(config.tema);Tiene dos hermanos con la misma idea: ||= asigna si la variable es falsy (con la misma trampa del
0 que ||), y &&= asigna si es truthy. En la práctica, ??= es el más útil y seguro de los tres.
Pruébalo tú#
Edita el código de abajo y pulsa «Ejecutar»: el editor corre ?., ?? y ??= de forma nativa. Juega
con él —añade un héroe sin nombre y muestra 'Anónimo' con ?? (no con ||, para que un nombre
vacío '' no se confunda con ausente)— y mira la consola.
Comprueba lo que sabes#
Pregunta 1 de 5
Si heroe.stats es undefined, ¿qué hace heroe?.stats?.rango?
Tu turno#
Leerlo no es lo mismo que saber escribirlo. Resuélvelo aquí mismo: edita el código de partida y
complétalo para imprimir con console.log una ficha por héroe a partir de un roster sucio
—accediendo con ?. y dando defaults con ??, cuidado con el novato de 0 partidas— y ejecútalo.
Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.
Ejercicio · en esta página
Pinta fichas de datos sucios sin romper
A partir de un roster con datos sucios (héroes sin stats, sin rango, y un novato con 0 partidas legítimas), imprime con console.log una ficha por héroe (nombre, rango, partidas y winrate) sin que nada reviente. Da 'Sin clasificar' si falta el rango y no confundas el 0 con un dato ausente.
Paso 1: Que funcione
- Imprimes una ficha por héroe con console.log y ninguna revienta cuando faltan datos.
- Si falta el rango, muestras 'Sin clasificar'.
Paso 2: Que esté pulido
- Accedes con optional chaining (?.) en vez de && a mano.
- Das los valores por defecto con ?? (no con ||).
- Respetas el 0: el novato muestra 0 partidas, no 'sin datos'.
Paso 3: Que sea excelente
- Combinas ?? con destructuring y valores por defecto, sin guardas con if.
- El cálculo del winrate vive aislado del formateo.
- Modelas 'lo que puede faltar' una vez y trabajas limpio.
- Usas ??= para rellenar un campo que puede faltar (por ejemplo, config.tema) en vez de un if explícito.
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "funciona"
//
// Acceso defensivo a la antigua: comprueba stats con && antes de leer dentro, así
// no revienta con Mercy (que no trae stats). Funciona y pinta todas las fichas.
//
// Su trampa: usa || para los valores por defecto. Y || trata el 0 como "vacío",
// así que Echo (un novato con 0 partidas DE VERDAD) sale como "sin datos partidas".
// Es un dato real perdido: justo el bug que ?? viene a arreglar.
// ════════════════════════════════════════════════════════════════════════════
const roster = [
{
nombre: "Tracer",
rol: "Daño",
stats: { rango: "Diamante", partidas: 120, victorias: 78 },
},
// sin stats
{ nombre: "Mercy", rol: "Apoyo" },
// sin rango
{ nombre: "Genji", rol: "Daño", stats: { partidas: 150, victorias: 72 } },
// ceros reales
{
nombre: "Echo",
rol: "Daño",
stats: { rango: "Sin clasificar", partidas: 0, victorias: 0 },
},
];
function ficha(heroe) {
// Rango: solo se lee si stats existe; si no, "Sin clasificar".
let rango = "Sin clasificar";
if (heroe.stats && heroe.stats.rango) {
rango = heroe.stats.rango;
}
// Partidas con ||: OJO, el 0 de Echo se trata como vacío y sale "sin datos" (mal).
let partidas = "sin datos";
if (heroe.stats) {
// 0 || "sin datos" → "sin datos"
partidas = heroe.stats.partidas || "sin datos";
}
// Winrate: solo tiene sentido si jugó alguna partida (evita dividir por cero).
let winrate = "—";
if (heroe.stats && heroe.stats.partidas > 0) {
winrate =
((heroe.stats.victorias / heroe.stats.partidas) * 100).toFixed(1) + "%";
}
return (
heroe.nombre + " — " + rango + " · " + partidas + " partidas · " + winrate
);
}
// Imprime una ficha por héroe, una línea por cada uno.
for (const heroe of roster) {
// una línea por héroe
console.log(ficha(heroe));
} Por qué este nivel
- Acceso defensivo con && a mano: no revienta con Mercy (sin stats), pero es verboso.
- Usa || para los defaults: trata el 0 de Echo como vacío y lo muestra como 'sin datos' (un dato real perdido).
- Funciona, pero ese bug del 0 es justo lo que el capítulo viene a arreglar.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL MEJOR — "pulido"
//
// Por qué mejora a OK:
// - Acceso seguro con ?.: heroe?.stats?.rango no revienta aunque stats falte,
// sin las comprobaciones con && a mano.
// - Defaults con ?? en vez de ||: ?? solo sustituye null/undefined, así que el 0
// real de Echo se respeta y se muestra "0 partidas", no "sin datos".
// ════════════════════════════════════════════════════════════════════════════
const roster = [
{
nombre: "Tracer",
rol: "Daño",
stats: { rango: "Diamante", partidas: 120, victorias: 78 },
},
// sin stats
{ nombre: "Mercy", rol: "Apoyo" },
// sin rango
{ nombre: "Genji", rol: "Daño", stats: { partidas: 150, victorias: 72 } },
// ceros reales
{
nombre: "Echo",
rol: "Daño",
stats: { rango: "Sin clasificar", partidas: 0, victorias: 0 },
},
];
function ficha(heroe) {
// ?. corta la cadena si stats falta; ?? pone el defecto solo si es null/undefined.
const rango = heroe?.stats?.rango ?? "Sin clasificar";
// ?? respeta el 0 de Echo (con || saldría "sin datos", que sería un dato perdido).
const partidas = heroe?.stats?.partidas ?? "sin datos";
// Winrate solo si jugó alguna partida (evita 0/0 = NaN).
const winrate =
heroe?.stats?.partidas > 0
? ((heroe.stats.victorias / heroe.stats.partidas) * 100).toFixed(1) + "%"
: "—";
return `${heroe.nombre} — ${rango} · ${partidas} partidas · ${winrate}`;
}
// Imprime una ficha por héroe, una línea por cada uno.
for (const heroe of roster) {
// una línea por héroe
console.log(ficha(heroe));
} Por qué es mejor que el anterior
- ?. para el acceso seguro (sin && a mano) y ?? para los defaults: el 0 de Echo se respeta.
- Distingue 'ausente' de 'cero': Echo muestra 0 partidas, no 'sin datos'.
- Su límite respecto a Excelente: repite ?. y mezcla el cálculo del winrate con el formateo.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL EXCELENTE — "óptimo"
//
// Por qué mejora a Mejor:
// - Combina ?? con DESTRUCTURING (capítulo 1): const { rango = "..." } = stats ?? {}
// desempaqueta y da defaults en un sitio, sin un solo ?. repetido en el cuerpo.
// - El cálculo del winrate vive en su propia función pura, separado del formateo
// de la ficha: añadir un dato a la ficha es tocar un solo sitio.
// - Usa ??= para rellenar el campo config del héroe si está ausente: asigna el
// objeto por defecto solo cuando el campo es null/undefined, sin un if explícito.
// - Sigue respetando el 0 real (?? y el guard de "sin partidas" lo conservan).
//
// Idea de fondo: el acceso seguro no es llenar el código de ?.; es modelar bien
// "lo que puede faltar" una vez (stats ?? {}) y trabajar limpio a partir de ahí.
// ════════════════════════════════════════════════════════════════════════════
const roster = [
{
nombre: "Tracer",
rol: "Daño",
stats: { rango: "Diamante", partidas: 120, victorias: 78 },
// config presente: ??= no la sobreescribe
config: { tema: "claro" },
},
// sin stats ni config
{ nombre: "Mercy", rol: "Apoyo" },
// sin rango
{ nombre: "Genji", rol: "Daño", stats: { partidas: 150, victorias: 72 } },
// ceros reales
{
nombre: "Echo",
rol: "Daño",
stats: { rango: "Sin clasificar", partidas: 0, victorias: 0 },
},
];
// Pura: dadas partidas y victorias, devuelve el winrate formateado, o null si no jugó.
// !partidas es true para 0 y para undefined: en ambos casos no hay winrate.
function winratePct(partidas, victorias) {
if (!partidas) return null;
return ((victorias / partidas) * 100).toFixed(1) + "%";
}
function ficha(heroe) {
// ??= rellena el campo config SOLO si es null/undefined (no toca el de Tracer).
heroe.config ??= { tema: "oscuro" };
// stats puede faltar: ?? {} da un objeto vacío y el destructuring aplica defaults.
const { rango = "Sin clasificar", partidas, victorias } = heroe.stats ?? {};
// null → "—"
const winrate = winratePct(partidas, victorias) ?? "—";
// ?? respeta el 0 de Echo
const jugadas = partidas ?? "sin datos";
return (
heroe.nombre +
" — " +
rango +
" · " +
jugadas +
" partidas · " +
winrate +
" [tema: " +
heroe.config.tema +
"]"
);
}
// Imprime una ficha por héroe, una línea por cada uno.
for (const heroe of roster) {
// una línea por héroe
console.log(ficha(heroe));
} Por qué es mejor que el anterior
- Combina ?? con destructuring (cap. 1): const { rango = '...' } = heroe.stats ?? {} y se acaban los ?. repetidos.
- El winrate vive en una función pura, separada del formateo de la ficha.
- Modela 'lo que puede faltar' una sola vez (stats ?? {}) y trabaja limpio a partir de ahí.
- Añade ??= para rellenar un config por defecto: heroe.config ??= { tema: 'oscuro' } asigna solo si es null/undefined, sin un if explícito.