Spraxium logoSpraxium

Pluralización

La pluralización no es una simple comprobación singular-vs-plural. Es una regla matemática que varía significativamente entre idiomas. Entiende cómo el motor selecciona la forma plural correcta y por qué el sistema de categorías CLDR es el modelo adecuado.

Por qué la pluralización no es una condición simple

La primera intuición al manejar texto plural es escribir algo como:

naive-plural.ts
const message = count === 1 ? 'One item' : `${count} items`;

Esto funciona en inglés. Falla en polaco, donde "2 items" es 2 elementy, "5 items" es 5 elementów, y "21 items" vuelve a ser 21 elementów. Falla en árabe, que tiene seis formas de plural. Falla en ruso, que distingue entre un ítem, dos-a-cuatro ítems y cinco-o-más ítems. Falla para cero. En francés, cero usa la forma singular (0 élément), mientras que en inglés cero usa la forma plural (0 items).

La idea central es que la selección plural es una función tanto del idioma como del número. Cada idioma importante tiene reglas documentadas en el proyecto Unicode CLDR (Common Locale Data Repository), y esas reglas mapean cualquier entero a una de cinco categorías con nombre: zero, one, two, few, many y other. Cada idioma usa un subconjunto de estas categorías, y other siempre es la categoría de cierre obligatoria.

El motor de pluralización de Spraxium delega la selección de categorías a Intl.PluralRules, el motor V8 integrado en Node.js que implementa las reglas CLDR, y luego busca la variante con key correspondiente en el map plano de traducciones.

Convención de nombres para variantes plurales

Las keys plurales siguen el patrón baseKey_category. La base key es la misma key que usarías para t(). La categoría es uno de los seis nombres CLDR anexado con un separador de guion bajo.

locales/commands/en-US/items.json
{
  'items': {
    '_zero': 'No items in your inventory',
    '_one': 'One item in your inventory',
    '_other': '{{count}} items in your inventory'
  }
}

Después de aplanar con el nombre de referencia commands y nombre de archivo items, las keys completas quedan:

  • commands.items._zero
  • commands.items._one
  • commands.items._other

El motor construye la key de búsqueda anexando _ más la categoría resuelta a la base key que pases a tp().

Las seis categorías y cuándo aplica cada una

CategoríaAplica aIdiomas de ejemplo y reglas
zeroForma específica para ceroÁrabe (0), galés (0)
oneSingularInglés (1), español (1), portugués (1)
twoForma dualÁrabe (2), galés (2), hebreo (2)
fewPaucal / plural pequeño

Ruso (-), polaco (2, 3, 4 que no terminan en teen), árabe (-0)

manyPlural grande o irregularRuso (5+, 1-4), polaco (5+), árabe (1-9)
otherPlural general, categoría de cierre obligatoriaInglés (0, 2+), chino (todos), japonés (todos)

Los idiomas que no tienen una forma de cero dedicada (la mayoría de idiomas de Europa occidental) mapean cero a other. Esto significa que en inglés 0 items se resuelve a _other, por eso la variante de cero en el ejemplo de arriba dice "No items.". Necesitas proporcionar una entrada _zero específicamente para esa redacción.

Algoritmo de resolución

Determinación de categoría

Verifica si se registró una regla plural personalizada para el locale solicitado en el campo de configuración pluralRules. Si existe una regla personalizada, ejecuta esa función con el count numérico y usa la string de categoría que devuelva. Si no hay regla personalizada registrada, delega en Intl.PluralRules(locale).select(count), que implementa las reglas Unicode CLDR integradas en Node.js y devuelve una de las seis strings de categoría con nombre.

Construcción de key candidata

Construye la key de búsqueda concatenando la base key, un carácter de guion bajo y la categoría resuelta. Por ejemplo, una base key commands.items con categoría resuelta few produce la key candidata commands.items_few. Esta string construida es la primera key que el registro buscará al intentar encontrar la forma plural traducida.

Búsqueda primaria en el locale

Busca la key candidata en el map plano del locale solicitado llamando a LocaleRegistry.get(locale). El registro mantiene un Map<string, string> por locale cargado y devuelve la string traducida al instante si la key existe. Si la key se encuentra en este paso, la ejecución salta directamente a la interpolación.

Fallback a la categoría _other

Si la búsqueda primaria no devolvió nada y la categoría resuelta no es ya other, construye una segunda key candidata usando _other como sufijo y busca de nuevo en el mismo map del locale. Este fallback es lo que permite proporcionar solo _one y _other para la mayoría de idiomas sin necesidad de definir todas las categorías CLDR posibles, ya que other es el cierre obligatorio.

Fallback al locale predeterminado

Si ni la key de categoría ni la key _other se encontraron en el locale solicitado, y el locale solicitado no es ya el locale predeterminado configurado, repite los pasos tres y cuatro contra el map plano del locale predeterminado. Este nivel permite traducciones parciales: un idioma que solo cubre algunas keys producirá silenciosamente el texto del locale predeterminado para las entradas faltantes en lugar de romper la respuesta.

Retorno seguro de la key cruda

Si todas las búsquedas anteriores fallan, devuelve la base key cruda tal cual en lugar de lanzar un error. El motor de pluralización nunca lanza una excepción de key faltante en tiempo de petición, así que una traducción faltante aparece como texto visible de placeholder en la UI sin tumbar el bot ni dejar la interacción sin respuesta. Este comportamiento hace seguro publicar traducciones parciales en producción mientras el trabajo en los locales restantes sigue en progreso.

Interpolación de variables

Fusiona el count numérico en el objeto vars bajo la key count, dejándolo disponible como {{ count }} en la string traducida sin trabajo extra del llamador. Reemplaza cada placeholder {{ varName }} en la string resuelta con su valor correspondiente del objeto vars fusionado. La string totalmente interpolada se devuelve al handler que llamó a tp() o tpUser().

Proveer traducciones para un idioma con reglas plurales complejas

El ruso requiere cuatro entradas para ser correcto. Si omites _few o _many, el motor cae hacia _other para esos valores, lo que puede ser gramaticalmente incorrecto para tu string concreta.

locales/commands/ru/items.json
{
  'items': {
    '_one': '{{count}} предмет',
    '_few': '{{count}} предмета',
    '_many': '{{count}} предметов',
    '_other': '{{count}} предмета'
  }
}
test the russian plural
i18n.tp('commands.items', 'ru', 1); // '1 предмет'    (one)
i18n.tp('commands.items', 'ru', 2); // '2 предмета'   (few)
i18n.tp('commands.items', 'ru', 5); // '5 предметов'  (many)
i18n.tp('commands.items', 'ru', 11); // '11 предметов' (many, teen numbers are always many in Russian)
i18n.tp('commands.items', 'ru', 21); // '21 предмет'   (one, 21 ends in 1)

Usar pluralización en handlers

import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { InventoryCommand } from '../commands/inventory.command';

@SlashCommandHandler(InventoryCommand)
export class InventoryHandler {
  constructor(private readonly i18n: I18nService) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const count = await getInventoryCount(interaction.user.id);
    const locale = interaction.locale;

    const message = this.i18n.tp('commands.items', locale, count, { count });
    await interaction.reply(message);

  }
}

Reglas plurales personalizadas para casos límite

Cuando Intl.PluralRules da resultados incorrectos para un identificador de locale específico (esto es poco común, pero puede ocurrir con strings de locale personalizadas o variantes de locale no cubiertas por los datos CLDR de la versión actual de Node.js), puedes sobrescribir la regla en tiempo de configuración:

config/i18n.config.ts
import { defineI18n } from '@spraxium/i18n';

export const i18nConfig = defineI18n({
  defaultLocale: 'en-US',
  references: [{ name: 'commands', path: './locales/commands' }],
  pluralRules: {
    'pt-BR': (count) => (count === 1 ? 'one' : 'other'),
    ar: (count) => {
      if (count === 0) return 'zero';
      if (count === 1) return 'one';
      if (count === 2) return 'two';
      if (count % 100 >= 3 && count % 100 <= 10) return 'few';
      if (count % 100 >= 11 && count % 100 <= 99) return 'many';
      return 'other';
    },
  },
});

Las reglas personalizadas reciben el count numérico y deben devolver una de las seis strings de categoría. Se ejecutan antes de Intl.PluralRules y la sobrescriben por completo para el locale especificado. Los locales sin una regla personalizada siguen usando Intl.PluralRules con normalidad.

La variable {{count}}

El count pasado a tp() o tpUser() queda disponible automáticamente como {{count}} en la string traducida. No necesitas pasarlo explícitamente en el objeto vars. El motor lo fusiona automáticamente para que cada variante plural pueda referenciar el count sin boilerplate extra.

items translation showing count use
{
  'items': {
    '_one': 'You have one item remaining',
    '_other': 'You have {{count}} items remaining'
  }
}

Si pasas vars adicionales, se fusionan con count y todas quedan disponibles como {{varName}} placeholders:

using extra vars with count
i18n.tp('commands.items', 'en-US', 5, { maxCapacity: 10 });
// Translation: 'You have {{count}} of {{maxCapacity}} item slots used'
// Result:      'You have 5 of 10 item slots used'