Skip to content

Instantly share code, notes, and snippets.

@ohbob
Created October 23, 2025 23:10
Show Gist options
  • Select an option

  • Save ohbob/c5d6bbbbf0aa142f5ff668fed082d1c2 to your computer and use it in GitHub Desktop.

Select an option

Save ohbob/c5d6bbbbf0aa142f5ff668fed082d1c2 to your computer and use it in GitHub Desktop.
number_to_words.js
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
type Gender = "masculine" | "feminine" | "neutral";
interface NumberWords {
ones: string[];
tens: string[];
}
interface ScaleDefinition {
singular: string;
plural: string;
}
interface HundredsConfig {
pattern: "prefix" | "suffix"; // "one hundred" vs "simts"
word: string;
pluralWord?: string; // e.g., "simti" for Latvian plural
useNumberPrefix: (hundreds: number, isScaleMultiplier: boolean) => boolean;
}
interface LanguageConfig {
code: string;
name: string;
numberWords: {
masculine?: NumberWords;
feminine?: NumberWords;
neutral: NumberWords;
};
scales: ScaleDefinition[];
hundreds: HundredsConfig;
zero: string;
connector: string; // "and", "un", "y", etc.
defaultCurrency: string;
defaultCentName: string;
genderForCents: Gender;
// Currency translations (universal code → localized name)
currencyTranslations: Record<string, { singular: string; plural: string }>;
centTranslations: Record<string, { singular: string; plural: string }>;
// Pluralization functions
pluralizeCurrency: (amount: number, currencyName: string) => string;
pluralizeCents: (amount: number, centName: string) => string;
}
interface ConversionOptions {
language?: string;
currency?: string;
centName?: string;
gender?: Gender;
}
// ============================================================================
// LANGUAGE CONFIGURATIONS
// ============================================================================
const ENGLISH: LanguageConfig = {
code: "en",
name: "English",
numberWords: {
neutral: {
ones: [
"", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
"seventeen", "eighteen", "nineteen"
],
tens: ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]
}
},
scales: [
{ singular: "", plural: "" },
{ singular: "thousand", plural: "thousand" },
{ singular: "million", plural: "million" },
{ singular: "billion", plural: "billion" }
],
hundreds: {
pattern: "prefix",
word: "hundred",
useNumberPrefix: () => true
},
zero: "zero",
connector: "and",
defaultCurrency: "euro",
defaultCentName: "cent",
genderForCents: "neutral",
currencyTranslations: {
"euro": { singular: "euro", plural: "euros" },
"dollar": { singular: "dollar", plural: "dollars" },
"pound": { singular: "pound", plural: "pounds" },
"yen": { singular: "yen", plural: "yens" }
},
centTranslations: {
"cent": { singular: "cent", plural: "cents" },
"penny": { singular: "penny", plural: "pennies" },
"sen": { singular: "sen", plural: "sen" }
},
pluralizeCurrency: (n, name) => {
if (n === 1) return name;
// Handle irregular plurals
if (name.endsWith('y')) return name.slice(0, -1) + 'ies'; // penny → pennies
return `${name}s`;
},
pluralizeCents: (n, name) => {
if (n === 1) return name;
// Handle irregular plurals
if (name.endsWith('y')) return name.slice(0, -1) + 'ies'; // penny → pennies
return `${name}s`;
}
};
const LATVIAN: LanguageConfig = {
code: "lv",
name: "Latvian",
numberWords: {
masculine: {
ones: [
"", "viens", "divi", "trīs", "četri", "pieci", "seši", "septiņi", "astoņi", "deviņi",
"desmit", "vienpadsmit", "divpadsmit", "trīspadsmit", "četrpadsmit", "piecpadsmit", "sešpadsmit",
"septiņpadsmit", "astoņpadsmit", "deviņpadsmit"
],
tens: ["", "", "divdesmit", "trīsdesmit", "četrdesmit", "piecdesmit", "sešdesmit", "septiņdesmit", "astoņdesmit", "deviņdesmit"]
},
feminine: {
ones: [
"", "viena", "divas", "trīs", "četras", "piecas", "sešas", "septiņas", "astoņas", "deviņas",
"desmit", "vienpadsmit", "divpadsmit", "trīspadsmit", "četrpadsmit", "piecpadsmit", "sešpadsmit",
"septiņpadsmit", "astoņpadsmit", "deviņpadsmit"
],
tens: ["", "", "divdesmit", "trīsdesmit", "četrdesmit", "piecdesmit", "sešdesmit", "septiņdesmit", "astoņdesmit", "deviņdesmit"]
},
neutral: { ones: [], tens: [] } // Not used
},
scales: [
{ singular: "", plural: "" },
{ singular: "tūkstotis", plural: "tūkstoši" },
{ singular: "miljons", plural: "miljoni" },
{ singular: "miljards", plural: "miljardi" }
],
hundreds: {
pattern: "suffix",
word: "simts",
pluralWord: "simti",
useNumberPrefix: (n, isScaleMultiplier) => !(n === 1 && isScaleMultiplier)
},
zero: "nulle",
connector: "un",
defaultCurrency: "euro",
defaultCentName: "cent",
genderForCents: "masculine",
currencyTranslations: {
"euro": { singular: "eiro", plural: "eiro" },
"dollar": { singular: "dolārs", plural: "dolāri" },
"pound": { singular: "mārciņa", plural: "mārciņas" },
"yen": { singular: "jēna", plural: "jēnas" }
},
centTranslations: {
"cent": { singular: "cents", plural: "centi" },
"penny": { singular: "penss", plural: "pensi" },
"sen": { singular: "sens", plural: "seni" }
},
pluralizeCurrency: (n, name) => {
// Latvian pluralization rules for currency
const lastDigit = n % 10;
const lastTwoDigits = n % 100;
// Use singular for numbers ending in 1 (but not 11)
if (lastDigit === 1 && lastTwoDigits !== 11) {
return name; // e.g., "1 dolārs", "21 dolārs"
}
// Plural for everything else
return name; // Already handled by translation lookup
},
pluralizeCents: (n, name) => {
const lastDigit = n % 10;
const lastTwoDigits = n % 100;
return lastDigit === 1 && lastTwoDigits !== 11 ? "cents" : "centi";
}
};
// ============================================================================
// LANGUAGE REGISTRY
// ============================================================================
class LanguageRegistry {
private languages: Map<string, LanguageConfig> = new Map();
constructor() {
this.register(ENGLISH);
this.register(LATVIAN);
}
register(config: LanguageConfig): void {
this.languages.set(config.code, config);
}
get(code: string): LanguageConfig {
const config = this.languages.get(code);
if (!config) {
throw new Error(
`Language '${code}' is not supported. Available: ${this.getSupportedLanguages().join(", ")}`
);
}
return config;
}
getSupportedLanguages(): string[] {
return Array.from(this.languages.keys());
}
has(code: string): boolean {
return this.languages.has(code);
}
}
const registry = new LanguageRegistry();
// ============================================================================
// CONVERSION ENGINE
// ============================================================================
class NumberToWordsConverter {
private config: LanguageConfig;
constructor(languageCode: string) {
this.config = registry.get(languageCode);
}
private getNumberWords(gender: Gender): NumberWords {
if (gender === "masculine" && this.config.numberWords.masculine) {
return this.config.numberWords.masculine;
}
if (gender === "feminine" && this.config.numberWords.feminine) {
return this.config.numberWords.feminine;
}
return this.config.numberWords.neutral;
}
private getScaleWord(chunk: number, scaleIndex: number): string {
if (scaleIndex === 0) return "";
const scale = this.config.scales[scaleIndex];
return chunk === 1 ? scale.singular : scale.plural;
}
private convertHundreds(n: number, gender: Gender, isScaleMultiplier: boolean): string {
if (n === 0) return "";
const { ones, tens } = this.getNumberWords(gender);
const parts: string[] = [];
const hundreds = Math.floor(n / 100);
const remainder = n % 100;
// Handle hundreds
if (hundreds > 0) {
const usePrefix = this.config.hundreds.useNumberPrefix(hundreds, isScaleMultiplier && remainder === 0);
const hundredsWord = this.config.hundreds.word;
if (this.config.hundreds.pattern === "prefix") {
// English: "one hundred", "two hundred"
parts.push(`${ones[hundreds]} ${hundredsWord}`);
} else {
// Latvian: "viens simts", "divi simti", or just "simts"
if (hundreds === 1) {
parts.push(usePrefix ? `${ones[1]} ${hundredsWord}` : hundredsWord);
} else {
const pluralForm = this.config.hundreds.pluralWord || `${hundredsWord}s`;
parts.push(`${ones[hundreds]} ${pluralForm}`);
}
}
}
// Handle remainder (0-99)
if (remainder > 0) {
if (remainder < 20) {
parts.push(ones[remainder]);
} else {
const tensDigit = Math.floor(remainder / 10);
const onesDigit = remainder % 10;
const separator = this.config.hundreds.pattern === "prefix" ? "-" : " ";
const tensPart = onesDigit > 0
? `${tens[tensDigit]}${separator}${ones[onesDigit]}`
: tens[tensDigit];
parts.push(tensPart);
}
}
return parts.join(" ");
}
private convertWholeNumber(n: number, gender: Gender): string {
if (n === 0) return this.config.zero;
const words: string[] = [];
let scaleIndex = 0;
let tempNum = n;
while (tempNum > 0) {
const chunk = tempNum % 1000;
if (chunk !== 0) {
const chunkWords = this.convertHundreds(chunk, gender, scaleIndex > 0);
const scaleWord = this.getScaleWord(chunk, scaleIndex);
words.unshift(scaleWord ? `${chunkWords} ${scaleWord}` : chunkWords);
}
tempNum = Math.floor(tempNum / 1000);
scaleIndex++;
}
return words.join(" ");
}
convert(
amount: number,
currency?: string,
centName?: string,
gender?: Gender
): string {
// Validation
if (typeof amount !== 'number') {
throw new Error("Amount must be a number");
}
if (amount < 0) {
throw new Error("Amount must be non-negative");
}
if (amount > 999999999999.99) {
throw new Error("Amount must be less than or equal to 999,999,999,999.99");
}
// Use defaults from language config if not provided
const currencyCode = currency || this.config.defaultCurrency;
const centNameToUse = centName || this.config.defaultCentName;
const genderToUse = gender || "masculine";
// Translate currency to localized name
const translation = this.config.currencyTranslations[currencyCode];
let currencyName: string;
if (translation) {
// Use translation table to get singular/plural
const lastDigit = Math.floor(amount) % 10;
const lastTwoDigits = Math.floor(amount) % 100;
const useSingular = lastDigit === 1 && lastTwoDigits !== 11;
currencyName = useSingular ? translation.singular : translation.plural;
} else {
// Fallback to pluralization function if no translation
currencyName = this.config.pluralizeCurrency(Math.floor(amount), currencyCode);
}
// Split into whole and decimal parts
const roundedNum = Math.round(amount * 100) / 100;
const wholePart = Math.floor(roundedNum);
const decimalPart = Math.round((roundedNum - wholePart) * 100);
const parts: string[] = [];
// Whole part (currency)
const wholeWords = wholePart > 0
? this.convertWholeNumber(wholePart, genderToUse)
: this.config.zero;
parts.push(`${wholeWords} ${currencyName}`);
// Decimal part (cents)
if (decimalPart > 0) {
const centGender = this.config.genderForCents;
const centWords = this.convertWholeNumber(decimalPart, centGender);
// Translate cent name to localized name
const centTranslation = this.config.centTranslations[centNameToUse];
let centWord: string;
if (centTranslation) {
const lastDigit = decimalPart % 10;
const lastTwoDigits = decimalPart % 100;
const useSingular = lastDigit === 1 && lastTwoDigits !== 11;
centWord = useSingular ? centTranslation.singular : centTranslation.plural;
} else {
// Fallback to pluralization function if no translation
centWord = this.config.pluralizeCents(decimalPart, centNameToUse);
}
parts.push(`${this.config.connector} ${centWords} ${centWord}`);
}
return parts.join(" ");
}
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Convert a number to words in the specified language
*
* @example
* ```typescript
* // Simple usage with defaults
* numberToWords(123.45, { language: "en" })
* // "one hundred twenty-three euros and forty-five cents"
*
* // With custom currency
* numberToWords(123.45, { language: "en", currency: "dollar", centName: "cent" })
* // "one hundred twenty-three dollars and forty-five cents"
*
* // Latvian with gender
* numberToWords(123.45, { language: "lv", gender: "masculine" })
* // "viens simts divdesmit trīs eiro un četrdesmit pieci centi"
* ```
*/
function numberToWords(amount: number, options: ConversionOptions = {}): string {
const language = options.language || "en";
const converter = new NumberToWordsConverter(language);
return converter.convert(amount, options.currency, options.centName, options.gender);
}
// Convenience functions
const toEnglish = (amount: number, currency?: string, centName?: string) =>
numberToWords(amount, { language: "en", currency, centName });
const toLatvian = (amount: number, currency?: string, centName?: string, gender?: Gender) =>
numberToWords(amount, { language: "lv", currency, centName, gender });
// ============================================================================
// TESTS
// ============================================================================
console.log("=== ENGLISH TESTS ===\n");
const englishTests = [
{ input: 1.50, expected: "one euro and fifty cents" },
{ input: 100, expected: "one hundred euros" },
{ input: 100000, expected: "one hundred thousand euros" },
{ input: 1234.56, expected: "one thousand two hundred thirty-four euros and fifty-six cents" }
];
englishTests.forEach(({ input, expected }) => {
const result = toEnglish(input);
const passed = result === expected;
console.log(`${passed ? '✅' : '❌'} ${input}: ${result}`);
if (!passed) console.log(` Expected: ${expected}`);
});
console.log("\n=== LATVIAN TESTS ===\n");
const latvianTests = [
{ input: 1.50, expected: "viens eiro un piecdesmit centi" },
{ input: 100, expected: "viens simts eiro" },
{ input: 100000, expected: "simts tūkstoši eiro" },
{ input: 1234.56, expected: "viens tūkstotis divi simti trīsdesmit četri eiro un piecdesmit seši centi" }
];
latvianTests.forEach(({ input, expected }) => {
const result = toLatvian(input);
const passed = result === expected;
console.log(`${passed ? '✅' : '❌'} ${input}: ${result}`);
if (!passed) console.log(` Expected: ${expected}`);
});
console.log("\n=== CUSTOM CURRENCY TESTS ===\n");
const customCurrencyTests = [
{
lang: "en",
input: 99.99,
currency: "dollar",
cent: "cent",
expected: "ninety-nine dollars and ninety-nine cents"
},
{
lang: "en",
input: 1.00,
currency: "dollar",
cent: "cent",
expected: "one dollar"
},
{
lang: "en",
input: 50.25,
currency: "pound",
cent: "penny",
expected: "fifty pounds and twenty-five pennies"
},
{
lang: "lv",
input: 99.99,
currency: "dollar",
cent: "cent",
expected: "deviņdesmit deviņi dolāri un deviņdesmit deviņi centi"
},
{
lang: "lv",
input: 1.50,
currency: "dollar",
cent: "cent",
expected: "viens dolārs un piecdesmit centi"
},
{
lang: "lv",
input: 21.00,
currency: "dollar",
cent: "cent",
expected: "divdesmit viens dolārs"
},
{
lang: "en",
input: 1000.01,
currency: "yen",
cent: "sen",
expected: "one thousand yens and one sen"
},
{
lang: "lv",
input: 1000.01,
currency: "yen",
cent: "sen",
expected: "viens tūkstotis jēnas un viens sens"
}
];
customCurrencyTests.forEach(({ lang, input, currency, cent, expected }) => {
const result = lang === "en"
? toEnglish(input, currency, cent)
: toLatvian(input, currency, cent);
const passed = result === expected;
console.log(`${passed ? '✅' : '❌'} [${lang.toUpperCase()}] ${input} ${currency}: ${result}`);
if (!passed) console.log(` Expected: ${expected}`);
});
// ============================================================================
// EXPORTS
// ============================================================================
export {
numberToWords,
toEnglish,
toLatvian,
registry,
type LanguageConfig,
type ConversionOptions,
type Gender
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment