Предыстория


Пол года назад в нашей компании было принято решение о переходе на более новые и модные технологии. Для этого сформировали группу специалистов, которая должна была: определиться с технологическим стеком, на базе этого стека сделать мост к легаси коду и, наконец, перевести часть старых модулей на новые рельсы. Мне посчастливилось попасть в эту группу. Клиентская кодовая база примерно в один миллион строк кода. В качестве языка мы выбрали TypeScript. GUI подложку решили делать на vue в купе с vue-class-component и IoC.

Но история не о том, как мы избавлялись от легаси кода, а об одном маленьком инциденте, который вылился в настоящую войну знаний. Кому интересно, добро пожаловать под кат.

Знакомство с проблемой


Спустя пару месяцев после старта, рабочая группа неплохо освоилась с новым стеком и уже успела перевести на него часть старого кода. Можно даже сказать, что набралась некоторая критическая масса мяса, когда нужно было остановиться, перевести дух и взглянуть на то, что мы накодили.

Мест, которые требовали глубокой проработки, как Вы понимаете, хватало. Но из всего важного, по иронии судьбы, меня ничего не цепляло. Не цепляло как разработчика. А вот одно из неважного, наоборот, не давало покоя. Меня до безумия раздражало то, как мы работаем с данными типа перечисление. Не было никакого обобщения. То встретишь отдельный класс с набором требуемых методов, то найдешь два класса, для того же самого, а то и вовсе нечто загадочное и магическое. И винить здесь некого. Темп который мы взяли для избавления от легаси был слишком велик.

Подняв тему с перечислениями среди коллег, я получил поддержку. Оказалось, что не только я не доволен отсутствием единого подхода по работе с ними. В тот момент, мне привиделось, что за пару часов кодинга я достигну желаемого результата и вызвался исправить сложившуюся ситуацию. Но как я тогда ошибался…

// МОЯ ФАНТАЗИЯ на тему, как должны выглядеть перечисления в нашем новом решении.
import {Enum} from "ts-jenum";

@Enum("text")
export class State {

    static readonly NEW = new State("New");
    static readonly ACTIVE = new State("Active");
    static readonly BLOCKED = new State("Blocked");

    private constructor(public text: string) {
        super();
    }
}

// Пример использования
console.log("" + State.ACTIVE);        // Active
console.log("" + State.BLOCKED);       // Blocked
console.log(State.values());           // [State.NEW, State.ACTIVE, State.BLOCKED]
console.log(State.valueOf("New"));     // State.NEW
console.log(State.valueByName("NEW")); // State.NEW
console.log(State.ACTIVE.enumName);    // ACTIVE

1. Декоратор


С чего начать? На ум приходило только одно: взять за основу Java-подобное перечисление. Но так как мне хотелось выпендриться перед коллегами, я решил отказаться от классического наследования. Вместо него воспользоваться декоратором. Декоратор к тому же, можно было бы применить с аргументами, для того, чтобы придать перечислениям требуемую функциональность легко и непринужденно. Кодинг не отнял много времени и через пару часиков я уже имел, что-то похожее на это:

Декоратор
export function Enum(idProperty?: string) {
    // tslint:disable-next-line
    return function <T extends Function, V>(target: T): T {
        if ((target as any).__enumMap__ || (target as any).__enumValues__) {
            const enumName = (target as any).prototype.constructor.name;
            throw new Error(`The enumeration ${enumName} has already initialized`);
        }
        const enumMap: any = {};
        const enumMapByName: any = {};
        const enumValues = [];
        // Lookup static fields
        for (const key of Object.keys(target)) {
            const value: any = (target as any)[key];
            // Check static field: to be instance of enum type
            if (value instanceof target) {
                let id;
                if (idProperty) {
                    id = (value as any)[idProperty];
                    if (typeof id !== "string" && typeof id !== "number") {
                        const enumName = (target as any).prototype.constructor.name;
                        throw new Error(`The value of the ${idProperty} property in the enumeration element ${enumName}. ${key} is not a string or a number: ${id}`);
                    }
                } else {
                    id = key;
                }
                if (enumMap[id]) {
                    const enumName = (target as any).prototype.constructor.name;
                    throw new Error(`An element with the identifier ${id}: ${enumName}.${enumMap[id].enumName} already exists in the enumeration ${enumName}`);
                }
                enumMap[id] = value;
                enumMapByName[key] = value;
                enumValues.push(value);
                Object.defineProperty(value, "__enumName__", {value: key});
                Object.freeze(value);
            }
        }
        Object.freeze(enumMap);
        Object.freeze(enumValues);
        Object.defineProperty(target, "__enumMap__", {value: enumMap});
        Object.defineProperty(target, "__enumMapByName__", {value: enumMapByName});
        Object.defineProperty(target, "__enumValues__", {value: enumValues});
        if (idProperty) {
            Object.defineProperty(target, "__idPropertyName__", {value: idProperty});
        }
        // методы values(), valueOf и др потерялись во времени, но жили здесь когда-то.
        Object.freeze(target);
        return target;
    };
}

И здесь меня постигла первая неудача. Оказалось, что с помощью декоратора нельзя изменить тип. На эту тему у Microsoft есть даже обращение: Class Decorator Mutation. Когда я говорю, что нельзя изменить тип, я имею ввиду, что Ваша IDE ничего об этом не узнает и никаких подсказок и адекватных автодополнений не предложит. А тип менять можно сколько угодно, только толку от этого…

2. Наследование


Как я не старался уговаривать самого себя, но мне пришлось вернуться к идее создания перечислений на базе общего класса. Да и что в этом такого? Я был раздосадован самим собой. Время то идет, парни из группы фигачат дай бог, а я тут на декораторы время трачу. Можно было вообще за час запилить enum и идти дальше. Значит так тому и быть. Быстро накидал код базового класса Enumerable и вздохнул, почувствовав облегчение. Закинул драфт в общий репозиторий и попросил коллегу проверить решение.

Enumerable
// ПРИМЕЧАНИЕ: этот код примерно так выглядел, но что-то я из него потерял
export class Enumerable<T> {

    constructor() {
        const clazz = this.constructor as any as EnumStore;
        if (clazz.__enumMap__ || clazz.__enumValues__ || clazz.__enumMapByName__) {
            throw new Error(`It is forbidden to create ${clazz.name} enumeration elements outside the enumeration`);
        }
    }

    static values<T>(): ReadonlyArray<T> {
        const clazz = this as any as EnumStore;
        if (!clazz.__enumValues__) {
            throw new Error(`${clazz.name} enumeration has not been initialized. It is necessary to add the decorator @Enum to the class`);
        }
        return clazz.__enumValues__;
    }

    static valueOf<T>(id: string | number): T {
        const clazz = this as any as EnumStore;
        if (!clazz.__enumMap__) {
            throw new Error(`${clazz.name} enumeration has not been initialized. It is necessary to add the decorator @Enum to the class`);
        }
        const value = clazz.__enumMap__[id];
        if (!value) {
            throw new Error(`The element with ${id} identifier does not exist in the $ {clazz.name} enumeration`);
        }
        return value;
    }

    static valueByName<T>(name: string): T {
        const clazz = this as any as EnumStore;
        if (!clazz.__enumMapByName__) {
            throw new Error(`${clazz.name} enumeration has not been initialized. It is necessary to add the decorator @Enum to the class`);
        }
        const value = clazz.__enumMapByName__[name];
        if (!value) {
            throw new Error(`The element with ${name} name does not exist in the ${clazz.name} enumeration`);
        }
        return value;
    }

    get enumName(): string {
        return (this as any).__enumName__;
    }

    toString(): string {
        const clazz = this.constructor as any as EnumStore;
        if (clazz.__idPropertyName__) {
            const self = this as any;
            return self[clazz.__idPropertyName__];
        }
        return this.enumName;
    }
}

Но трагикомедия набирала полный ход. У меня на машине был установлен TypeScript версии 2.6.2, именно та версия, в которой имелся бесценный баг. Бесценный, потому что не баг, а фитча. Голос из соседней комнаты прокричал, что у него ничего не собирается. Ошибка при компиляции (транспиляции). Я не поверил собственным ушам, ибо всегда собираю проект, перед пушем, даже если это драфт. А внутренний голос прошептал: это фиаско, братан.

После недолгих разбирательств понял, что дело в версии TypeScript. Оказалось, что если имя дженерика у класса совпадало с именем дженерика указанного в статическом методе, то компилятор рассматривал это как один тип. Но как бы там не было, сейчас это уже часть истории той войны за знание TypeScript.

В сухом остатке: проблема с перечислениями как была так и осталась. Моя печаль…

Примечание: не могу воспроизвести такое поведение у себя сейчас с 2.6.2, возможно с версией ошибся или чего-то не дописал в тестовых примерах. А запрос на описанную выше проблему Allow static members to reference class type parameters был отклонен.

3. Функция кастования


Несмотря на то, что кривое решение имелось, с явным указанием типа класса перечисления в статических методах, например так, State.valueOf<State>(), оно никого не устраивало и прежде всего меня. На некоторое время я даже отложил в сторону долбаные перечисления и потерял уверенность в том, что вообще смогу решить эту проблему.

Морально восстановив силы, я порыскал в интернете трюки с TypeScript, посмотрел кто от чего страдает, почитал документацию по языку еще раз, на всякий случай, и решил, во чтобы то не стало, довести дело до конца. Семь часов непрерывных экспериментов, не отрываясь ни на что, даже на кофе, дали свой результат. Всего одна функция, состоящая из одной строчки кода, расставила все на свои места.

export function EnumType<T>(): IStaticEnum<T> {
    return (<IStaticEnum<T>> Enumerable);
}
// где IStaticEnum это:
export interface IStaticEnum<T> {

    new(): {enumName: string};

    values(): ReadonlyArray<T>;

    valueOf(id: string | number): T;

    valueByName(name: string): T;
}


А само объявление Java-подобного перечисления теперь выглядит так:

import {Enum, EnumType, IStaticEnum} from "ts-jenum";

@Enum("text")
export class State extends EnumType<State>() {

    static readonly NEW = new State("New");
    static readonly ACTIVE = new State("Active");
    static readonly BLOCKED = new State("Blocked");

    private constructor(public text: string) {
        super();
    }
}

// Пример использования
console.log("" + State.ACTIVE);        // Active
console.log("" + State.BLOCKED);       // Blocked
console.log(State.values());           // [State.NEW, State.ACTIVE, State.BLOCKED]
console.log(State.valueOf("New"));     // State.NEW
console.log(State.valueByName("NEW")); // State.NEW
console.log(State.ACTIVE.enumName);    // ACTIVE

Не обошлось и без курьеза, с лишним импортом IStaticEnum, который нигде не используется (см пример выше). В той самой злополучной версии TypeScript 2.6.2 нужно указывать его явно. Баг на тему здесь.

Итого


Если долго мучаться, что-нибудь получится. Ссылка на гитхаб с результатом проделанной работы здесь. Для себя я открыл, что TypeScript — это язык с большими возможностями. Этих возможностей так много, что в них можно утонуть. А кто не хочет идти ко дну, учится плавать. Если вернуться к теме перечислений, то можно увидеть как с ними работают другие:


Пишите о своих наработках, думаю сообществу будет интересно. Всем спасибо за терпение и проявленный интерес.

Комментарии (20)


  1. Beyondtheclouds
    13.08.2018 14:31

    А почему не подошли встроенные enum / const enum?


    1. reforms Автор
      13.08.2018 15:10

      Вопрос не в том, что не подошел enum от TypeScript, мы также им пользуемся и он весьма полезен:

      • для него есть поддержка в switch/case выражениях
      • его удобно использовать в протокольных объектах

      Вопрос в том, что стандартный enum нельзя расширить до требуемой функциональности. Нам, например, нужно все 'наши' перечисления отображать в таблице тем цветом, который указан в конструкторе:
      @Enum("text")
      export class State extends EnumType<State>() {
      
          static readonly NEW = new State("Новый", Color.BLACK);
          static readonly ACTIVE = new State("Действующий", Color.GREEN);
          static readonly BLOCKED = new State("Blocked", Color.RED);
      
          private constructor(public text: string, public color:string) {
              super();
          }
      }
      

      Color — не единственный атрибут, который требуется нашей бизнес логике. Как правило это пара: code и text.


      1. oxidmod
        13.08.2018 16:15
        +1

        Мне кажется, или вы смешиваете бизнес-логику с логикой отображения?


        1. reforms Автор
          13.08.2018 17:27

          Есть такое, отрицать не буду. Но как показала практика, ущерб от этого смешивания — невелик или вообще отсутствует, а польза есть. Всего один класс EnumRenderer для отображения всех объектов такого типа.


          1. oxidmod
            13.08.2018 17:56
            +1

            А если по разному отразить надо на разных страницах?


            1. reforms Автор
              13.08.2018 18:11

              И такое тоже есть. Пока правда в одном единственном месте. На этот случай написан, свой Renderer.


              1. oxidmod
                14.08.2018 12:25

                А можно было изначально напилить рендерер, который принимает некую мапу с соответствием между значением энама и необходимыми вам штуками типа цвета\кода\етс и не заморачиваться более


      1. bm13kk
        13.08.2018 16:34

        Вы разрушаете всю пользу от енама. Енам — это перечесление. Что-то, что может даже не отображаться в хранимых данных, а влиять исключительно на компиляцию.


        То есть. Енам — это набор констант без значений. Как только появляются значения — нужны карты (map).


        1. Perlovich
          13.08.2018 16:44
          +1

          Они не разрушают пользу от енамов. Поля (разумеется, константные) в енамах — это удобно. Это широко используется, например, в Java. Позволяет избавиться от лишних условных конструкций в коде.


          1. bm13kk
            13.08.2018 16:54

            Null не разрушает пользу от указателей, говорили они.
            Гоуту удобно, говорили они.


            Нет никаких лишних инструкций, если отделать енам от значения.


            1. Perlovich
              13.08.2018 17:39

              Это вы зря. Во-первых, это реально удобно на практике. Если каждому элементу енама соответствует какое-то значение или даже поведение (в Java для enum элементов можно определить метод), то удобно этот аттрибут или поведение сохранить в самом енаме.

              Во-вторых, с теоретической точки зрения это тоже приветствуется. Таким образом без лишних телодвижений реализуются паттерны state и strategy, что позволяет заменить условную логику полиморфизмом. Вместо switch и if просто делегируем часть функциональности енаму. Про то, что полиморфизм > условная логика, в последнее десятилетие не писал только ленивый.

              Это отличная фича в Java. И мне этого очень не хватает в Typescript.


            1. Fedcomp
              14.08.2018 15:43

              В rust к примеру enum'ы тоже могут хранить дополнительные поля, причем один элемент может его иметь а другой может не иметь. Очень удобно на практике.


        1. gearbox
          14.08.2018 14:00

          в typescript можно делать enum на стрингах. Я к тому что в typescript оно работает немного не так (это набор констант без значений)


      1. PYXRU
        13.08.2018 17:30
        +3

        Поправьте если я ошибся, но мне кажется вы просто заменили HashMap в пару ключ значение и назвали это расширенных enum. Я просто не понимаю почему тогда нельзя объявить HashMap, где ключем будет один из enum значений, а значением набор code, text, color. Если же я неправильно понял, то можно описать требуемую enumа функциональность


        1. reforms Автор
          13.08.2018 18:13

          Я без примеров с Вашей стороны, не смогу показать разницы.


        1. AndreyRubankov
          14.08.2018 10:02
          +1

          Думаю, reforms просто привел не самый лучший пример. Да, действительно хранить свойства отображения в enum – не лучшее решение, придет требование вводить Theme и будет много боли. Все же emun — это про логику/состояние, а не про отображение/визуализацию.

          Более хороший пример: TimeUnit из java.
          В этом примере можно увидеть классический enum + некоторые связанные с ним методы.

          К примеру, это можно использовать для конфигов:

          { "duration": 3, "timeUnit": "HOURS" }

          const timeUnit = TimeUnit.valueOf(config.timeUnit || "MILLISECONDS");
          const durationMs = timeUnit.toMillis(config.duration);
          


          В случае с классическим enum, нужно было бы описывать сам enum и плодить пачку независимых функций для конвертации, и тут уже все будет зависеть от парадигмы программирования: OOP vs FP.


  1. serf
    13.08.2018 19:05
    +4

    TypeScript тем и хорош что придает коду строгость, а вы это теряете повсеместно используя сырые типы (как any, или просто string). Вместо type-safety на этапе разработки по-прежнему работаете «в стиле JS» делая рантайм проверки (throw new Error). Я бы сказал что подобное использовать в продакешене вреднее чистого JS, тк в случае JS хотябы ясно что это чистая динамика, а здесь может сложится ошибочное мнение о высоком уровне type-safety. Если не получается задизайнить решение на TS с высоким уровнем type-safety, то может быть вообще не стоит это делать на TS.


    1. reforms Автор
      14.08.2018 00:11

      Давайте разбираться вместе, по пунктам. Моя правда такова:

      TypeScript тем и хорош что придает коду строгость, а вы это теряете повсеместно используя сырые типы (как any, или просто string)

      1. Про какую повсеместность Вы говорите? Если об этапе использования указанного Enum, то как раз все и затевалось, чтобы конкретный класс енума, в примере, State, обладал всеми свойствами базового класса, но строгой типизацией под свои нужды. Проверить легко, в IDE набираем State+точка и смотрим что она предлагает.
      2. Если речь идет о самой реализации декоратора/базового класса — Ваша правда, но дело поправимое: ждем commit от меня на гитхабе.
      3. Почему string это сырой тип?
      Вместо type-safety на этапе разработки по-прежнему работаете «в стиле JS» делая рантайм проверки (throw new Error).

      В моем случае, проверки (throw new Error) и «стиль JS» никак не связаны. На это есть свои причины:
      // Пример 1: лишний декоратор - ошибка при мерже веток после рефакторинга объекта State
      @Enum("text") 
      @Enum("code") 
      class State extends EnumType<State> {
          // ...
      }
      // Пример 2: забыли декоратор - неудачная копипаста 
      class State extends EnumType<State> {
          // ...
      }
      // Пример 3 (js-code): создание экземпляра объекта вне области класса - защита от дурака
      // функция конструктор класса State
      var StateType = require("moduleName/state").State;
      var deleteState = new StateType("Удален", Color.BLUE);
      

      Я бы так сказал: все идет от декоратора, который пока не умеет менять тип исходного объекта.
      Надеюсь дело поправимое Class Decorator Mutation
      … а здесь может сложится ошибочное мнение о высоком уровне type-safety

      Да. Я и команда знаем это. Но дух самой идеи декоратора таков, что легко можно накосячить. И не важно как и где вы их применяете. Примеры 1 и 2 тому подтверждение, или вот Enum(«incorrect_field_name»)
      Если не получается задизайнить решение на TS с высоким уровнем type-safety, то может быть вообще не стоит это делать на TS

      Здесь не вижу проблемы. Даже если не удастся мне прийти в решении к type-safety, есть тесты в конце концов. В целом предложение странное. Многие вещи сейчас не типизированы так как хочется. Например, связка vue-class-component и миксины, элементы самого vue (кто в теме $refs.myComponent) и многое другое. Я не в коем случае не говорю, что это хорошо, но однозначно не является причиной, чтобы отказываться от такого мощного языка как TypeScript


  1. justboris
    13.08.2018 22:20
    +1

    Группе разработчиков дали задание разработать самый технологичный и современный стек, не особо ограничивая по срокам. Ну как тут обойтись без оверинжиниринга и своих велосипедов? ;)


    А если серьезно, то для вашей задачи присвоения дополнительных полей атрибутам есть два решения:


    1) Пользоваться обычными enum, а недостающие значения доставать через маппинг:


    enum State {
      NEW, ACTIVE, BLOCKED
    }
    
    const statesMap: Record<State, IStateData> = {
      [State.NEW]: { color: Color.BLACK, title: "New", value: State.NEW },
      // ...
    };
    
    function getState(state: State): IStateData {
      return statesMap[state];
    }
    
    console.log(getState(State.NEW).title); // "New"

    Плюс в том, что у разных модулей могут быть свои маппинги по какой-то собственной логикой. Про смежение бизнес-логики и отображения вам уже выше написали.


    2) Если уж очень хочется именно enum-классы, то и они уже написаны до вас: https://github.com/LMFinney/ts-enums Там более идеоматичное для typescript решение, без использования any, что плохо для строгой типизации и декораторов, которые еще экспериментальные, включаются флагом и их лучше не использовать в проектах на много лет и миллионов строк.


    1. reforms Автор
      14.08.2018 17:48

      Пользоваться обычными enum, а недостающие значения доставать через маппинг:

      Многие выше предлагают такой подход, но мало кто сравнивает и анализирует плюсы и минусы.
      Конечно, это тема для отдельной статьи, но если кратко пройтись по неудобству работы с мапой:
      — не все можно сделать с помощью мапы, см TimeUnit, (коммент выше от AndreyRubankov)
      — мапа, это все же не енум. Иногда требуется прикладная/функциональная завершенность: смотришь на объект и сразу все понимаешь.
      — подход с мапой более многословен, из твоего же примера в проде обращение к title будет выглядеть так: StateScope.getState(State.NEW).title vs State.NEW.title
      — работа в браузере и дебаг: встречаешь ты значение 77 при отладке, а шо це таке?
      И это только навскидку.
      У каждого инструмента есть свои преимущества и недостатки. Я в первую очередь попытался создать инструмент. А подходит он или нет решать Вам по конкретным обстоятельствам.

      Если уж очень хочется именно enum-классы, то и они уже написаны до вас...

      В статье упоминание об этом имеется.