Wednesday, May 18, 2022

Working with String Enums in TypeScript

Enums have long been a feature of the Java language, but were only added to TypeScript in recent years. Interestingly, enums are one of the few TypeScript features which is not a type-level extension of JavaScript. Their purpose is to define a set of named constants, making it easier to document intent, as well as create a set of distinct cases. TypeScript enums come in two flavors: numeric and string-based. This article is specifically about the latter; we’ll learn how to use string enums in our Angular applications while side-stepping some of their more prevalent gotchas.

TypeScript Enums Example

As a set of named constants, each member of a string enum has to be initialized with a string literal, or with another string enum member. Here’s the enum that we’ll be using to set a drop-down of language choices in an Angular application:

export enum SupportedLanguages {
  en = 'English',
  fr = 'français',
  es = 'Español'
}

To reference enum values, we would treat them as object attribute and access them using dot (.) notation:

console.log(SupportedLanguages.en); // 'English'
console.log(SupportedLanguages.fr); // 'français',
console.log(SupportedLanguages.es); // 'Español'

Read: Choosing Between TypeScript String Literals and Enums

Iterating Over a String Enum in TypeScript

Looking at the enum structure, it should be apparent that entries consist of a key/value combination. You would expect such a configuration to be ideal for populating a drop-down. In truth, string enums are not as easily iterated over as Arrays, which support standard for looping. An enum’s structure more closely resembles that of a Map, so they require a similar approach. In both cases, we must extract the object keys and iterate over those. That’s because the TypeScript enum is transpiled to a vanilla JS Object:

Object { en: "English", fr: "français", es: "Español" }
  en: "English"
  es: "Español"
  fr: "français"

Thus, all we need to do to fetch an enum’s keys is pass it to the static Object.keys() method. We can then reference keys using a public getter:

private _supportedLanguages = Object.keys(SupportedLanguages);

public get supportedLanguages(): Array<string> {
  return this._supportedLanguages;
}

Now we can use a for…of loop to populate our drop-down. The enum values are accessible using the JS associative array object[key] syntax:

<mat-form-field>
  <mat-label>{{ languageLabel }}</mat-label>
  <mat-select
    [(ngModel)]="selectedLanguage"
    (ngModelChange)="onSelectedLanguageChange($event)"
  >
    <mat-option *ngFor="let language of supportedLanguages" [value]="language"
      >{{ languages[language] }}
    </mat-option>
  </mat-select>
</mat-form-field>

Read: Getting Fancy with the JavaScript For Loop

Responding to Drop-down Selections

Of course, getting enum data into the options is only half the battle. We still need to do something with it. So, let’s use the enum keys to set the page language. This is a DIY approach that is well suited to simple single-page applications (SPAs). For anything more elaborate, I would recommend using Angular’s built-in Internationalization framework.

The onSelectedLanguageChange() method accepts the enum type because, although Object.keys() converts keys to strings, we know that only valid enum keys will ever be passed to the method. The key is converted into a query parameter and passed along to the Router’s navigate() method to reload the page with the language parameter:

public onSelectedLanguageChange(
  language: SupportedLanguages
) {
  const queryParams: Params = { lang: language };

  this.router.navigate([], {
    relativeTo: this.activatedRoute,
    queryParams: queryParams
  });
}

We could just update the language in place, but setting a query parameter allows the page to retain the current language even on page refreshes.

Setting the Current Language

The first time that the page loads, we subscribe to the ActivatedRoute’s queryParams Observable. On subsequent drop-down triggered loads, it will emit the new query parameter, making it the perfect place to update page labels, titles, and any other content:

public languages = SupportedLanguages;
public selectedLanguage: string;
public languageLabel: string;
public title: string;
private _supportedLanguages = Object.keys(SupportedLanguages);
  
constructor(
  private router: Router,
  private activatedRoute: ActivatedRoute,
  private translationService: TranslationService
) {}

ngOnInit(): void {
  this.activatedRoute.queryParams.subscribe(queryParams => {
    this.selectedLanguage = 
      queryParams['lang'] || this.supportedLanguages[0];

    this.title = this.getTranslation('title');
    this.languageLabel = this.getTranslation('language');
  });
}

Fetching Translations

The AppComponent’s getTranslation() method merely delegates the translation fetching to a service, passing along the selectedLanguage. The service doesn’t know what the current language is, and that is by design. The AppComponent has to maintain the selectedLanguage because it’s bound to the language SELECT’s model. It’s best to avoid having both the component and service maintain the current language because such a setup is prone to synchronization issues. Perhaps you’re familiar with the Single Source of Truth (SSOT) concept. It’s the practice of structuring models such that every data element is maintained in only one place. Therefore, we would either have to make the service itself public or pass data along to it, as I have done here:

private getTranslation(key: string) {
  return this.translationService
             .getTranslation(
                key, 
                <SupportedLanguages>this.selectedLanguage
             );
}

The TranslationService does however store the translations themselves. Here we’re using nested Maps where the outer Map entries are accessed via the enum keys whereas the inner ones contain keys for all strings in that language:

import { Injectable } from '@angular/core';

export enum SupportedLanguages {
  en = 'English',
  fr = 'français',
  es = 'Español'
}

@Injectable()
export class TranslationService {
  private readonly translations 
    = new Map<string, Map<string, string>>([
      ['en', new Map<string, string>([
        ["language", "Language"],
        ["title", "Convert Map Enum to Array Demo"]
      ])],
      ['fr', new Map<string, string>([
        ["language", "Langue"],
        ["title", "Démo Convertir Enum en Array"]
      ])],
      ['es', new Map<string, string>([
        ["language", "Idioma"],
        ["title", "Demostración Para Convertir Enum a Array"]
      ])]
  ]);

  public getTranslation(
    key: string, 
    language: SupportedLanguages = SupportedLanguages.en
  ) {
    return this.translations.get(language).get(key);
  }
}

You can see the result of setting the language to French here:

TypeScript Enums Tutorial

 

There’s a demo of the language app on stackblitz.

Conclusion

Whenever you need to define a set of named constants in TypeScript or Angular, consider using an Enum. They work very well once you become accustomed to their modest set of idiosyncrasies.

Read: RxJS Observables Primer in Angular

Rob Gravelle
Rob Gravelle
Rob Gravelle resides in Ottawa, Canada, and has been an IT guru for over 20 years. In that time, Rob has built systems for intelligence-related organizations such as Canada Border Services and various commercial businesses. In his spare time, Rob has become an accomplished music artist with several CDs and digital releases to his credit.

Popular Articles

Featured