2
\$\begingroup\$

In my application, a country code may be passed from the client as a URL query string or in a header. If there is no country in the URL or headers, then it will default to the United States. The application uses both Alpha-2 and Alpha-3 codes, and with the header there are special cases for Canada and Great Britain.

const countries = {
  UNITED_STATES: ['US', 'USA'],
  UNITED_KINGDOM: ['UK', 'GBR'],
  CANADA: ['CA', 'CAN'],
  AUSTRALIA: ['AU', 'AUS'],
  PUERTO_RICO: ['PR', 'PRI'],
  GREAT_BRITAIN: ['GB', 'GBR']
};

function lookupCountry(obj, country) {
  return Object.keys(obj).map((el) => obj[el])
         .filter((countryArr) => countryArr.includes(country.toUpperCase()));
}

function getCountry(i18nHeader, i18nQuery) {
  if (i18nQuery) {
    return lookupCountry(countries, i18nQuery);
  } else {
    if (!i18nHeader || !i18nHeader.length) {
      return countries.UNITED_STATES;
    } else {
      if (i18nHeader.toUpperCase() === countries.GREAT_BRITAIN[0]) {
        return countries.UNITED_KINGDOM;
      }
      if (i18nHeader.toUpperCase() === countries.CANADA[0]) {
        return countries.UNITED_STATES;
      }
    }
    return lookupCountry(countries, i18nHeader);
  }
}

I would like to take advantage of new ES6 language features and use the Map object. I'm thinking this Map structure could be initialized and structured like this:

 const countries = new Map([
      ['UNITED_STATES', ['US', 'USA']],
      [ 'GREAT_BRITAIN', ['GB', 'UK']]...
  ]);

I could then use counteries.get('GREAT_BRITAIN') to retrieve the array of country codes.

\$\endgroup\$
  • 2
    \$\begingroup\$ Do the special rules really only apply for the i18nHeader but not for the i18nQuery? \$\endgroup\$ – Roland Illig Jan 22 '18 at 22:45
  • \$\begingroup\$ @RolandIllig yes, if it comes from the query it will be the correct iso code. \$\endgroup\$ – Aaron Goldsmith Jan 22 '18 at 23:12
  • 1
    \$\begingroup\$ I would like to emphasize that the original intent of this post was to try to use an ES6 Map object, not regular objects. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/… \$\endgroup\$ – Aaron Goldsmith Jan 27 '18 at 0:28
1
\$\begingroup\$

The natural data structure for looking up things is a map. In JavaScript this is simply an object and is written like {key: value}. It provides efficient access.

Therefore my first idea was to use two maps for the two kinds of lookup you need: country by ISO-3166-alpha2 and country by ISO-3166-alpha3. So instead of your single countries map, I prefer to have these two maps:

const countryByIso2 = {};
const countryByIso3 = {};

These are currently empty but will be filled soon.

function init() {
    const countryCodes = [
        ['US', 'USA'],
        ['UK', 'GBR'],
        ['CA', 'CAN'],
        ['AU', 'AUS'],
        ['PR', 'PRI'],
        ['GB', 'GBR']
    ];

    const overrides = [
        ['GB', 'UK'],
        ['CA', 'US']
    ];

    for (let country of countryCodes) {
        countryByIso2[country[0]] = country;
        countryByIso3[country[1]] = country;
    }
    for (let override of overrides) {
        let from = countryByIso2[override[0]];
        let to = countryByIso2[override[1]];
        countryByIso2[from[0]] = countryByIso2[to[0]];
        countryByIso3[from[1]] = countryByIso3[to[1]];
    }
}

init();

In this function I have removed all the unnecessary data and left just the country codes. There are two configuration sections. The first is the mapping you already know. The second are the overrides that you wrote in the if clauses of your getCountry function.

After the first part of the initialization, countryByIso2['CA'] contains ['CA', 'CAN']. But when the overrides are done, they overwrite the existing map entries. This means that after the initialization you can inspect the countryByIso2 object and quickly see what you will get.

What's missing is the actual lookup.

function lookupCountry(code) {
    if (!code) {
        return null;
    }
    let upperCode = code.toUpperCase();
    return countryByIso2[upperCode]
        || countryByIso3[upperCode];
}

function getCountry(i18nHeader, i18nQuery) {
    return lookupCountry(i18nHeader)
        || lookupCountry(i18nQuery)
        || countryByIso2['US'];
}

In total, this code is longer than yours, but it uses only very few and very fast operations. It is suitable for country lists of several thousand. Your code on the other hand walks through all entries (lookupCountry) and takes a long time when the data grows.

There's another difference. If i18nHeader is set to some unknown value, your code returns undefined while my code returns the US country codes.


The next topic is putting your code into its own namespace. After the above definitions, all parts of the code (even those you didn't write) can access the init function, the countryByIso2 tables, the lookupCountry function. But these are meant to be internal to your code and should not be seen by any unrelated code.

Therefore, it is best practice to explicitly define those pieces of code you want to export to the other code and only make that visible. This is typically done with the following pattern, putting all your code into an anonymous function and then returning the things to be exported:

const getCountry = (function() {

    const countryByIso2 = {};
    const countryByIso3 = {};

    const countryCodes = [
        ['US', 'USA'],
        ['UK', 'GBR'],
        ['CA', 'CAN'],
        ['AU', 'AUS'],
        ['PR', 'PRI'],
        ['GB', 'GBR']
    ];

    const overrides = [
        ['GB', 'UK'],
        ['CA', 'US']
    ];

    for (let country of countryCodes) {
        countryByIso2[country[0]] = country;
        countryByIso3[country[1]] = country;
    }
    for (let override of overrides) {
        let from = countryByIso2[override[0]];
        let to = countryByIso2[override[1]];
        countryByIso2[from[0]] = countryByIso2[to[0]];
        countryByIso3[from[1]] = countryByIso3[to[1]];
    }

    function lookupCountry(code) {
        if (!code) {
            return null;
        }
        let upperCode = code.toUpperCase();
        return countryByIso2[upperCode]
            || countryByIso3[upperCode];
    }

    function getCountry(i18nHeader, i18nQuery) {
        return lookupCountry(i18nHeader)
            || lookupCountry(i18nQuery)
            || countryByIso2['US'];
    }

    return getCountry;
})();

When I saw your code, the first thing I did was to remove the unnecessary else clauses from the getCountry function. Then it looked like this:

function getCountry(i18nHeader, i18nQuery) {
    if (i18nQuery) {
        return lookupCountry(countries, i18nQuery);
    }
    if (!i18nHeader || !i18nHeader.length) {
        return countries.UNITED_STATES;
    }
    if (i18nHeader.toUpperCase() === countries.GREAT_BRITAIN[0]) {
        return countries.UNITED_KINGDOM;
    }
    if (i18nHeader.toUpperCase() === countries.CANADA[0]) {
        return countries.UNITED_STATES;
    }
    return lookupCountry(countries, i18nHeader);
}

It's a one-dimensional chain of decisions now, instead of a hard-to-grasp nested decision tree. It's still difficult to follow, and that's why I rewrote it in the alternative way described above.

\$\endgroup\$
  • 1
    \$\begingroup\$ I think that init is needless because it includes to much logic for a proposed which you can do manually \$\endgroup\$ – Roman Jan 26 '18 at 22:33
  • 1
    \$\begingroup\$ @Roman No, it's not needless. It keeps the data definitions to a minimum, and whenever the country lists change, they are very easy to change. Just generate the data from an Excel spreadsheet, since it consists of two easy to understand two-dimensional tables. \$\endgroup\$ – Roland Illig Jan 26 '18 at 22:38
  • \$\begingroup\$ for what it's worth, there will only be about 10 or so countries supported. \$\endgroup\$ – Aaron Goldsmith Jan 27 '18 at 0:22
1
\$\begingroup\$

In the following, I tried to use a functional approach to have a maintainable solution.

Explanation

A hashmap is a good way to store lookup values. In JavaScript, a hashmap is simply an object. Each key will store the lookup name of the country followed be the value which will be represented by a Country object.

The Country object can store two arrays - one for the alpha2 and one for the alpha3. I chose an array because the United Kingdom has two alpha2 values (UK and GB)

const country = (alpha2, alpha3) => ({
    alpha2,
    alpha3
})

const countries = {
    unitedStates: country(['US'], ['USA']),
    unitedKingdom: country(['UK', 'GB'], ['GBR']),
    canada: country(['CA'], ['CAN']),
    australia: country(['AU'], ['AUS']),
    puertoRico: country(['PR'], ['PRI']),
}

The Algorithm

  1. uppercase the country code
  2. lookup the country
  3. if country is present return it else return a default country
  4. apply rules

This will look like this:

const lookupCountryPipe = countries =>
    pipe(
        toUpperCase,
        lookupCountry (countries),
        ifNotPresentGetDefaultCountry (countries.unitedStates),
        countryRule (countries.canada) (countries.unitedStates)
    )

The key function is lookupCountry. It will return undefined if there is no country inside countries for the given countryCode. (In a 100% functional program you would return a Maybe instead of undefined)

const lookupCountry = countries => countryCode =>  {
    const countryNames = Object.keys(countries)
    const country = countryNames[0]
    return countryNames.length === 0
        ? undefined
        : isCountryRelatedToCode(countryCode, countries[country])
            ? countries[country]
            : lookupCountry (omitProperty(country, countries)) (countryCode)
}

The whole code

// helper functions
const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args)))

const toUpperCase = string =>
    string.toUpperCase()

const isEqualObject = a => b => 
    JSON.stringify(a) === JSON.stringify(b)
    
const omitProperty = (property, obj) => {
    const { [property]: omit, ...clone } = obj
    return clone
}
    
    
    

// country :: (['String'], ['String']) -> Country
const country = (alpha2, alpha3) => ({
    alpha2,
    alpha3
})

const countries = {
    unitedStates: country(['US'], ['USA']),
    unitedKingdom: country(['UK', 'GB'], ['GBR']),
    canada: country(['CA'], ['CAN']),
    australia: country(['AU'], ['AUS']),
    puertoRico: country(['PR'], ['PRI']),
}

// isCountryForCode :: (String, Country) -> Booealn
const isCountryRelatedToCode = (code, country) =>
    country.alpha2.includes(code) || country.alpha3.includes(code)

// lookupCountry :: ([Country], String) -> Country
const lookupCountry = countries => countryCode => {
    const countryNames = Object.keys(countries)
    const country = countryNames[0]
    return countryNames.length === 0
        ? undefined
        : isCountryRelatedToCode(countryCode, countries[country])
            ? countries[country]
            : lookupCountry (omitProperty(country, countries)) (countryCode)
}

// ifNotPresentGetDefaultCountry :: Country -> Country -> Country
const ifNotPresentGetDefaultCountry = defaultCountry => country =>
   country
        ? country
        : defaultCountry

// countryRule :: Country -> Country -> Country -> Country
const countryRule = countryToApplyRule => countryToReturn => countryToCheck =>
    isEqualObject (countryToApplyRule) (countryToCheck)
        ? countryToReturn
        : countryToCheck
        
// lookupCountryPipe :: [Country] -> String -> Country
const lookupCountryPipe = countries =>
    pipe(
        toUpperCase,
        lookupCountry (countries),
        ifNotPresentGetDefaultCountry (countries.unitedStates),
        countryRule (countries.canada) (countries.unitedStates)
        // countryRule (countries.puertoRico) (countries.unitedStates),
        // and more rules ..
    )

console.log(lookupCountryPipe (countries) ('AU'))
console.log(lookupCountryPipe (countries) ('GB'))
console.log(lookupCountryPipe (countries) ('CA'))
console.log(lookupCountryPipe (countries) ('X'))

\$\endgroup\$
  • 1
    \$\begingroup\$ Your code is very complicated compared to mine. Using a functional approach does not automatically lead to maintainable code. Could you add a few sentences on why you think your code is more maintainable than mine? \$\endgroup\$ – Roland Illig Jan 26 '18 at 22:45
  • 1
    \$\begingroup\$ @RolandIllig The purpose of my approach was not to compare it with yours. I had only the goal to make it easyer to add new rules. There's more than one way to skin a cat.. \$\endgroup\$ – Roman Jan 26 '18 at 22:48
1
\$\begingroup\$

So this is what I came up with for using an ES6 Map object, I think it's a bit cleaner, and I'm glad I had a chance to use this new data structure type. I'd appreciate any feedback or suggestions for further improvements, thanks!

const countries = new Map([
  ['UNITED_KINGDOM', ['UK', 'GBR']],
  ['CANADA', ['CA', 'CAN']],
  ['AUSTRALIA', ['AU', 'AUS']],
  ['GUERSEY', ['GG', 'GGY']],
  ['JERSEY', ['JE', 'JEY']],
  ['ISLE_OF_MAN', ['IM', 'IMN']],
  ['PUERTO_RICO', ['PR', 'PRI']],
  ['VIRGIN_ISLANDS', ['VI', 'VIR']],
  ['GUAM', ['GU', 'GUM']],
  ['GREAT_BRITAIN', ['GB', 'GBR']],
  ['AMERICA_SAMOA', ['AS', 'ASM']],
  ['NORTHERN_MARIANA_ISLANDS', ['MP', 'MNP']],
  ['UNITED_STATES', ['US', 'USA']]
]);

const countriesValues = [... countries.values()];

const lookupCountry = country => countriesValues.reduce((acc, next) => acc[0] === country || acc[1] === country ? acc : next) || ['US', 'USA'];

const verifyCountry = header => [].concat.apply([], countriesValues).includes(header);

const getCountry = (header, override) => {
  if (override) {
    header = override.toUpperCase();
  }
  if (header && header.toUpperCase() === 'GB') {
    return ['UK', 'GBR'];
  }
  if (!header || !header.length || (header && header.toUpperCase() === 'CA') || !verifyCountry(header))  {
    return ['US', 'USA'];
  }
  return lookupCountry(header.toUpperCase());
};
\$\endgroup\$

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.