Skip to main content
Hypersphere
Article Cover

Advanced TypeScript: Mapped Types and more

Advanced TypeScript

Mapped Types

and more

Using strongly typed language has a lot of benefits. But TypeScript is not a magical tool — the stronger types you provide, the better results you will get. Unfortunately, in a lot of the cases we can end up using very broad types to describe much narrower sets of values — it’s foremost apparent when using primitives like strings or numbers.

In my previous article I write about an issue of using plain primitive types, as they often represent different things and cannot be used interchangeably:

Opaque types are great when dealing with infinite subsets of values. However, in plenty of cases, we deal with a finite subset of strings (or other primitive values). Instead of verifying those values in runtime, we can leverage TypeScript mechanisms to ensure we pass types properly.

Subset

In this article I’d like to use an example that is quite common in multi-regional sites that deal with multiple locales, to showcase some of the lesser-known features of TypeScript. This will provide a good overview of the following TypeScript features:

  • as const expression
  • keyof and typeof
  • type argument inference for dynamic type resolution in generics
  • mapped types
  • using never in conditional types to filter out unions

The Problem #

Imagine you are working on a website that is available in multiple countries (regions). Each country has its version of the website that can be available in multiple local languages. Moreover, we want to be able to disable certain regions or languages using the configuration below.

const EnabledRegons = {
	"GB": {
		"en": true,
	},
	"IT": {
		"en": true,
		"it": true,
	},
	"PL": {
		"pl": true,
		"en": false,
	},
	"LU": {
		"fr": true,
		"de": true,
		"en": true,
	}
} as const;

Because we know we won’t change this object in our code in runtime, we can use as const assertion to let TypeScript know that there’s no way those values will be modified. This allows TypeScript to freeze the structure of an object and prevent expression widening.

As const in TypeScript

Extracting Countries #

Now we can implement a function that will return us the full name of the country for a given country code. It might be tempting to type it as follows:

const countryCodeToName = (countryCode: string): string => {
	switch (countryCode) {
		case "GB": return "Great Britain";
		case "IT": return "Italy";
		case "PL": return "Poland";
		case "LU": return "Luxembourg";
	}
}

The code above covers all possible country types, but TypeScript will complain that we don’t have a default value for our switch case. We could add a dummy return in this case which we know it’s never going to be used, but this solution has pitfalls:

  • We introduce artificial code branches for situations we know will never happen
  • If we decide to remove a region, we will end up with unused code across our application
  • If we decide to add a new region, we will need to look for places where to add it — we cannot use TypeScript to tell us where should we edit our code

Instead, we could narrow our countryCode parameter type to accept only the keys of our EnableRegions array.

const countryCodeToName = (countryCode: keyof typeof EnabledRegons): string => {
	switch (countryCode) {
		case "GB": return "Great Britain";
		case "IT": return "Italy";
		case "PL": return "Poland";
		case "LU": return "Luxembourg";
	}
}

Now TypeScript no longer complains about the lack of default return because it knows we covered all possible cases. Moreover, if you decide to remove one of the regions from the array, TypeScript will raise an error in all the cases where you use this value so you can easily remove obsolete code. If we decide to add a new value, TypeScript will let us know where in the code we are not covering the new scenario.

Typing languages based on the region #

Now let’s imagine we want to have a function getUrl that returns a value for a specific country and the language. We also want to restrict, on the typing language level, that you can’t pass incorrect language/country configuration:

getUrl("GB", "en"); // no error
getUrl("IT", "it"); // no error
getUrl("IT", "pl"); // type error because we didn't specify pl to be correct language for IT region

Fortunately, we can use type argument inference to capture country code and based on that type the second argument.

const getUrl = <CountryCode extends keyof typeof EnabledRegons>
  (country: CountryCode,
  language: keyof typeof EnabledRegons[CountryCode]): string => {
    // body of our function
}

This technique is really useful in all the cases where one argument determines types of the other ones.

Probably one of the most common use cases is addEventListener method - based on the event type, the callback argument is determined. I used the same concept in my cross-platform Event Bus library. Check out my other article about it:

Dynamically creating locale strings #

If you worked on multilingual websites you might have come across a format that captures both region and language: locale.

Locale is a string that contains both region and country connected with a hyphen. You might have come across it when using locale-aware methods like Date.prototype.toLocaleDateString that formats date into proper language.

Let us refactor the previous method to use a single parameter locale that will encapsulate both of those values — obviously it should be typed so we need dynamically create possible locale values. Fortunately, we can use mapped types to do so:

type ValueOf<T> = T[keyof T];

type Locale = ValueOf<{[K in Region]: `${keyof typeof EnabledRegons[K] & string}-${K}`}>
Regions mapped

The Locale type is created by iterating over regions and for each of them, it generates a mapped type consisting of the language code combined with region code. One small quirk we had to add in the example above is & string — potential keyof type is string | number | symbol and we need to tell TS that we only care about the intersection with string.

Excluding false values #

We can improve our solution and automatically exclude locales that are disabled in our configuration object. To do it, let’s create the following helper generic type:

type ExcludeFalseValues<T> = {[K in keyof T as T[K] extends true ? K : never]: T[K] }

type Locale = ValueOf<{
    [K in Region]: `${keyof ExcludeFalseValues<typeof EnabledRegons[K]> & string}-${K}`
}>

The code above uses never to filter out unwanted values. When a value is not extending true (which is TS way of checking if it is equal to it or not), then instead of key we set never resulting in TypeScript omitting this field.

Now if we decide to stop serving a locale, TypeScript will automatically detect which code should we remove alongside it:

Adding disabled regions

This technique can be particularly useful when dealing with flipper flags you want to decommission alongside all the related code or when maintaining translations, making sure we keep keys in sync between languages.

Conclusion #

TypeScript mapped types and other advanced techniques allow us to type our code much stricter if we spend time typing our code precisely. On the other side, it’s usually not trivial and adds a lot of extra overhead. What do you think? In which cases do you think this technique is the most useful? Or maybe you know other solutions that can help with similar tasks?