Разбираем классы по косточкам или интроспектируем типы в Typescript

в 23:07, , рубрики: TypeScript, интроспекция, обобщённое программирование, ооп, Программирование

Разбираем классы по косточкам или интроспектируем типы в Typescript - 1

«Крутую ты штуку придумал, Стёпа», — сообщил мне коллега, осознав рассказанную ему идею. Надеюсь это действительно так, хоть и не скажу, что в том, о чём далее пойдёт речь, есть что-то безумно новаторское, однако, на мой взгляд, интерес данный материал всё же представляет.
Сегодня поговорим о применении интроспекции в разработке веб-интерфейсов, немного пошаманим с обобщённым программированием и изобретём велосипед в Typescript, имеющий похожий аналог в .NET.

Что мы знаем об интроспекции?

Википедия гласит, что это возможность запросить тип и структуру объекта во время выполнения программы.

Ну то есть имеется класс:

class Person {
    height: number;
    weight: number;
    bloodPressure: string;
}

Его объект определяется набором полей, каждое из которых имеет, по крайней мере, свой тип и значение.

Разбираем классы по косточкам или интроспектируем типы в Typescript - 2

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

const fields = ObjectFields.of(Person)

От теории к практике

Я, конечно, человек с замыленными мозгами, но в данной ситуации буду мыслить шаблонно. Вытянуть имена полей можно с помощью Object.keys, а типизировать это дело уже через keyof. Далее используя ключи, как индексы, получаем значения и данные о них.
Пропустив через себя эту информацию, можно выразить свои выводы следующим образом. Начнём с простого, описав некий тип, характеризующий поле объекта.

interface IObjectField<T extends object> {
    readonly field: keyof T;
    readonly type: string;
    readonly value: any;
}

Если задуматься, то можно увидеть, что это сильно напоминает FieldInfo. Правда я это понял в момент написания статьи :)
И сейчас самое время вспомнить, что Typescript — это не .NET. Например, создавать экземпляры объекта в контексте обобщённого программирования здесь можно только с помощью фабрик. То есть, как в C# не прокатит.
Если описывать конструктор некого класса, то получится приблизительно следующее.

interface IConstructor<T> {
    new(...args: any[]): T;
}

Хорошо, попробуем создать инструмент для интроспекции класса, который бы удовлетворял следующим требованиям:

  1. Всё, что у нас есть на входе — это конструктор изучаемого класса.
  2. На выходе мы получаем массив объектов типа IObjectField
  3. Сгенерированные данные — неизменяемы.

Вот теперь рассуждения в начале раздела переведены на язык Typescript.

class ObjectFields<T extends object> extends Array<IObjectField<T>> {
    readonly [n: number]: IObjectField<T>;
    constructor(type: IConstructor<T>) {
        const instance: T = new type();
        const fields: Array<IObjectField<T>> = (Object.keys(instance) as Array<keyof T>)
            .map(x => {
                const valueType = typeof instance[x];
                let result: IObjectField<T> = {
                    field: x,
                    type: valueType === 'object'
                        ? (instance[x] as unknown as object).constructor.name
                        : valueType,
                    value: instance[x]
                }
                return result;
            });
        super(...fields);
    }
}

Попробуем "прочитать" класс Person и выведем данные на экран.

const fields = new ObjectFields(Person);
console.log(fields);

Правда, вместо ожидаемого вывода получили пустой массив.

Разбираем классы по косточкам или интроспектируем типы в Typescript - 3

Как же так? Всё скомпилировалось и отработало без ошибок. Однако дело в том, что результирующий массив строится с помощью Object.keys, и поскольку в рантайме работает Javascript, то какой объект засунем, такой набор ключей и получим. А объект — пустой, вот и информация о типах, которую мы попытались извлечь, куда-то потерялась. Чтобы её "вернуть", необходимо инициализировать поля класса какими-то начальными значениями.

class Person {
    height: number = 80;
    weight: number = 188;
    bloodPressure: string = '120-130 / 80-85';
}

Вуаля — получили, что хотели.

Разбираем классы по косточкам или интроспектируем типы в Typescript - 4

Также протестируем более сложную ситуацию.

class Material {
    name = "wood";
}

class MyTableClass {
    id = 1;
    title = "";
    isDeleted = false;
    createdAt = new Date();
    material = new Material();
}

Результат превзошёл ожидания.

Разбираем классы по косточкам или интроспектируем типы в Typescript - 5

И что с этим делать?

Первое, что пришло в голову: CRUD приложения на react теперь можно писать, реализуя обобщённые компоненты. Например, нужно сделать форму для вставки в таблицу. Пожалуйста, никто не запрещает делать что-то такое.

interface ITypedFormProps<T extends object> {
    type: IConstructor<T>;
}

function TypedForm<T extends object>(props: ITypedFormProps<T>) {
    return (
        <form>
            {new ObjectFields(props.type).map(f => mapFieldToInput(f))}
        </form>
    );
}

И использовать потом этот компонент вот так.

<TypedForm
    type={Person} />

Ну и саму таблицу сделать по такому же принципу тоже возможно.

Подводя итоги

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

Автор: Степан Минин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js