Inicio / Artículos

Truncación de cadenas segura para emojis en JavaScript con Intl.Segmenter

Por qué slice() y la truncación basada en propagación dañan los emojis y el texto con acentos, cómo se han roto productos reales al usarlas, y cómo cortar cadenas en los límites de grafemas en su lugar.

Editor: REACTAPP.TOP10 min de lectura2341 palabras

Fuentes: I Was Truncating Strings Wrong

Casi todos los repositorios de código que cuentan con un campo de biografía, una vista previa de comentarios o un resumen de notificaciones incluyen una pequeña función auxiliar que acorta el texto hasta cumplir un límite de caracteres. Por lo general, parece funcionar correctamente, supera todas las pruebas realizadas para ella, pero luego corrompe silenciosamente el texto de cualquier usuario cuya entrada contenga un emoji cerca del límite establecido. Este artículo explica qué es lo que JavaScript realmente cuenta cuando se llama a .length y .slice(), analiza errores reales en productos conocidos que comparten la misma causa raíz, y muestra cómo truncar el texto en los límites que realmente ve el usuario.

La función auxiliar que todos escriben

Aquí está la versión que aparece en innumerables repositorios de código y tutoriales: si la cadena es más larga que el límite, se corta y se agrega un punto y coma.

function truncate(str, max) {
  return str.length > max ? str.slice(0, max) + '…' : str;
}

Con texto en inglés funciona perfectamente, y ese es precisamente el motivo por el que ha sobrevivido tanto tiempo. El problema solo aparece cuando la entrada de alguien contiene, digamos, dos emojis y el límite cae justo entre ellos. En lugar de una cadena más corta, el perfil muestra un carácter de reemplazo: un diamante negro con un signo de interrogación. A primera vista no parece haber nada incorrecto en la función, y un conjunto de pruebas formado por cadenas ASCII nunca lo detectará.

Qué cuenta realmente .length

Las cadenas de JavaScript son secuencias de unidades de código UTF-16. Tanto .length como .slice() operan sobre esas unidades de 16 bits, no sobre caracteres en el sentido humano del término.

Para las letras latinas, los dígitos y la puntuación más común, una unidad de código equivale a un carácter, por lo que la distinción es invisible. Los caracteres fuera del Basic Multilingual Plane, que incluye a la mayoría de los emojis, necesitan dos unidades de código. Esa pareja se denomina par sustituto: un sustituto alto seguido de un sustituto bajo, que juntos codifican un punto de código.

'🐶'.length   // 2

Un perro en la pantalla, con una longitud de 2. Si .slice() corta entre las dos mitades, no se obtiene un emoji más pequeño. Se obtiene un sustituto solitario, que por sí solo no es un Unicode válido. Los navegadores suelen renderizarlo como el carácter de reemplazo, y los consumidores más estrictos generan errores.

Esa es la primera capa del problema. Existe una segunda, más sutil, que se explica a continuación. Primero, vale la pena señalar que esto no es un problema teórico.

El mismo error en cuatro productos reales

Las banderas de 14 caracteres de Twitter

Durante años, Twitter calculó aproximadamente la longitud de los tuits en función de los puntos de código. Un simple emoji de pulgar hacia arriba costaba 2 caracteres, añadir un tono de piel lo elevaba a 7, y algunas banderas llegaban a costar hasta 14. La razón es que las variantes de tono de piel y las banderas se componen de varios puntos de código combinados, y cada uno de ellos se contaba por separado.

En octubre de 2018, Twitter cambió la regla para que cada emoji contara como 2 caracteres, independientemente de cuántos puntos de código contenga. Esa ponderación sigue formando parte de las reglas de conteo en su biblioteca de código abierto twitter-text. Consulte la cobertura del cambio en el conteo de emojis y la documentación sobre el conteo de caracteres en X.

Este caso es útil porque separa dos preguntas que son fáciles de confundir:

  • ¿Cuántos caracteres visibles ve el usuario?
  • ¿Cómo debería cobrar o limitar el producto los diferentes tipos de texto?

La respuesta de Twitter a la segunda pregunta es una política de producto. No es lo mismo que medir lo que aparece en la pantalla.

Yjs y lib0: un emoji dividido que detuvo silenciosamente la sincronización

Yjs es una biblioteca ampliamente utilizada para la edición colaborativa, y TipTap se basa en ella. Yjs depende de un pequeño paquete de utilidades, lib0, cuyo complemento splice utilizaba internamente a .slice().

Cuando una edición colaborativa ocurría entre las dos mitades de un emoji, lib0 generaba una cadena que contenía un sustituto solitario. Esa cadena era posteriormente pasada a encodeURIComponent, el cual lanza un URIError al encontrarse con un sustituto aislado. Nada capturó la excepción, por lo que la sincronización simplemente se detuvo. Existe una descripción detallada del error relacionado con los sustitutos de lib0.

Lo que hace que este problema sea especialmente grave es que era invisible. El editor seguía funcionando localmente. Los usuarios veían su propio texto y seguían escribiendo, sin ninguna indicación de que sus cambios ya no llegaban a nadie más. La solución propuesta detecta un carácter sobrante en el extremo de la cadena procesada y lo reemplaza por el carácter de sustitución.

React Native TextInput y maxLength

TextInput de React Native ofrece una propiedad maxLength, y desde 2016 se han presentado quejas sobre su comportamiento con emojis, como por ejemplo los problemas 10964 y 28774.

Los informes siguen el mismo patrón: se establece un maxLength, se escribe hasta ese límite y luego se agrega un emoji. En lugar de rechazar el emoji o aceptarlo completo, la entrada lo corta en un límite entre unidades de código. Los usuarios han relatado ver un emoji diferente al que escribieron, un cuadro con una “A”, o, en iOS, que todo el campo se vacíe.

Si su aplicación tiene un campo de nombre de usuario o biografía con longitud máxima y nunca ha escrito un emoji de tono de piel justo en ese límite, vale la pena probarlo. Un conjunto rápido de pruebas manuales cubre los casos principales:

Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨‍👩‍👧‍👦

Esas cuatro líneas contienen un emoji simple, uno con modificador de tono de piel, una bandera y una secuencia familiar unidos por conectores sin ancho. Si su código los trata a cada uno como un único carácter, probablemente esté contando algo distinto.

truncateString de Gemini CLI

En marzo de 2026, se descubrió que la CLI Gemini de Google contaba con una función auxiliar truncateString que realizaba el corte en la posición maxLength. Cuando se daba un string que terminaba con un emoji de cara llorando y el límite de corte estaba dentro de dicho string, la función auxiliar devolvía un único \uD83D, es decir, el sustituto alto por sí solo (problema 22753).

Eso es peor que un simple problema estético. Un sustituto aislado hace que el string sea inválido en formato UTF-16, y pasarlo a un codificador JSON o a una API puede causar fallos en etapas posteriores. La solución es la misma a la que llega eventualmente todo equipo: nunca permitir que el punto de corte quede dentro de un par de sustitutos.

Cuatro productos, cuatro años diferentes, una causa raíz. Los emojis son solo el síntoma más visible de un problema más amplio: el texto no se divide donde los índices de JavaScript sugieren que lo hace.

Tres niveles de “carácter”

Es útil considerar las estrategias de truncamiento como operando en tres niveles, cada uno que cuenta con una unidad diferente.

Nivel 1: unidades de código con .length y .slice()

Esto es a lo que casi todos recurren primero, ya que se trata de la API más obvia. También es la única de las tres que puede dividir un par sustituto. Si su límite es 20 y la 20ª unidad de código se encuentra en medio de un emoji, el resultado es texto corrupto en lugar de una cadena más corta. Los errores de Yjs, Gemini CLI y React Native ocurren todos en este nivel.

La diferencia conceptual es fácil de explicar: JavaScript indexa unidades de código UTF-16; los desarrolladores tienden a creer que están indexando caracteres. Para la mayoría de los textos en inglés, ambos coinciden, y esa coincidencia oculta el problema.

Nivel 2: puntos de código al distribuir la cadena

Una solución común es convertir la cadena en un array, el cual itera por punto de código en lugar de por unidad de código.

const codePoints = [...str];

Esto mantiene juntos a los pares sustitutos, de modo que un solo emoji de perro permanece intacto. Es una mejora real, y muchos desarrolladores se detienen aquí creyendo que el problema está resuelto.

Pero no lo está, porque muchas cosas que una persona percibe como un único carácter están compuestas por varios puntos de código:

  • Un emoji familiar está formado por varios emojis de personas unidos mediante caracteres de unión sin ancho.
  • La bandera de un país combina dos símbolos indicadores regionales, uno por cada letra del código del país.
  • Una variante de tono de piel agrega un modificador Fitzpatrick al emoji base.
  • Una letra acentuada puede ser una letra base seguida de un signo combinante.

Truncar por punto de código aún puede interrumpirse a mitad de una de estas secuencias. Aunque se evita el UTF-16 inválido, aún es posible mostrar al usuario solo la mitad de un símbolo, una letra indicadora regional aislada o una mano sin su tono de piel correspondiente.

[...'👋🏽']
// more than one item

El array contiene más de un elemento, pero el lector solo ve una mano agitando. Los puntos de código y los caracteres percibidos por el usuario son cosas diferentes, y esa diferencia es precisamente donde se esconden los errores restantes.

Nivel 3: grupos de grafemas con Intl.Segmenter

La unidad que corresponde a lo que ve el lector es el grupo de grafemas, y la forma integrada de encontrar los límites entre ellos es mediante Intl.Segmenter con la opción granularity: 'grapheme'. La función auxiliar a continuación segmenta la cadena, recopila el texto de cada segmento y trunca el array resultante en lugar de la cadena original.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateGraphemes(str, max) {
  const graphemes = Array.from(segmenter.segment(str), s => s.segment);
  return graphemes.length > max
    ? graphemes.slice(0, max).join('') + '…'
    : str;
}

truncateGraphemes('Loving my new puppy 👨‍👩‍👧‍👦🐶🐶🐶!', 22);
// → "Loving my new puppy 👨‍👩‍👧‍👦🐶…"

Observe lo que sucede en la llamada de ejemplo. El emoji de familia cuenta como un único grafema y se mantiene completo; el primer perro cabe dentro del límite de 22 caracteres, y la división ocurre de manera limpia antes del segundo perro. Ningún segmento se divide, ya que la función nunca accede directamente a la cadena.

La segmentación de grafemas maneja emojis con múltiples puntos de código, combinaciones de acentos y escrituras como el tailandés o el devanagari, donde un carácter visible suele corresponder a varios puntos de código. Intl.Segmenter forma parte de la especificación de internacionalización ECMA-402. Chrome y Safari lo incluyeron en 2020 y 2021 respectivamente, Firefox lo añadió en abril de 2024, y Node.js lo ha tenido desde la versión 16; por lo tanto, en los entornos actuales no existe costo alguno por usar un polyfill que justifique evitarlo.

Es tentador recurrir en su lugar a una biblioteca específica para emojis. Eso solo trata el síntoma visible. Intl.Segmenter aborda el problema real, que es que los límites del texto están definidos por las reglas de Unicode, no por los índices de un array.

Detalles que vale la pena acertar

Al adoptar el enfoque basado en grafemas, hay algunas mejoras que merecen consideración:

  • Los puntos suspensivos son un carácter adicional. Si el límite es una restricción estricta, como una columna de base de datos o un campo de API, reserve un grafema para ellos o omítalos.
  • El ejemplo segmenta toda la cadena antes de truncarla. Para entradas muy largas, puede iterar sobre los segmentos y detenerse una vez se alcance el límite, lo que evita crear un array grande.
  • El recuento de grafemas no corresponde a la anchura que se muestra. Un carácter CJK o un emoji puede ocupar dos columnas terminales, por lo que una interfaz de línea de comandos que alinea columnas necesita un cálculo de anchura además de la segmentación.
  • Para detectar o reparar cadenas que ya contienen sustitutos solitarios, los entornos de ejecución modernos ofrecen String.prototype.isWellFormed() y toWellFormed(), los cuales son útiles como medida de protección antes de llamar a encodeURIComponent o enviar datos a una API.
  • Dónde es importante el truncamiento sensible a grafemas

    No todas las cadenas necesitan esto. Para líneas de registro internas o identificadores exclusivamente en ASCII, la segmentación por grafemas representa un trabajo innecesario.

    Mire dónde ocurrieron los errores anteriores: tweets, documentos compartidos, campos de formulario y salida de la CLI. Cada uno es un lugar donde el texto generado por el usuario recibe un límite y luego se muestra de nuevo a las personas. Esos son los lugares donde esa línea adicional de código resulta útil. También son los lugares donde el error nunca aparece en las pruebas locales, ya que los datos de prueba son en ASCII, pero sí aparece en producción precisamente en los perfiles y mensajes que la gente consulta con más frecuencia.

    Casos típicos para auditar:

    1. Campos de biografía y perfil. Un límite de “20 caracteres” significa 20 caracteres visibles para el usuario, no 20 unidades de código UTF-16.
    2. Vistas previas de comentarios. Una vista previa que termina con medio emoji parece defectuosa, aunque el comentario completo esté bien.
  • Notificaciones. Un carácter corrupto en una notificación push es muy visible y hace que todo el producto parezca poco fiable.
  • Editores colaborativos. El texto se divide y combina constantemente entre usuarios, por lo que el manejo de los límites afecta directamente la integridad de los datos.
  • Salida de la CLI. Las herramientas de terminal muestran cada vez más contenido real del usuario, por lo que el manejo de Unicode ya no es solo un problema del navegador.
  • La regla práctica es sencilla. Dondequiera que una cadena pueda contener texto Unicode visible para el usuario, reemplace .slice(0, n) por un método de truncamiento basado en grafemas y prefiera Intl.Segmenter en lugar del truco de dispersión. La dispersión protege a las parejas sustitutas; la segmentación corresponde a lo que un ser humano ve como un único carácter.

    Preguntas frecuentes

    ¿Funciona Intl.Segmenter en Node.js? Sí. Está disponible sin necesidad de ninguna flag desde Node.js 16. Si aún utilizas una versión más antigua, confírmalo antes de asumir que necesitas un polyfill.

    ¿Es más lento que slice()? Un poco, porque realiza una segmentación real en lugar de un corte por índice. En el caso de un campo de biografía que se trunca una vez por renderizado, la diferencia no será perceptible. Si estás truncando miles de cadenas en un bucle intensivo, realiza pruebas de rendimiento antes y después.

    ¿Es importante esto más allá de los emojis? Sí. La combinación de acentos y muchos sistemas de escritura del sur y sudeste asiático, incluidos el tailandés, el devanagari, el tamil y el bengalí, no asigna un punto de código a un carácter visible único. Intl.Segmenter los maneja correctamente; tanto .slice() como la expansión de arrays aún pueden cometer errores al tratarlos.

    ¿Debería usar twitter-text en su lugar? Solo si desea seguir la política de conteo específica de Twitter, donde cada emoji cuesta 2 y las URLs cuestan 23. Se trata de una regla comercial, no de una solución para corregir errores. Para contar lo que ve el usuario, Intl.Segmenter es la herramienta estándar incorporada.

    Conclusión

    El enfoque basado en la unidad de código .slice() ha sido la solución por defecto en los tutoriales sobre “cómo truncar una cadena” durante mucho tiempo, y ha contribuido a un error en el cálculo del límite de caracteres en Twitter, a fallos silenciosos de sincronización en editores colaborativos y a problemas persistentes en React Native. La solución no es complicada: decida qué unidad realmente representa su límite y, cuando se trate de lo que ven las personas, segmente la cadena por grupos de grafemas antes de realizar el corte. Si su código cuenta con una función auxiliar truncate, le tomará unos minutos averiguar en cual de los tres niveles opera, y una cadena de prueba de cuatro líneas para demostrarlo.

    Lecturas relacionadas