13
\$\begingroup\$

This seems like a popular topic lately, so I took a crack at it.

Related questions are Number to Words and Converting a number to the text representation.

I used this article as a guideline. The article mostly explains by example, but some key points were:

  • Use a hyphen to separate numbers like twenty-two.

  • Using and is not a requirement, so 2014 can be written as two thousand fourteen.

  • No use of commas is mentioned, other than as placeholders in numbers represented with numerals.

It mentions that numbers may be spoken like two thousand and fourteen in the last footnote; maybe this could be an optional feature added later. If someone finds a better reference showing that any of this is incorrect, please let me know.

(function (global) {
    'use strict';

    var scalePrefix = ['m', 'b', 'tr', 'quadr', 'quint', 
            'sext', 'sept', 'oct', 'non', 'dec'];

    var digits = ['', 'one', 'two', 'three', 'four',
            'five', 'six', 'seven', 'eight', 'nine'];

    var tens = ['', 'ten', 'twenty', 'thirty', 'forty', 'fifty',
            'sixty', 'seventy', 'eighty', 'ninety'];

    var teens = { 0: 'ten', 1: 'eleven', 2: 'twelve', 
            3: 'thir', 5: 'fif', 8: 'eigh' };

    function nameDigit(digit) {
        return digits[digit];
    }

    function nameTenToNineteen(number) {
        var prefix = teens[number] || digits[number];

        return number < 3 ? prefix : prefix + 'teen';
    }

    function nameTripletScale(digitCount) {
        return  digitCount < 3 ? '' :
                digitCount < 6 ? 'thousand' :
                scalePrefix[digitCount / 3 - 2] + 'illion';
    }

    function nameTripletHead(number) {
        return +number ? (nameDigit(number) + ' hundred') : '';
    }

    function nameTripletTail(digitPair) {
        var separator = +digitPair[1] ? '-' : '';

        return  digitPair[0] == 0 ? nameDigit(digitPair[1]) :
                digitPair[0] == 1 ? nameTenToNineteen(digitPair[1]) :
                tens[digitPair[0]] + separator + digits[digitPair[1]];
    }

    function extractSymbols(number) {
        var symbols, digitCount;

        number = ('' + number);
        if (!number.match(/^(-?)[\d,]+/)) {
            throw new Error('invalid number: ' + number);
        }
        symbols = number.replace(/,/g, '').split('');
        digitCount = symbols.length - (symbols[0] == '-');
        if (digitCount / 3 > scalePrefix.length + 2) {
            throw new Error('number out of range: ' + number);
        }

        return symbols;
    }

    function nameNumber(number) {
        var symbols = extractSymbols(number),
            sign = '',
            segments = [],
            segment;

        function append(item) {
            item && segments.push(item);
        }

        if (symbols[0] == '-') {
            sign = 'negative ';
            symbols.shift();
        }

        while (symbols.length % 3) {
            symbols.unshift(0);
        }

        while (symbols.length) {
            segment = symbols.splice(0, 3);
            append(nameTripletHead(segment[0]));
            append(nameTripletTail(segment.slice(1)));
            if (segment.join('') > 0) {
                append(nameTripletScale(symbols.length));
            }
        }

        return sign + (segments.join(' ') || 'zero');
    }

    global['nameNumber'] = nameNumber;

}(this));
  • I am especially interested in feedback on better names for things, especially things like teens which are not quite accurate. I'm also looking for feedback on any bugs or mistakes. The internal nameNumber also looks a little bit too busy, maybe it should be broken up more but I wasn't really happy with anything I tried so far.

  • I am especially uninterested in hearing about unreadable/unmaintainable ternary conditionals. They look fine to me. If you can suggest a different way to write it that's still readable and doesn't double the number of relevant LOC, though, I might be interested after all.

Test it out here; there's a test harness and a passing test case for each example from the linked article.

\$\endgroup\$
  • \$\begingroup\$ Minor bug: nameTripletTail doesn't account for a trailing zero so nameNumber("20") returns "twenty-". \$\endgroup\$ – David Harkness Aug 3 '14 at 19:28
  • \$\begingroup\$ @DavidHarkness good catch, I actually found that article and added that hyphen at the last minute. Should be an easy fix (and fixed, I think). \$\endgroup\$ – Dagg Aug 3 '14 at 19:31
  • 2
    \$\begingroup\$ While I don't have a reference, I do recall that you should never insert "and" into the number. It's a common habit for people when speaking once the number goes over one hundred because it feels like a list once there are enough terms. \$\endgroup\$ – David Harkness Aug 3 '14 at 19:33
  • \$\begingroup\$ Rules for Writing Numbers (cached) summarizes the rules from a few good media sources, including one for using "and" with fractions. \$\endgroup\$ – David Harkness Aug 3 '14 at 19:51
  • 1
    \$\begingroup\$ No reference, but I was taught in school to only write "and" to stand for the decimal point. Ex: 112.56 = One hundred twelve and 56 hundredths. \$\endgroup\$ – RubberDuck Aug 3 '14 at 22:32
7
\$\begingroup\$

Interesting question, impressive code.

I only found one thing that could be considered a bug: nameNumber('15.000') will return "one hundred fiftyundefined thousand", this could be fixed by changing your regex to

if (!number.match(/^(-?)[\d,]+$/)) {
    throw new Error('invalid number: ' + number);
}

Other than that I think you went a little overboard in some places to achieve the ultimate DRYness.

This:

var digits = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
var tens = ['', 'ten', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
var teens = { 0: 'ten', 1: 'eleven', 2: 'twelve', 3: 'thirteen', 5: 'fifteen', 8: 'eighteen' };

function nameDigit(digit) {
    return digits[digit];
}

function nameTenToNineteen(number) {
    return teens[number] || digits[number] + 'teen';
}

(ignore the removed newlines) puts the 'teen' back in the teens array, you no longer need a temporary variable or encode that you do not have the add 'teen' for 10,11 and 12. This has less characters, and less complexity.

The same ( though I am not sure this would reduce character count here ) can be said for scalePrefix, it took me a while to figure that this was supposed to return milllion, billion etc. If you were to add '', 'thousand' and 'illion' at the end of existing entries, then your nameTripletScale could be

function nameTripletScale(digitCount) {
    return scalePrefix[digitCount / 3 ];
}

Note how it sneakily removes an offensive ternary :P It would also no longer require the magical +2 in if (digitCount / 3 > scalePrefix.length + 2) {

In extractSymbols you mix parsing, conversion, validation and error handling, I usually don't like that but the code is succinct enough that I can not offer a reasonable alternative.

The same goes for function append(item) { in nameNumber, it looks a bit busy but removing it looks worse.

One last item is nameDigit(digit), it requires more characters than say digits[digit] and about half of the time you actually use direct array access instead of calling nameDigit, I would just drop nameDigit entirely.

\$\endgroup\$
  • \$\begingroup\$ The regex thing I actually noticed and corrected in my copy, but it was such a small change I decided not to bump the question. I didn't think anyone would catch it, though... good eye :) \$\endgroup\$ – Dagg Aug 8 '14 at 15:29
  • \$\begingroup\$ I think you're right about adding "teen" to the teens. I'm not sure how I feel about adding "illion" to the, ah, illions. If you wanted to add undecillion, duodecillion and so on, each addition would add twice as many characters and another repeated "illion." Not sure whether this is going overboard but I was having trouble stomaching all that repetition when I wrote this. \$\endgroup\$ – Dagg Aug 8 '14 at 15:43
  • \$\begingroup\$ Agreed on illions, it's a matter of taste \$\endgroup\$ – konijn Aug 8 '14 at 15:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.