0

I have an object in the following structure:

const geo = {
    europe: {
      germany: ['berlin', 'hamburg', 'cologne'],
      france: ['toulouse', 'paris', 'limoges'],
      italy: ['rome', 'venice', 'genoa'],
    },
    asia: {
      india: ['mumbai', 'rajkot', 'pune'],
      china: ['shenzhen', 'beijing', 'shanghai'],
      nepal: ['kohalpur', 'ghorahi', 'hetauda'],
    },
  };

And I want to write a simple function that randomly selects a city, given continent and country. Although it seems to me that a straightforward implementation would be

const randomElement = (arr) => arr[Math.floor(Math.random() * arr.length)];
const pickCity = (continent, country) => randomElement(geo[continent][country])

// calling `pickCity()`
pickCity("europe", "france") // => we get one city at random, as requested

I nevertheless wonder whether such implementation is falling under the category of a "stringly typed" function, which is said to be a sub-optimal practice.

On the other hand, if I attempt to avoid those string parameters, then the alternative would be more verbose and not respecting the DRY principle:

const pickCityGermany = () => randomElement(geo.europe.germany)
const pickCityFrance = () => randomElement(geo.europe.france)
const pickCityItaly = () => randomElement(geo.europe.italy)
const pickCityIndia = () => randomElement(geo.asia.india)
const pickCityChina = () => randomElement(geo.asia.china)
const pickCityNepal = () => randomElement(geo.asia.nepal)

Is pickCity() indeed "stringly typed", or am I misinterpreting the concept?


UPDATE


Following @Bergie's answer below, I've realized that TypeScript's enum is likely a proper solution to typing pickCity()'s parameters. So I took a stub at this, but see below, this is still not quite a complete solution.

I started with creating two enums, one for continent and one for country:

// typescript
enum Continent {
    Europe = "europe",
    Asia = "asia"
}

enum Country {
    Germany = "germany",
    France = "france",
    Italy = "italy",
    India = "india",
    China = "china",
    Nepal = "nepal"
}

However, as @Bergi wrote, they are dependent on each other because the way geo data is structured. So the only way I could solve it right now is by typing any, which is certainly not a solution I want:

type Geo = Record<Continent, any> // <~-~-~-~-~ `any` is :(

const geo: Geo = {
    europe: {
      germany: ['berlin', 'hamburg', 'cologne'],
      france: ['toulouse', 'paris', 'limoges'],
      italy: ['rome', 'venice', 'genoa'],
    },
    asia: {
      india: ['mumbai', 'rajkot', 'pune'],
      china: ['shenzhen', 'beijing', 'shanghai'],
      nepal: ['kohalpur', 'ghorahi', 'hetauda'],
    },
  };

const randomElement = (arr) => arr[Math.floor(Math.random() * arr.length)];
const pickCity2 = (continent: Continent, country: Country) => randomElement(geo[continent][country])

// calling pickCity2()
console.log(pickCity2(Continent.Europe, Country.Germany)) // => one German city at random

why can't we simply untangle the dependency by changing geo?

@Bergi asked this, because it's seems like an unneeded complication. So had we have the following geoSimpler instead, life would have been easier:

type GeoSimpler = Record<Country, string[]>;

const geoSimpler: GeoSimpler = {
  germany: ['berlin', 'hamburg', 'cologne'],
  france: ['toulouse', 'paris', 'limoges'],
  italy: ['rome', 'venice', 'genoa'],
  india: ['mumbai', 'rajkot', 'pune'],
  china: ['shenzhen', 'beijing', 'shanghai'],
  nepal: ['kohalpur', 'ghorahi', 'hetauda'],
};
const pickCity3 = (country: Country) => randomElement(geoSimpler[country])
console.log(pickCity3(Country.France)) // 

However!

I still want to account for data structures that could not be simplified. For example, consider geoTwinCitiesAttractions:

const geoTwinCitiesAttractions = {
  us: {
    cairo: ['U.S. Custom House', 'Cairo Public LIbrary', 'Magnolia Manor'],
    memphis: ['Graceland', 'National Civil Rights Museum', 'Beale Street'],
    saintPetersburg: ['The Dali Museum', 'Sunken Gardens', 'Chihuly Collection'],
    moscow: [],
    athens: ['Pleasant Hill Vineyards', 'Little Fish Brewing', 'Athens Farmers Market'],
  },
  greece: {
    athens: ['Acropolis of Athens', 'Parthenon', 'Mount Lycabettus'],
  },
  france: {
    paris: ['Eiffel Tower', 'Louvre Museum', 'Arc de Triomphe'],
  },
  russia: {
    saintPetersburg: ['State Hermitage Museum', 'Savior on the Spilled Blood', 'Winter Palace',
    ],
    moscow: ['Red Square', 'Bolshoi Theatre', 'Cathedral of Christ the Saviour'],
  },
  egypt: {
    cairo: ['Giza Necropolis', 'Mosque of Muhammad Ali', 'Salah Al-Din Al-Ayoubi Castle'],
    memphis: ['foo', 'bar', 'baz'],
  },
};

Here we must specify both country and city, because there are same-name cities in different countries. So how could we use enum to avoid the "stringly typed" pickAttraction():

const pickAttraction = (country: string, city: string) => randomElement(geoTwinCitiesAttractions[country][city])
Emman
  • 3,695
  • 2
  • 20
  • 44
  • Depends a bit on how you are calling them. – Bergi May 15 '22 at 21:41
  • Example of Javascript stringly typing: https://stackoverflow.com/questions/44274608/how-to-avoid-being-stringly-typed-in-javascript – Ouroborus May 15 '22 at 21:43
  • Is this actually a javascript question or it a typescript question? You have it tagged as [tag:javascript] but, based on a comment you made, it seems that [tag:javascript] might not be the correct tag. – Ouroborus May 15 '22 at 22:29
  • @Ouroborus, you're right. I started with plain *JavaScript* in my mind, but I'm realizing this relates to *TypeScript* even more so. Updated the tagging. – Emman May 15 '22 at 22:31

1 Answers1

1

Yes, pickCity is "stringly typed" if you really consider passing arbitrary strings to it. pickCity('africa', 'sudan') fails with your current data structure. And worse, pickCity('sun', 'earth') or pickCity('major:Scholz', 'population:>10000') or pickCity('','ivqu ioqwe h') just don't make sense at all.

But it's fine if you don't pass arbitrary strings to it, if you pass only continent names to the first parameter and only state names to the first parameter. Using enum types would be more appropriate to type these parameters. However, since you're asking about JavaScript specifically, there are no enum types (nor type declarations at all), and at runtime using strings is the most viable solution if you need dynamic choice between the options. But your mental model (and also annotations and documentation) must treat these as enums, or as a set of allowed strings.

(A further problem, unrelated to the "stringly typed", is that the parameters are dependent on each other. pickCity('america', 'germany') doesn't work since Germany is not in America. Do you need the continent at all?)

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • Passing arbitrary strings is more about error checking rather than stringly typing. – Ouroborus May 15 '22 at 21:49
  • Bergi, thanks. Would you mind showing how you'd use enum types in this case, if we were in TypeScript? – Emman May 15 '22 at 21:50
  • 1
    @Emman It should be `const pickCity: (continent: ContinentName, country: CountryName) => CityName` instead of `const pickCity: (continent: string, country: string) => string`. Where `type ContinentName = 'europe' | 'asia';`, or `type ContinentName = keyof geo;`, or an `enum` (and then `geo` should be declared as a `Record`). Whether `CityName` is necessary depends, you can also use `string` as the return type. As for `CountryName`, you still have the dependent type problem. You can solve this by omitting the continent, or overloading, or something else. – Bergi May 15 '22 at 21:58
  • @Bergi thanks. I've updated my post in light of your answer and comment, see above. – Emman May 16 '22 at 07:50
  • "*the only way I could solve it right now is by typing any*" - I don't see why you couldn't use `type Geo = Record>`. Sure, it still doesn't represent the dependency, but it's still much better than `any`. – Bergi May 16 '22 at 07:59
  • 1
    "*how could we use enum to avoid the "stringly typed" `pickAttraction()`*" - you can still use a `City` enum that has all the city names. Better than any string… but if you want the type system to prevent passing a city together with the wrong country, you'll need one enum per country (like `enum UsCity = …; enum GreekCity = …; …`), and then overload the function as `pickAttraction(country: "us", city: UsCity): string; pickAttraction(country: "greece", city: GreekCity): string; …`. But in that case you might as well go with one function per country. – Bergi May 16 '22 at 08:02
  • Using `type Geo = Record>` throws an error, see [here](https://tsplay.dev/WPZAqN) – Emman May 16 '22 at 08:04
  • 1
    Ah right, `Record>>` it is – Bergi May 16 '22 at 08:05
  • Thanks :) I don't want to be greedy but if you could incorporate your comments in the answer itself for the sake of completeness, I'd be grateful. Right now I'm a bit perplexed unpacking your comment about `pickAttraction()`. Again, don't want to be greedy and you've already helped me greatly, but I don't want to miss the potential of gaining this knowledge. Thanks! – Emman May 16 '22 at 08:13