Created
October 23, 2025 23:10
-
-
Save ohbob/c5d6bbbbf0aa142f5ff668fed082d1c2 to your computer and use it in GitHub Desktop.
number_to_words.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================================ | |
| // 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