Spraxium logoSpraxium

Pluralização

Pluralização não é um simples teste entre singular e plural. É uma regra matemática que varia bastante de idioma para idioma. Entenda como o motor escolhe a forma plural correta e por que o sistema de categorias do CLDR é o modelo certo.

Por que pluralização não é uma condição simples

A primeira reação ao lidar com texto no plural costuma ser escrever algo assim:

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

Isso funciona para inglês. Falha para polonês, onde “2 items” vira 2 elementy, “5 items” vira 5 elementów e “21 items” volta para 21 elementów. Falha para árabe, que tem seis formas de plural. Falha para russo, que distingue entre um item, dois a quatro itens e cinco ou mais itens. Falha até para zero. Em francês, zero usa a forma singular, 0 élément, enquanto em inglês zero usa a forma plural, 0 items.

O ponto central é que a escolha do plural depende de duas coisas: o idioma e o número. Os principais idiomas têm regras documentadas pelo projeto Unicode CLDR, Common Locale Data Repository, e essas regras mapeiam qualquer inteiro para uma entre seis categorias nomeadas: zero, one, two, few, many e other. Cada idioma usa apenas um subconjunto dessas categorias, e other é sempre o fallback obrigatório.

O motor de pluralização do Spraxium delega a escolha da categoria para Intl.PluralRules, a implementação do V8 embutida no Node.js que segue as regras do CLDR, e depois procura a variante correspondente dentro do mapa achatado de traduções.

Convenção de nomes para variantes plurais

As chaves de plural seguem o padrão baseKey_category. A base é a mesma chave que você usaria com t(). A categoria é um dos seis nomes do CLDR, anexada com underscore.

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"
  }
}

Depois do flattening usando o nome da referência commands e o nome do arquivo items, as chaves completas ficam assim:

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

O motor constrói a chave de busca adicionando _ mais a categoria resolvida à base que você passou para tp().

As seis categorias e quando cada uma se aplica

CategoriaAplica-se aIdiomas e regras de exemplo
zeroForma específica para zeroÁrabe (0), galês (0)
oneSingularInglês (1), espanhol (1), português (1)
twoForma dualÁrabe (2), galês (2), hebraico (2)
fewPaucal ou plural pequeno

Russo (-), polonês (2, 3, 4 sem terminar em teen), árabe (-0)

manyPlural amplo ou irregularRusso (5+, 1-4), polonês (5+), árabe (1-9)
otherPlural geral, fallback obrigatórioInglês (0, 2+), chinês (todos), japonês (todos)

Idiomas que não têm forma específica para zero, como a maior parte das línguas da Europa Ocidental, mapeiam zero para other. Isso significa que 0 items em inglês cai em _other, e por isso a variante _zero do exemplo acima usa a frase “No items”. Se você quiser uma formulação especial para zero, precisa fornecer _zero explicitamente.

Algoritmo de resolução

Determinação da categoria

Verifique se existe uma regra plural customizada registrada para o locale solicitado no campo de configuração pluralRules. Se existir, execute essa função com o número e use a categoria retornada. Se não existir, delegue a Intl.PluralRules(locale).select(count), que implementa as regras do CLDR embutidas no Node.js e devolve uma entre as seis categorias nomeadas.

Construção da chave candidata

Monte a chave de busca concatenando a base, um underscore e a categoria resolvida. Por exemplo, se a base for commands.items e a categoria for few, a chave candidata será commands.items_few. Essa é a primeira chave que o registry tentará encontrar ao procurar a forma plural traduzida.

Busca no locale principal

Procure a chave candidata no mapa achatado do locale solicitado chamando LocaleRegistry.get(locale). O registry mantém um Map<string, string> por locale carregado e retorna a string imediatamente se a chave existir. Se a chave for encontrada aqui, a execução pula direto para a interpolação.

Fallback para a categoria _other_

Se a primeira busca não encontrar nada e a categoria resolvida ainda não for other, construa uma segunda chave usando _other como sufixo e procure de novo no mesmo mapa de locale. Esse fallback é o que permite definir apenas _one e _other para a maioria dos idiomas sem precisar cobrir todas as categorias possíveis do CLDR.

Fallback para o locale padrão

Se nem a chave específica nem a _other existirem no locale solicitado, e se esse locale ainda não for o locale padrão configurado, repita as etapas anteriores no mapa achatado do locale padrão. Isso permite traduções parciais: um idioma pode cobrir só parte das chaves sem quebrar a resposta.

Retorno seguro da chave bruta

Se todas as buscas falharem, devolva a string da base exatamente como foi passada, em vez de lançar erro. O motor de pluralização nunca explode em tempo de request por falta de chave. Assim, uma tradução ausente aparece como texto visível na UI sem derrubar o bot nem deixar a interação sem resposta.

Interpolação de variáveis

Mescle o número recebido ao objeto de variáveis usando a chave count, para que ele fique disponível como {{ count }} na string final sem trabalho extra do caller. Depois substitua cada placeholder {{ varName }} usando os valores do objeto mesclado. A string totalmente interpolada é a que retorna para quem chamou tp() ou tpUser().

Fornecendo traduções para idiomas com regra plural complexa

O russo precisa de quatro entradas para ficar correto. Se você pular _few ou _many, o motor cai em _other nesses casos, o que pode gerar uma frase gramaticalmente errada.

locales/commands/ru/items.json
{
  "items": {
    "_one": "{{count}} ?@54<5B",
    "_few": "{{count}} ?@54<5B0",
    "_many": "{{count}} ?@54<5B>2",
    "_other": "{{count}} ?@54<5B0"
  }
}
test the russian plural
i18n.tp('commands.items', 'ru', 1); // '1 ?@54<5B'    (one)
i18n.tp('commands.items', 'ru', 2); // '2 ?@54<5B0'   (few)
i18n.tp('commands.items', 'ru', 5); // '5 ?@54<5B>2'  (many)
i18n.tp('commands.items', 'ru', 11); // '11 ?@54<5B>2' (many, teen numbers are always many in Russian)
i18n.tp('commands.items', 'ru', 21); // '21 ?@54<5B'   (one, 21 ends in 1)

Usando pluralização em 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);

  }
}

Regras plurais customizadas para casos de borda

Quando Intl.PluralRules devolve um resultado incorreto para um identificador específico de locale, algo raro, mas possível em variantes customizadas ou em combinações ainda não cobertas pela versão atual do CLDR embarcada no Node.js, você pode sobrescrever a regra no momento da configuração:

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';
    },
  },
});

As regras customizadas recebem o número e devem devolver uma entre as seis categorias possíveis. Elas rodam antes de Intl.PluralRules e o substituem completamente para o locale especificado. Locales sem regra customizada continuam usando Intl.PluralRules normalmente.

A variável

O valor passado para tp() ou tpUser() já fica disponível automaticamente como {{count}} na string traduzida. Você não precisa enviá-lo manualmente no objeto de vars. O motor faz essa mescla sozinho para que qualquer variante plural possa referenciar a contagem sem boilerplate extra.

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

Se você passar variáveis adicionais, elas são mescladas com count, e todas ficam disponíveis como placeholders {{varName}}:

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'