Война с TypeScript или покорение Enum +9


Предыстория


Пол года назад в нашей компании было принято решение о переходе на более новые и модные технологии. Для этого сформировали группу специалистов, которая должна была: определиться с технологическим стеком, на базе этого стека сделать мост к легаси коду и, наконец, перевести часть старых модулей на новые рельсы. Мне посчастливилось попасть в эту группу. Клиентская кодовая база примерно в один миллион строк кода. В качестве языка мы выбрали 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 — это язык с большими возможностями. Этих возможностей так много, что в них можно утонуть. А кто не хочет идти ко дну, учится плавать. Если вернуться к теме перечислений, то можно увидеть как с ними работают другие:


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




К сожалению, не доступен сервер mySQL