Módulo 2: JavaScript esencial para manipular datos

6. Strings y números en la práctica

Descripción

Al terminar esta lección vas a poder tomar un dato sucio —" LUNA COFFEE ", "1,250.50", "12 kg", "cf-ara-500"— y dejarlo consistente y utilizable. Vas a conocer los métodos de texto que se usan a diario en el nodo Code, vas a saber armar mensajes con template literals en vez de pegar textos con +, y vas a manejar las conversiones numéricas con criterio: cuándo Number, cuándo parseFloat, cómo redondear a dos decimales sin sorpresas, y por qué toFixed —que parece la respuesta obvia— tiene una trampa que rompe workflows.

Esto importa porque los datos limpios son la excepción, no la regla. El canal web de Cumbre manda JSON impecable y es el que menos trabajo da; los otros dos —whatsapp con sus espacios de sobra y sus campos vacíos, y rep_csv con sus números como texto, sus mayúsculas y sus duplicados— son el 70% del volumen y el 100% de los problemas. Un nodo Code que no limpia lo que recibe funciona en la demo con datos de prueba y falla el martes con datos reales.

Conexión con el módulo: la lección 2 te dijo que '12' no es 12 y te dio Number(); esta lección desarrolla las conversiones difíciles, las que Number() no resuelve sola. La lección 3 te dio el acceso seguro con ?., y aquí lo vas a necesitar en cada línea, porque llamar un método de texto sobre null revienta el nodo. Y la lección 5 te dio map, que es la herramienta con la que vas a aplicar toda esta limpieza a una lista entera. Hacia adelante: el mini-proyecto de la lección 8 es esta lección aplicada a un dataset diseñado para ser feo.

Los métodos: qué son y cómo se leen

Antes de la lista, una aclaración de vocabulario que conviene tener clara.

Un método es una función que "pertenece" a un valor y se invoca con un punto y paréntesis: texto.trim(). Es la diferencia entre una herramienta suelta y una que viene incorporada al objeto.

'  Luna Coffee  ' . trim ( )
        │          │  │    │
        │          │  │    └─ paréntesis: se invoca. SIEMPRE van, aunque estén vacíos
        │          │  └────── el nombre del método
        │          └───────── el punto: "aplícale esto a lo de la izquierda"
        └──────────────────── el valor sobre el que opera

Dos propiedades que valen para todos los métodos de texto de esta lección:

No modifican el original. Devuelven un valor nuevo. Los textos en JavaScript son inmutables: no se pueden cambiar, solo se pueden producir textos nuevos a partir de ellos.

const raw = '  Luna Coffee  ';
raw.trim();        // 'Luna Coffee'
raw;               // '  Luna Coffee  '   ← el original sigue igual

Esto es la causa de un error muy frecuente: llamar el método y no guardar el resultado. raw.trim(); como línea suelta no hace absolutamente nada.

Se encadenan. Como cada método devuelve un texto, se le puede aplicar el siguiente:

'  LUNA COFFEE  '.trim().toLowerCase();   // 'luna coffee'

Léelo de izquierda a derecha, como una tubería: primero quita los espacios, después baja a minúsculas.

Y la advertencia de seguridad, que vas a necesitar en cada ejemplo real: null y undefined no tienen métodos.

const city = null;
city.trim();        // ❌ TypeError: Cannot read properties of null (reading 'trim')
city?.trim();       // ✅ undefined, en paz
(city ?? '').trim();// ✅ '' — mi favorito: normalizo primero, después opero

La tercera forma es la que recomiendo cuando vas a encadenar varios métodos: conviertes lo ausente en un texto vacío una sola vez, y después toda la cadena funciona sin ?. en cada eslabón.

Template literals: armar textos sin pelear

Empecemos por el que más mejora la vida diaria.

Hasta aquí viste textos entre comillas simples. Hay una tercera forma, con acentos graves —el carácter `, que en la mayoría de los teclados en español está a la izquierda del 1 o cerca de la tecla de acento—:

const message = `Hola`;

Por sí solo no aporta nada. Su superpoder es que dentro de un texto con acentos graves puedes incrustar valores con ${...}:

const name = 'Luna Coffee';
const orderId = 'ORD-2041';
const total = 2154;

const message = `Hola ${name}, tu pedido ${orderId} suma ${total} pesos.`;
// → 'Hola Luna Coffee, tu pedido ORD-2041 suma 2154 pesos.'

Compara con la alternativa de pegar con +:

const message = 'Hola ' + name + ', tu pedido ' + orderId + ' suma ' + total + ' pesos.';

La segunda funciona y es un festival de errores: espacios que faltan, comillas mal cerradas, y —peor— la coerción de la lección 2 acechando en cada +. La primera se lee como la frase que quieres producir.

Tres cosas que puedes poner dentro de ${...}:

// Una variable
`Cliente: ${order.customer_name}`

// Una expresión completa
`Total con IVA: ${orderTotal * 1.16}`

// Una llamada a método
`SKU: ${line.sku.toUpperCase()}`

Y una que conviene conocer: los acentos graves permiten saltos de línea reales, sin necesidad de caracteres especiales.

const summary = `Pedido: ${order.order_id}
Cliente: ${order.customer_name}
Total: ${orderTotal}`;

Es útil para armar el cuerpo de un correo o un mensaje de Slack que un nodo posterior va a enviar.

Una advertencia sobre lo que sí y lo que no. Dentro de ${...} va una expresión, es decir, algo que produce un valor. No va un if, ni un for, ni varias líneas de lógica. Si necesitas una decisión, usa un operador ternario —${isPriority ? 'URGENTE' : 'normal'}— o, mejor, calcula el valor arriba con un nombre claro y ponlo en el template. Un template literal con lógica adentro se vuelve ilegible muy rápido.

Los métodos de texto que vas a usar

Esta es la lista corta. No son todos los que existen; son los que aparecen en un nodo Code de verdad.

Limpiar

'  Luna Coffee  '.trim();         // 'Luna Coffee'   ← quita espacios de los extremos
'  Luna Coffee  '.trimStart();    // 'Luna Coffee  '
'  Luna Coffee  '.trimEnd();      // '  Luna Coffee'

trim es probablemente el método que más vas a escribir. Quita espacios, tabulaciones y saltos de línea de los extremos, nunca de en medio. 'Luna Coffee'.trim() sigue teniendo los tres espacios centrales.

Cambiar mayúsculas y minúsculas

'LUNA COFFEE'.toLowerCase();      // 'luna coffee'
'cf-ara-500'.toUpperCase();       // 'CF-ARA-500'

En Cumbre hay una convención: los SKU van en mayúsculas y los nombres de cliente en formato de título —primera letra de cada palabra en mayúscula—. JavaScript no tiene un método para el formato de título; se arma combinando otros, y lo vas a ver en el ejemplo trabajado.

Un uso importante que no es cosmético: comparar sin importar mayúsculas.

// ❌ Falla si el canal llegó como "WEB" o "Web"
if (order.channel === 'web') { }

// ✅ Normaliza antes de comparar
if (order.channel?.toLowerCase() === 'web') { }

Reemplazar

'1,250.50'.replace(',', '');        // '1250.50'    ← solo la PRIMERA aparición
'1,250,500'.replace(',', '');       // '1250,500'   ← ⚠️ la segunda coma sigue ahí
'1,250,500'.replaceAll(',', '');    // '1250500'    ← todas

La diferencia entre replace y replaceAll es la causa de un bug clásico con números grandes: quitas la coma de los miles y funciona con 1,250 pero falla con 1,250,500. Cuando quieras quitar todas las apariciones, replaceAll. Si tu instancia de n8n corriera una versión muy antigua de Node.js donde replaceAll no existe, la alternativa clásica es .split(',').join(''), que hace lo mismo.

Partir y unir

'CF-ARA-500'.split('-');            // ['CF', 'ARA', '500']    ← texto → arreglo
['CF', 'ARA', '500'].join('-');     // 'CF-ARA-500'            ← arreglo → texto

'luna coffee'.split(' ');           // ['luna', 'coffee']
['Luna', 'Coffee'].join(' ');       // 'Luna Coffee'

split convierte un texto en un arreglo cortándolo por el separador que le digas. join hace lo inverso. Juntos son la herramienta principal para reestructurar textos, y la pareja split().map().join() es un patrón que vas a escribir mucho.

Un uso muy concreto en Cumbre: el SKU codifica información.

const sku = 'CF-ARA-500';
const parts = sku.split('-');

const family = parts[0];       // 'CF'   ← café
const variety = parts[1];      // 'ARA'  ← arábica
const grams = Number(parts[2]);// 500

Buscar

'CF-ARA-500'.startsWith('CF-');     // true   ← ¿empieza con esto?
'archivo.csv'.endsWith('.csv');     // true   ← ¿termina con esto?
'Luna Coffee'.includes('Coffee');   // true   ← ¿lo contiene en algún lado?
'CF-ARA-500'.indexOf('ARA');        // 3      ← ¿en qué posición? (-1 si no está)

Los tres primeros devuelven booleanos y son los que más vas a usar dentro de un filter.

Cortar y rellenar

'CF-ARA-500'.slice(0, 2);           // 'CF'      ← desde la posición 0 hasta antes de la 2
'CF-ARA-500'.slice(-3);             // '500'     ← los últimos tres
'CF-ARA-500'.length;                // 10        ← cuántos caracteres (sin paréntesis)

'7'.padStart(3, '0');               // '007'     ← rellena por la izquierda
'ORD-9'.padEnd(8, ' ');             // 'ORD-9   '

padStart es útil para armar identificadores de ancho fijo, que muchos sistemas de facturación exigen.

Números: convertir de verdad

La lección 2 te dio Number(). Ahora los casos que Number() no resuelve.

Number contra parseInt contra parseFloat

Son tres herramientas distintas y elegir mal produce bugs sutiles.

Number('148.5');       // 148.5
parseFloat('148.5');   // 148.5
parseInt('148.5', 10); // 148      ← corta los decimales, no redondea

Number('12 kg');       // NaN      ← estricto: si sobra algo, falla
parseFloat('12 kg');   // 12       ← permisivo: lee mientras pueda y para
parseInt('12 kg', 10); // 12       ← permisivo

Number('');            // 0        ← ⚠️ el vacío se vuelve cero
parseFloat('');        // NaN      ← el vacío falla, que suele ser más útil
Number(null);          // 0        ← ⚠️
parseFloat(null);      // NaN

La diferencia de fondo:

Number() es estricto. Convierte el texto completo o devuelve NaN. Es lo que quieres cuando el dato debería ser un número limpio y quieres enterarte si no lo es.

parseFloat() es permisivo. Lee caracteres desde el principio mientras formen un número y se detiene en el primero que no encaja. Es lo que quieres cuando el dato viene con unidades pegadas: "12 kg", "500 g", "$148.50" —aunque este último devuelve NaN, porque el $ está al principio y parseFloat no llega a leer nada—.

parseInt() descarta la parte decimal, sin redondear. parseInt('148.9', 10) da 148, no 149. Y ese segundo argumento, el 10, se llama base y significa "estos dígitos están en base decimal"; ponlo siempre, porque sin él hay casos históricos donde parseInt interpreta el texto en otra base y produce resultados sorprendentes.

Mi recomendación para Cumbre: Number() por defecto, porque quieres que un dato mal formado se note; y parseFloat() solo cuando sepas que el campo trae unidades y hayas decidido a propósito ignorarlas.

Detectar NaN

Un NaN que se cuela contamina todo lo que toca: sumarlo da NaN, multiplicarlo da NaN, y compararlo siempre da falso. Hay que atraparlo apenas aparece.

const value = Number('doce');   // NaN

value === NaN;          // ❌ false — NaN no es igual ni a sí mismo
Number.isNaN(value);    // ✅ true

Sí, leíste bien: NaN === NaN da false. Es una rareza del estándar de números en punto flotante y es la razón por la que existe una función dedicada.

Cuidado con la versión vieja, isNaN() sin el Number. delante, que convierte antes de preguntar y produce falsos positivos:

isNaN('hola');           // true   — convirtió a NaN y respondió sobre la conversión
Number.isNaN('hola');    // false  — 'hola' es un texto, no es el valor NaN

Usa siempre Number.isNaN(). Y el patrón de conversión defensiva que vas a repetir mil veces:

// Convierte a número; si no se puede, usa el valor por defecto.
const toNumber = (value, fallback = 0) => {
  const n = Number(value);
  return Number.isNaN(n) ? fallback : n;
};

toNumber('148.5');    // 148.5
toNumber('doce');     // 0
toNumber(null);       // 0
toNumber(undefined);  // 0
toNumber('doce', -1); // -1  ← un centinela para distinguir "no se pudo" de "era cero"

Esa función de cuatro líneas es probablemente lo más útil de toda la lección. Ponla arriba de cualquier nodo Code que procese datos del canal rep_csv.

Redondear, y la trampa de toFixed

Los precios de Cumbre tienen decimales, y los totales calculados necesitan redondeo.

Math.round(148.5);      // 149   ← al entero más cercano; .5 sube
Math.floor(148.9);      // 148   ← hacia abajo, siempre
Math.ceil(148.1);       // 149   ← hacia arriba, siempre
Math.abs(-50);          // 50    ← valor absoluto
Math.max(10, 25, 7);    // 25
Math.min(10, 25, 7);    // 7

Para redondear a dos decimales, el patrón estándar es multiplicar, redondear y dividir:

const total = 2154.4567;

Math.round(total * 100) / 100;    // 2154.46   ← dos decimales, sigue siendo NÚMERO

Léelo así: llevas los centavos a la posición de las unidades, redondeas al entero, y vuelves a bajarlos. Para un decimal sería * 10 y / 10; para tres, * 1000 y / 1000.

Y ahora la trampa. Existe un método que parece hecho para esto:

total.toFixed(2);       // '2154.46'

Se ve perfecto. Y devuelve un texto, no un número. Fíjate en las comillas.

Ese texto es exactamente el problema de la lección 2 reintroducido por la puerta de atrás:

const a = (2154.4567).toFixed(2);   // '2154.46'
const b = (1050).toFixed(2);        // '1050.00'

a + b;                  // '2154.461050.00'   ❌ concatenó
Number(a) + Number(b);  // 3204.46            ✅

Un nodo Code que usa toFixed para "dejar bonitos" los totales y los pasa al nodo siguiente le entrega textos. Si el nodo siguiente los suma, los pega. Si un Filter los compara contra un número, se comporta de forma impredecible. Si van a una base de datos con columna numérica, el registro se rechaza.

La regla: toFixed es para mostrar, nunca para calcular ni para guardar. Si lo usas, que sea en el último paso, dentro de un template literal que arma un mensaje para una persona:

// ✅ Uso correcto: presentación
const message = `Tu pedido suma $${orderTotal.toFixed(2)} MXN.`;

// ✅ El campo del item se queda como número
json: { order_total: Math.round(orderTotal * 100) / 100 }

Por qué 0.1 + 0.2 no da 0.3

Vale la pena saberlo porque tarde o temprano vas a ver un total con doce decimales raros y vas a pensar que tu código está mal.

0.1 + 0.2;              // 0.30000000000000004
0.1 + 0.2 === 0.3;      // false

No es un bug de JavaScript: pasa en casi todos los lenguajes. Los números decimales se guardan en binario, y así como en decimal no puedes escribir un tercio exacto —0.3333… infinito—, en binario hay decimales corrientes que no tienen representación exacta. El error es de una parte en diez mil billones, y aparece cuando se acumula.

En Cumbre esto significa que un total de muchas líneas con precios como 148.5 y 62.33 puede salir como 5394.000000000001. Dos consecuencias prácticas:

Redondea el total antes de guardarlo o compararlo. Math.round(total * 100) / 100 resuelve el 99% de los casos.

Nunca compares dos decimales con ===. Si de verdad necesitas saber si dos totales son iguales, compara la diferencia contra una tolerancia pequeña, o —mucho mejor en un contexto de dinero— trabaja en centavos con números enteros.

Ejemplo trabajado: limpiar una fila del canal rep_csv

Aquí está todo junto, sobre el dato más feo de Cumbre. Así llega una fila del archivo que sube un vendedor de calle:

{
  "Order ID": " ord-2051 ",
  "CUSTOMER NAME": "  LUNA COFFEE  ",
  "channel": "REP_CSV",
  "city": "guadalajara",
  "sku": "cf-ara-500",
  "quantity": "12",
  "unit price": "1,250.50"
}

Cinco problemas en siete campos: claves con espacios y mayúsculas, valores con espacios de sobra, mayúsculas inconsistentes, un número con separador de miles, y todo lo numérico como texto.

// ============================================================
// Nodo: Code — "Clean rep_csv rows"
// Modo: Run Once for All Items
//
// ENTRADA:  filas del canal rep_csv, con claves y valores sucios
// SALIDA:   items normalizados: claves en snake_case, textos limpios,
//           números convertidos, más un campo de trazabilidad
// SUPUESTOS: "unit price" usa coma como separador de miles y punto
//            como separador decimal (formato de exportación de Cumbre)
// ============================================================

// --- Utilidades de limpieza -----------------------------------

// Convierte a número, tolerando separadores de miles y espacios.
// Si no se puede, devuelve el valor por defecto.
const toNumber = (value, fallback = 0) => {
  if (value == null) return fallback;
  const cleaned = String(value).trim().replaceAll(',', '');
  const n = Number(cleaned);
  return Number.isNaN(n) ? fallback : n;
};

// Deja un texto sin espacios de sobra. Tolera null y undefined.
const clean = (value) => (value ?? '').toString().trim();

// Formato de título: "  LUNA COFFEE  " → "Luna Coffee"
const titleCase = (value) =>
  clean(value)
    .toLowerCase()
    .split(' ')
    .filter((word) => word.length > 0)          // descarta espacios dobles
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');

// --- ACTO 1: leer ---------------------------------------------
const items = $input.all();

// --- ACTO 2: procesar -----------------------------------------
return items.map((item) => {
  const row = item.json;

  const quantity = toNumber(row['quantity']);
  const unitPrice = toNumber(row['unit price']);

  return {
    json: {
      // Textos: limpios y con la convención de Cumbre.
      order_id: clean(row['Order ID']).toUpperCase(),
      customer_name: titleCase(row['CUSTOMER NAME']),
      channel: clean(row['channel']).toLowerCase(),
      shipping_city: titleCase(row['city']),
      sku: clean(row['sku']).toUpperCase(),
      // Números: convertidos de verdad, y el total ya redondeado.
      quantity: quantity,
      unit_price: unitPrice,
      line_total: Math.round(quantity * unitPrice * 100) / 100,
      // Trazabilidad: si algo no se pudo convertir, quiero saberlo.
      has_numeric_issue: quantity === 0 || unitPrice === 0,
    },
  };
});

Qué esperar. Con la fila sucia de arriba, el item de salida en la vista JSON es:

{
  "order_id": "ORD-2051",
  "customer_name": "Luna Coffee",
  "channel": "rep_csv",
  "shipping_city": "Guadalajara",
  "sku": "CF-ARA-500",
  "quantity": 12,
  "unit_price": 1250.5,
  "line_total": 15006,
  "has_numeric_issue": false
}

Verifica los tipos en el panel: quantity, unit_price y line_total van sin comillas —son números— y has_numeric_issue aparece como false sin comillas, así que es un booleano de verdad.

Vale la pena mirar tres piezas de ese script con calma.

La función titleCase es una cadena de cinco métodos, y cada uno tiene su razón. clean normaliza lo ausente; toLowerCase iguala el punto de partida —si no lo hicieras, "LUNA" quedaría como "LUNA" porque su primera letra ya está en mayúscula—; split(' ') produce las palabras; el filter descarta las cadenas vacías que dejan los espacios dobles; el map sube la inicial de cada palabra pegándole el resto con slice(1); y join(' ') vuelve a armar la frase. Ese map sobre el resultado de un split es el patrón de la lección 5 aplicado a texto.

El toNumber hace tres cosas antes de convertir: descarta null y undefined de entrada, pasa a texto por si viene un número o algo raro, quita espacios y quita las comas de miles con replaceAll. Sin ese replaceAll, Number('1,250.50') daría NaN y el precio sería cero.

Los corchetes son obligatorios en cada lectura. row['Order ID'], row['unit price'], row['CUSTOMER NAME']. Con punto no se puede: las claves tienen espacios. Es el caso 1 de la lección 3, en un dato que un vendedor sube todas las semanas.

Y un detalle de honestidad en el campo has_numeric_issue: marcar el problema como "el valor quedó en cero" es una aproximación imperfecta, porque una cantidad de cero podría ser legítima. Una versión más rigurosa usaría el centinela: toNumber(value, -1) y marcar cuando el resultado sea -1. Lo dejo así para no complicar el ejemplo, pero es exactamente el tipo de decisión que conviene dejar escrita en la línea de SUPUESTOS.

Errores comunes

Llamar un método de texto sobre null o undefined (práctico). Qué pasa: el nodo se cae en rojo con Cannot read properties of null (reading 'trim') o ... of undefined (reading 'toLowerCase'), casi siempre en el ítem donde el canal whatsapp mandó un campo vacío. Por qué pasa: los métodos pertenecen al valor, y null no es un texto: no tiene métodos. Cómo detectarlo: el mensaje te dice exactamente qué método intentaste llamar; el campo que era null es el que está inmediatamente antes del punto. Cómo corregirlo: dos formas, y la elección depende de si vas a encadenar. Para un método suelto, city?.trim(). Para una cadena de varios, normaliza primero: (city ?? '').trim().toLowerCase(). Y si el campo es obligatorio por contrato, no lo tapes: descarta el item con una guarda y déjalo registrado.

Llamar el método y no guardar el resultado (práctico). Qué pasa: escribes order.customer_name.trim(); como línea suelta, ejecutas, y el nombre sigue con espacios. Por qué pasa: los textos son inmutables; los métodos devuelven un valor nuevo y no tocan el original. La línea corre, produce un texto limpio y lo tira. Cómo detectarlo: si un método aparece como línea completa sin un = ni un return delante, no está haciendo nada. Cómo corregirlo: const cleanName = order.customer_name.trim(); y usa cleanName. Es el mismo malentendido que con push, pero al revés: push sí modifica el arreglo, los métodos de texto no modifican nada.

Usar toFixed para calcular o para guardar (práctico). Qué pasa: los totales del nodo salen entre comillas en la vista JSON; el nodo siguiente los suma y los concatena, o un Filter que compara contra un número no filtra bien, o la base de datos rechaza el registro por tipo incorrecto. Por qué pasa: toFixed devuelve un texto, y su nombre no lo sugiere en absoluto. Cómo detectarlo: el punto 4 de la verificación de la lección 6 del Módulo 1 —si el total aparece entre comillas, es texto— y buscar toFixed en el script. Cómo corregirlo: para el valor que va en el item, Math.round(x * 100) / 100, que devuelve número. Reserva toFixed para el último paso de presentación, dentro de un template literal que arma un mensaje.

Usar replace cuando había que usar replaceAll (práctico). Qué pasa: la limpieza funciona en las pruebas con números chicos y falla con los grandes. "1,250" se limpia bien; "1,250,500" queda como "1250,500" y Number() de eso da NaN, que se propaga como cero o como total roto. Por qué pasa: replace con un texto como primer argumento reemplaza solo la primera aparición, y eso no está en su nombre. Cómo detectarlo: prueba tu limpieza con un valor que tenga el carácter repetido; si el bug solo aparece con datos grandes, es este. Cómo corregirlo: replaceAll cuando quieras todas las apariciones. Y prueba siempre las funciones de limpieza con el caso más feo que puedas imaginar, no con el más común.

Comparar textos sin normalizar mayúsculas ni espacios (práctico). Qué pasa: un filtro por channel === 'web' deja fuera pedidos legítimos porque el canal llegó como "Web", "WEB" o " web". El nodo corre en verde y descarta datos buenos en silencio. Por qué pasa: === compara carácter por carácter, y los tres valores de arriba son textos distintos. En Cumbre los canales llegan escritos de tres formas según quién produjo el archivo. Cómo detectarlo: si un filtro descarta más de lo que esperabas, imprime los valores únicos del campo en un item de diagnóstico y míralos. Cómo corregirlo: normaliza en la frontera, apenas lees el item: const channel = (order.channel ?? '').trim().toLowerCase();. Después compara contra el valor normalizado. Es la misma idea de "convertir en la frontera" de la lección 2, aplicada a texto.

Confundir Number con parseFloat cuando el dato trae unidades (conceptual). Qué pasa: un campo que llega como "500 g" se convierte en NaN con Number(), y todo el pedido sale con totales en cero. O al revés: usas parseFloat en un campo que debería ser un número limpio, y un dato corrupto como "12abc" pasa como 12 sin que nadie se entere. Por qué pasa: los dos convierten texto a número y la diferencia —estricto contra permisivo— no está en el nombre. Cómo detectarlo: si tus conversiones dan NaN con datos que "se ven bien", mira si sobra algo después del número. Si tus datos aceptan basura sin quejarse, probablemente estés usando parseFloat donde querías rigor. Cómo corregirlo: Number() por defecto, porque quieres enterarte cuando el dato está mal; parseFloat() solo cuando decidiste a propósito ignorar lo que venga pegado, y déjalo escrito en SUPUESTOS.

Ejercicios

Ejercicio 1 — Predice cada resultado y su tipo. Escribe qué produce cada expresión. Después verifícalo en tu nodo Playground devolviendo todas en un item de diagnóstico.

(a) '  CF-ARA-500  '.trim().toLowerCase()
(b) '1,250,500'.replace(',', '')
(c) Number('1,250.50')
(d) Number('  12  ')
(e) parseFloat('500 g')
(f) parseInt('148.9', 10)
(g) (2154.456).toFixed(2)
(h) Math.round(2154.456 * 100) / 100
(i) 'CF-ARA-500'.split('-')[2]
(j) Number.isNaN(Number('doce'))
Ver solución
ResultadoTipoNota
(a)'cf-ara-500'stringPrimero quita espacios, después baja a minúsculas
(b)'1250,500'string⚠️ Solo quitó la primera coma
(c)NaNnumberNumber es estricto: la coma lo arruina
(d)12numberLos espacios de los extremos sí los tolera
(e)500numberparseFloat lee mientras puede y para en el espacio
(f)148numberCorta el decimal, no redondea
(g)'2154.46'string⚠️ La trampa: devuelve texto
(h)2154.46numberEl patrón correcto para guardar
(i)'500'stringsplit devuelve textos, siempre
(j)truebooleanLa forma correcta de detectar NaN

Los tres que más se fallan son (b), (g) e (i).

(b) es el bug de replace contra replaceAll, y es traicionero porque funciona con la mitad de los datos.

(g) es la trampa de toFixed. Compara con (h): el mismo valor visible, dos tipos distintos, y solo uno sirve para el campo de un item.

(i) es el que casi nadie ve venir: split produce un arreglo de textos, siempre, aunque el contenido parezca numérico. Si extraes los gramos de un SKU y los sumas sin convertir, vuelves directo al bug de la lección 2.

Por qué funciona: seis de estas diez expresiones se ven "iguales" en la vista Table del panel de datos y son distintas en la vista JSON. Practicar la predicción del tipo, no solo del valor, es lo que hace que dejes de perder tardes.

Ejercicio 2 — Escribe un normalizador de pedidos. Escribe un nodo Code que tome pedidos de cualquier canal de Cumbre y devuelva items normalizados con estas reglas: order_id sin espacios y en mayúsculas; customer_name en formato de título; channel en minúsculas; shipping_city en formato de título o 'Sin ciudad' si falta; y line_items con cada línea normalizada —sku en mayúsculas, quantity y unit_price como números de verdad, más un campo line_total redondeado a dos decimales—. El nodo no debe fallar con ningún dato faltante.

Ver solución
// ============================================================
// Nodo: Code — "Normalize orders (all channels)"
// Modo: Run Once for All Items
//
// ENTRADA:  pedidos de Cumbre de cualquier canal, posiblemente sucios
// SALIDA:   los mismos pedidos con textos normalizados, números
//           convertidos y line_total calculado por línea
// SUPUESTOS: los campos de texto pueden venir null, undefined o vacíos;
//            los numéricos pueden venir como texto con separadores
// ============================================================

// --- Utilidades -----------------------------------------------

const clean = (value) => (value ?? '').toString().trim();

const titleCase = (value) =>
  clean(value)
    .toLowerCase()
    .split(' ')
    .filter((word) => word.length > 0)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');

const toNumber = (value, fallback = 0) => {
  if (value == null) return fallback;
  const n = Number(clean(value).replaceAll(',', ''));
  return Number.isNaN(n) ? fallback : n;
};

const round2 = (n) => Math.round(n * 100) / 100;

// --- ACTO 1 y 2: leer y procesar ------------------------------
return $input.all().map((item) => {
  const order = item.json;

  // Cada línea se normaliza por separado: map dentro de map.
  const lines = (order.line_items ?? []).map((line) => {
    const quantity = toNumber(line.quantity);
    const unitPrice = toNumber(line.unit_price);

    return {
      ...line,
      sku: clean(line.sku).toUpperCase(),
      quantity: quantity,
      unit_price: unitPrice,
      line_total: round2(quantity * unitPrice),
    };
  });

  // --- ACTO 3: devolver (implícito en el map) -----------------
  return {
    json: {
      ...order,
      order_id: clean(order.order_id).toUpperCase(),
      customer_name: titleCase(order.customer_name),
      channel: clean(order.channel).toLowerCase(),
      shipping_city: titleCase(order.shipping_city) || 'Sin ciudad',
      line_items: lines,
      order_total: round2(lines.reduce((sum, l) => sum + l.line_total, 0)),
    },
  };
});

Qué esperar. Cada pedido sale con los mismos campos, normalizados, y con order_total calculado. Un pedido de whatsapp sin shipping_city sale con "Sin ciudad". Un pedido de rep_csv con quantity: "12" sale con quantity: 12 sin comillas. Y ORD-2045, con line_items: [], sale con la lista vacía y order_total: 0, sin error.

El map dentro del map es la estructura clave. El externo recorre pedidos, el interno recorre las líneas de cada uno. Es la forma 2 de la lección 4 —dos niveles de recorrido— escrita con métodos en vez de con bucles. Y como map construye listas nuevas, la lista original nunca se toca: el problema de la copia superficial de la lección 3 queda resuelto de raíz.

Fíjate en titleCase(order.shipping_city) || 'Sin ciudad'. Aquí || es correcto y ?? no serviría: titleCase de un valor ausente devuelve el texto vacío '', no null, y ?? no reacciona al texto vacío. Es la excepción que confirma la regla de la lección 3: ?? para datos crudos que pueden ser null; || cuando quieres tratar el vacío como ausencia y lo decidiste a propósito.

Y fíjate en round2 aplicado dos veces: a cada línea y al total. Redondear solo al final dejaría pasar los decimales binarios de las líneas; redondear en los dos niveles hace que el total sea exactamente la suma de lo que se muestra por línea, que es lo que espera cualquier persona que revise el pedido a mano.

Las cuatro utilidades de arriba son el patrón que quiero que te lleves. Se definen una vez, arriba del script, con nombres que dicen qué hacen, y el cuerpo del nodo queda limpio. Eso es exactamente lo que la lección 7 formaliza.

Por qué funciona: este nodo es el 80% del mini-proyecto de la lección 8. Si lo escribiste sin mirar la solución, ese mini-proyecto va a ser una tarde tranquila.

Ejercicio 3 — Decodifica el SKU. Los SKU de Cumbre codifican información: CF-ARA-500 significa familia CF (café), variedad ARA (arábica) y presentación de 500 gramos. Escribe un nodo Code que, para cada línea de cada pedido, agregue tres campos: product_family con el nombre completo de la familia según la tabla de abajo, variety con el segundo segmento, y grams con el tercero como número. Un SKU con formato inesperado debe producir product_family: 'Desconocida' y grams: 0, sin que el nodo falle.

CódigoFamilia
CFCafé
TE
Ver solución
// ============================================================
// Nodo: Code — "Decode SKU"
// Modo: Run Once for All Items
//
// ENTRADA:  pedidos de Cumbre con line_items
// SALIDA:   los mismos pedidos, con cada línea enriquecida con
//           product_family, variety y grams
// SUPUESTOS: el SKU tiene el formato FAMILIA-VARIEDAD-GRAMOS.
//            Un SKU con otro formato produce valores por defecto,
//            NO detiene el nodo.
// ============================================================

// Tabla de familias. Fuera del bucle: se define una sola vez.
const FAMILY_NAMES = {
  CF: 'Café',
  TE: 'Té',
};

const clean = (value) => (value ?? '').toString().trim();

return $input.all().map((item) => {
  const order = item.json;

  const lines = (order.line_items ?? []).map((line) => {
    // Normalizo antes de partir: mayúsculas y sin espacios.
    const sku = clean(line.sku).toUpperCase();
    const parts = sku.split('-');

    // Cada parte puede no existir: uso ?? con el valor por defecto.
    const familyCode = parts[0] ?? '';
    const variety = parts[1] ?? '';
    const gramsRaw = parts[2] ?? '';

    const grams = Number(gramsRaw);

    return {
      ...line,
      sku: sku,
      // Corchetes: la clave está en una variable.
      product_family: FAMILY_NAMES[familyCode] ?? 'Desconocida',
      variety: variety,
      grams: Number.isNaN(grams) ? 0 : grams,
    };
  });

  return { json: { ...order, line_items: lines } };
});

Qué esperar. Para la línea con sku: 'CF-ARA-500', los tres campos nuevos son product_family: "Café", variety: "ARA" y grams: 500 sin comillas. Para TE-CHM-100: "Té", "CHM", 100. Y para un SKU mal formado como "PROMO", el split('-') produce ['PROMO'], así que parts[1] y parts[2] son undefined, y la línea sale con product_family: "Desconocida", variety: "" y grams: 0. Sin error.

Tres piezas de esta solución que valen más allá del ejercicio.

La tabla como objetoFAMILY_NAMES— es la forma limpia de traducir códigos a nombres. La alternativa sería un if/else if de dos ramas hoy y de quince el año que viene. Y está definida fuera del map, porque no cambia entre líneas; ponerla adentro la reconstruiría en cada vuelta sin ninguna ganancia.

El ?? 'Desconocida' después de la lectura de la tabla cubre dos casos a la vez: un código que no está en la tabla y un SKU tan malformado que ni siquiera tiene primer segmento. Una sola línea, dos protecciones.

Y Number.isNaN(grams) ? 0 : grams es el patrón de conversión defensiva de esta lección escrito en línea. Si el tercer segmento fuera "MED" en vez de un número, Number('MED') daría NaN, y ese NaN contaminaría cualquier suma posterior de gramos. Aquí se detiene en el punto donde nace.

Una extensión si quieres practicar: agrega un campo total_grams en el nivel del pedido, con la suma de grams * quantity de todas sus líneas. Es un reduce sobre la lista que acabas de construir.

Por qué funciona: decodificar un identificador estructurado es una tarea de integración muy frecuente —los SKU, los números de factura y los códigos de sucursal casi siempre codifican información— y es exactamente el caso donde el catálogo visual de n8n se queda corto: no hay nodo que parta un texto, busque en una tabla y produzca tres campos derivados dentro de una lista anidada.

Resumen y siguiente paso

En esta lección viste que un método es una función que pertenece a un valor, que se invoca con punto y paréntesis, que no modifica el original —los textos son inmutables, así que devuelven un valor nuevo que hay que guardar— y que se encadena. Con la advertencia permanente de que null y undefined no tienen métodos, y las dos formas de protegerse: city?.trim() para un método suelto y (city ?? '').trim() cuando vas a encadenar varios.

Los template literals con acentos graves te dejan incrustar valores con ${...} y escribir saltos de línea reales, lo que hace innecesario pegar textos con + y con él toda la coerción que eso arrastra.

Del lado del texto tienes el juego completo: trim para los espacios de los extremos, toLowerCase y toUpperCase para igualar antes de comparar, replace contra replaceAll —la diferencia que rompe los números de más de tres cifras—, split y join para reestructurar, startsWith / endsWith / includes para buscar, y slice y padStart para cortar y rellenar.

Del lado del número, tres conversores con criterios distintos: Number() estricto, que devuelve NaN si sobra algo y es el que quieres por defecto; parseFloat() permisivo, que lee mientras puede y sirve cuando el dato trae unidades pegadas; y parseInt(), que corta decimales sin redondear y necesita siempre el 10 de la base. Para detectar el fracaso, Number.isNaN() y nunca === NaN, porque NaN no es igual ni a sí mismo. Y la función toNumber de cuatro líneas que resuelve todo el problema de una vez.

Para redondear, Math.round(x * 100) / 100 devuelve número; toFixed devuelve texto y por eso es solo para presentación, nunca para calcular ni para guardar. Y entendiste por qué 0.1 + 0.2 da 0.30000000000000004: los decimales se guardan en binario y algunos no tienen representación exacta, así que conviene redondear los totales antes de guardarlos y no comparar decimales con ===.

Antes de avanzar deberías poder: escribir de memoria la función toNumber defensiva; explicar en una frase por qué toFixed no sirve para el campo de un item; y decir qué produce '1,250,500'.replace(',', '').

Y ahora fíjate en algo. En el ejemplo trabajado de esta lección escribiste tres funciones —clean, titleCase, toNumber— y las pusiste arriba del script para que el cuerpo quedara legible. Eso no fue una casualidad estética: es la única forma de que un nodo Code de cincuenta líneas se pueda leer. La lección 7 formaliza esa práctica: qué es exactamente una función, cómo se le pasan datos y cómo devuelve resultados, la diferencia entre el return de una función y el return del script —que ya te mordió dos veces en esta guía—, y las estructuras de decisión: if, else if, el operador ternario y los operadores lógicos. Es la lección que convierte un script que funciona en un script que alguien más puede mantener.

Recursos

  • String — MDN — la referencia completa de los métodos de texto, con ejemplos de cada uno.
  • Template literals — MDN — los acentos graves, la interpolación con ${} y los saltos de línea.
  • Number — MDNNumber.isNaN, toFixed y el resto de las utilidades numéricas, con la nota sobre el tipo de retorno.
  • Math — MDNround, floor, ceil, max, min y el resto de las operaciones matemáticas.
  • Data transformation functions — n8n Docs — las funciones que n8n agrega sobre textos y números en las expresiones; conviene conocerlas para el Módulo 4, teniendo presente que dentro del nodo Code trabajas con JavaScript puro.
  • Code node cookbook — n8n Docs — ejemplos oficiales de transformación de datos donde la limpieza de textos aparece constantemente.