Les types énumérables en typescript : enum

TypeScript est un langage typé construit comme un sur-ensemble de javascript1. Il faut comprendre par là que TypeScript ajoute une surcouche au javascript standard. Celle-ci permet l’écriture de code plus expressif et plus sûr, qui est ensuite converti en javascript standard lors de la compilation. Outre le typage, qui est un des atouts phares de Typescript, il inclut aussi un certain nombre de concepts et de sucre syntaxique2 dont les types énumérés: enum.

Les enum pour quoi faire ?

Lorsque le contenu d’une variable n’a de sens que parmi une liste de valeurs déterminées, l’utilisation d’un type générique (un entier par exemple) peut introduire des erreurs, car il ne contraint pas l’intervalle des valeurs acceptables. C’est dans ce cas de figure que le type enum est particulièrement adapté: il permet de spécifier la liste des valeurs possibles et de leur donner un nom, les clés, afin de s’y référer de façon explicite. Une fois compilé, et donc à l’exécution, un type plus général sera utilisé. Lors de la compilation cependant, et si le langage le supporte, le compilateur peut vérifier les valeurs utilisées et déclencher une erreur lors d’une utilisation inadaptée.

Typescript introduit donc le concept d’enum qui n’est pas présent nativement en javascript. Cependant, du fait de la nécessité de garder une certaine rétrocompatibilité et d’être interopérable avec javascript,  le comportement des enum en Typescript peut parfois être surprenant. Connaître ces comportements permet de tirer le meilleur parti des enum en Typescript, de savoir quand les utiliser, mais surtout de savoir dans quels cas les éviter.

Généralités sur les enum

Les enum ne sont pas uniquement des types Typescript

Contrairement aux interfaces, qui ne sont que des types Typescript, c’est-à-dire qu’elles ne produisent aucun code javascript après compilation, la déclaration d’un enum génère du code javascript qui construit un objet définissant la relation clé – valeur de celui-ci.

Afin d’illustrer le fait que les interfaces n’existent que sous la forme de types, le code suivant tente d’afficher une interface.

interface TestInterface {
    value: number;
}
/* error TS2693: 'TestInterface' only refers to a type, but is being used as a value here. */
console.log(TestInterface); // comment this line to compile

Le compilateur affiche une erreur spécifiant que l’interface ne peut pas être utilisée comme une valeur, c’est-à-dire une donnée concrète exploitable en javascript.

Le code suivant qui affiche un enum fraîchement défini compile, quant à lui, sans problème.

enum TestEnum {
    value,
}

console.log(TestEnum);       // { '0': 'value', value: 0 }
console.log(TestEnum.value); // 0

La console affiche un objet pour l’exécution de la ligne 5. C’est cet objet qui est utilisé à l’exécution et l’utilisation des valeurs énumérées coûte donc l’appel à une propriété d’un objet3. Si les performances d’exécution sont un point particulièrement important, il existe une forme d’enum ne nécessitant pas l’utilisation d’objet à l’exécution : le const enum.

Que deviennent les déclaration d’enum à la compilation ?

Le code javascript généré par le compilateur TypeScript (version 3.7.3) pour l’exemple précédent est le suivant :

var TestEnum;
(function (TestEnum) {
    TestEnum[TestEnum["value"] = 0] = "value";
})(TestEnum || (TestEnum = {}));

console.log(TestEnum);
console.log(TestEnum.value);

Ce code permet de stocker l’objet { '0': 'value', value: 0 } dans la variable TestEnum.

Si le code généré peut sembler complexe pour simplement stocker un objet, cette façon de faire donne la possibilité de définir les enum par blocs (éventuellement situés dans des fichiers différents) comme illustré par le code suivant

enum TestEnum {
    value = 0,
    otherValue = 1,
}

enum TestEnum {
    anotherValue = 2,
}

console.log(TestEnum);
/* { '0': 'value', '1': 'otherValue',
     '2': 'anotherValue', value: 0,
     otherValue: 1, anotherValue: 2 } */

Cette fonctionnalité permet de garantir l’interopérabilité avec javascript, où les enum sont généralement émulés par des objets potentiellement extensibles. Si la déclaration d’un enum se fait en plusieurs blocs, un seul des blocs de déclaration peut ne pas avoir de valeur affectée.

Il faut, dans le cas d’enum défini par bloc, être particulièrement vigilant aux cas de collisions de valeurs comme illustré par le code ci-dessous.

enum TestEnum {
    value = 0,
    otherValue,
}

enum TestEnum {
    anotherValue = 2,
}

enum TestEnum {
    yetAnotherValue,
}

console.log(TestEnum);
/* { '0': 'yetAnotherValue', '1': 'otherValue',
     '2': 'anotherValue', value: 0,
      otherValue: 1, anotherValue: 2,
      yetAnotherValue: 0 } */

On constate que les clés value et yetAnotherValue ont toutes les deux la même valeur. Si la définition des enum par blocs peut-être utile pour s’interfacer avec du code javascript, il est généralement préférable de définir l’ensemble des valeurs d’un enum dans un endroit unique afin d’éviter de morceler le code.

Les différents types d’enum

Les enum de type numérique

Par défaut les enum sont de type numérique, les clés de l’enum ont pour valeur associée des nombres entiers. Si rien n’est spécifié, les valeurs commencent à 0 et se suivent par incrément de 1. Les enum de type numérique sont bijectifs, ils font un pont entre les clés et les valeurs, mais ils contiennent aussi la relation réciproque, c’est-à-dire des valeurs vers les clés.

La façon idiomatique d’accéder à la valeur d’un enum est MyEnum.Key mais on peut aussi y accéder sous la forme MyEnum['Key']. Pour récupérer la clé associée à une valeur d’un enum numérique, on utilisera l’accesseur tabulaire auquel on passera la valeur numérique (par exemple: MyEnum[0]).

enum TestEnum {
    value,
    otherValue,
}

console.log(TestEnum.value);           // 0 (value)
console.log(TestEnum['otherValue']);   // 1 (value)
console.log(TestEnum[1]);              // otherValue (key)
console.log(TestEnum[TestEnum.value]); // value (key)

let myKey = 'value';

/* error TS7015: Element implicitly has an 'any' type because index expression is not of type 'number'. */
console.log(TestEnum['no-value']); // comment this line to compile

/* error TS7015: Element implicitly has an 'any' type because index expression is not of type 'number'. */
console.log(TestEnum[myKey]); // comment this line to compile

console.log(TestEnum[27]); // undefined

L’accès à une valeur au travers d’une clé non définie dans l’enum est vérifié par Typescript à la compilation, comme illustré par l’erreur de la ligne 14. Il n’est d’ailleurs pas possible d’utiliser le contenu d’un string comme clé, dont le contenu est indéterminé à la compilation, comme cela est illustré par l’erreur déclenchée lors de la compilation de la ligne 17. En revanche, l’accès à la relation réciproque (des valeurs vers les clés) n’est pas protégé et l’utilisation d’une valeur ne se trouvant pas dans l’ensemble des valeurs définies dans l’enum retournera undefined, mais ne provoquera pas d’erreur lors de la compilation, comme illustré à la ligne 19.

Enum numérique en paramètre de fonction

Lorsqu’il est utilisé pour définir le type d’un argument d’une fonction, l’enum numérique est simplement un nombre et n’impose aucune contrainte sur la valeur passée.

Le code suivant n’affiche pas d’erreur à la compilation et montre les dangers potentiels d’affecter une valeur numérique arbitraire à un enum.

enum TestEnum {
    value,
    otherValue,
}

function takeEnum(v: TestEnum) {
    switch(v) {
        case TestEnum.value:
            return 'Ok';
        case TestEnum.otherValue:
            return 'Ok';
        default:
            throw new Error('Launch nuclear war');
    }
}

takeEnum(5); // God bye World

qui affiche lors de l’exécution :

.../typescript-enum/enum.js:31
            throw new Error('Launch nuclear war');
            ^
Error: Launch a nuclear war
    at takeEnum (.../typescript-enum/enum.js:31:19)
    at Object.<anonymous> ...

Si la vérification des enum de type numérique comme argument de fonction peut sembler insuffisante, celle-ci est principalement due à des raisons d’héritage de code et d’interopérabilité avec le javascript. Le renforcement du typage des enum a déjà été envisagée et abandonnée par l’équipe de développement de Typescript4.

Il est possible d’obtenir une vérification de typage strict pour les enum passés en paramètre de fonction, le lien suivant présente un code qui permet de s’assurer que le paramètre passé à une fonction dont l’argument est de type enum est bien un des éléments énumérés : enum numérique strict.

Les enum de type texte

Un enum de type texte se construit en attribuant des valeurs de type string aux clés de l’enum, ils ne sont pas bijectifs comme les enum de type numérique. La relation n’existe donc que des clés vers les valeurs.

enum TestEnumString {
    value = "my_value",
    value2 = "my_value2",
    value3 = "my_value2", // value2 duplication
}

console.log(TestEnumString);
/* { value: 'my_value', value2: 'my_value2', value3: 'my_value2' } */
console.log(TestEnumString['value']); // my_value

/* error TS7053: Element implicitly has an 'any' type because expression of type '0' can't be used to index type 'typeof TestEnumString'. Property '0' does not exist on type 'typeof TestEnumString' */
console.log(TestEnumString[0]); // comment this line to compile

/* error TS7015: Element implicitly has an 'any' type because index expression is not of type 'number' */
console.log(TestEnumString[TestEnumString.value]); // comment this line to compile
console.log(TestEnumString['another_value']); // comment this line to compile

L’utilisation d’un enum de type texte comme paramètre d’une fonction est plus restrictive que celle d’un enum de type numérique, comme cela est illustré par le code suivant.

enum TestEnumString {
    value = "my_value",
    value2 = "my_value2",
    value3 = "my_value2", // value2 duplication
}

function takeEnumString(value: TestEnumString): string {
    switch(value) {
        case TestEnumString.value:
            return 'Hello';
            break;
        case TestEnumString.value2:
            return 'Bye';
            break;
        default:
            throw new Error('Apocalyptic War !')
    }
}

console.log(takeEnumString(TestEnumString.value));  // Hello

let keyEnumString = 'value'
/* error TS2345: Argument of type 'string / '0' / '"value"' / "'my_value'" is not assignable to parameter of type 'TestEnumString' */
takeEnumString(keyEnumString); // comment this line to compile
takeEnumString(0); // comment this line to compile
takeEnumString('value'); // comment this line to compile
takeEnumString('my_value'); // comment this line to compile

TypeScript est beaucoup plus restrictif pour ce type d’enum, il est nécessaire d’utiliser une des clés de l’enum comme argument de fonction, comme l’illustrent les erreurs déclenchées par les lignes 24, 25, 26 et 27. Il n’y a pas de relation réciproque des valeurs vers les clés (le type précis de l’argument de la fonction, lorsque le nom de l’enum est utilisé pour typer l’argument, est une union des clés de l’enum5). Il est cependant nécessaire d’affecter une valeur à chaque clé et l’unicité de ces valeurs est à charge du développeur.

Les const enum

Si l’objet généré pour contenir les valeurs de l’enum n’est pas nécessaire lors de l’exécution du code javascript, il est alors possible d’utiliser un const enum. Dans ce cas, il n’est pas possible de faire référence à l’objet dans le code et l’accès à celui-ci comme étant une valeur concrète génère une erreur à la compilation.

const enum TestConstEnum {
    valueA,
    valueB,
    valueC,
}

const enum TestConstEnumString {
    Alpha = 'Alpha',
    Beta = 'Beta',
    Gamma = 'Gamma',
}

/* TS2475: 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.*/
console.log(TestConstEnum); // comment this line to compile
console.log(TestConstEnumString); // comment this line to compile

function takeConstEnum(value: TestConstEnum) {
    switch (value) {
        case TestConstEnum.valueA:
            console.log('Hello valueA');
            break;
        case TestConstEnum.valueB:
            console.log('Hello valueB');
            break;
        default:
            console.log('Hello Other Value');
    }
}
takeConstEnum(TestConstEnum.valueA); // Hello valueA
takeConstEnum(25); // Hello Other Value

function takeConstEnumString(value: TestConstEnumString) {
    switch (value) {
        case TestConstEnumString.Alpha:
            console.log('Hello Alpha');
            break;
        case TestConstEnumString.Beta:
            console.log('Hello Beta');
            break;
        default:
            console.log('Hello Other');
    }
}
takeConstEnumString(TestConstEnumString['Alpha']); // Hello Alpha

Le code javascript résultant de la compilation de l’exemple précédent, une fois le code incorrect commenté, est particulièrement explicite sur le façon dont Typescript gère ce genre d’enum.

"use strict";
function takeConstEnum(value) {
    switch (value) {
        case 0 /* valueA */:
            console.log('Hello valueA');
            break;
        case 1 /* valueB */:
            console.log('Hello valueB');
            break;
        default:
            console.log('Hello Other Value');
    }
}
function takeConstEnumString(value) {
    switch (value) {
        case "Alpha" /* Alpha */:
            console.log('Hello Alpha');
            break;
        case "Beta" /* Beta */:
            console.log('Hello Beta');
            break;
        default:
            console.log('Hello Other');
    }
}
takeConstEnumString("Alpha"); // affiche 'Alpha'
takeConstEnum(0); // affiche 'valueA'
takeConstEnum(25); // affiche 'Hello Other Value'

Toutes les références aux const enum ont disparu et ont été remplacées par leurs valeurs respectives lors de la compilation, les clés sont laissées dans le code en commentaires. Les restrictions de type appliquées par Typescript sont identiques à celles des enum sans le mot-clé const, en particulier pour les passages en paramètre de fonction.

Conclusion

L’utilisation d’enum en Typescript permet de rendre le code plus expressif et de regrouper des ensembles de valeurs dans une même catégorie tout en leur donnant un nom plus explicite. Cependant pour des raisons de compatibilité, les enum n’offrent pas toujours une vérification de typage aussi forte que celle qui pourrait être désirée. Il existe cependant des alternatives comme les enum de type texte.

  1. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript – https://www.typescriptlang.org.
  2. Ajout d’une syntaxe particulière à un langage rendant le code plus lisible et plus facile à écrire – https://fr.wikipedia.org/wiki/Sucre_syntaxique.
  3. L’utilisation d’un enum n’est donc pas gratuite du point de vue des ressources. Le javascript est cependant particulièrement efficace pour l’accès aux propriétés d’un objet et ceci n’est donc pas un frein à l’utilisation des enum.
  4. https://github.com/Microsoft/TypeScript/issues/26362.
  5. Pour les unions de type voir https://www.typescriptlang.org/docs/handbook/advanced-types.html#union-types, en particulier les unions de littérales.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *