Использование приватных свойств класса для усиления типизации в typescript

Kate

Administrator
Команда форума
Вот за что я люблю typescript, так это за то что он не даёт мне пороть ерунду. Измерить длину числового значения и проч. Поначалу я конечно плевался, возмущался что ко мне пристают со всякими глупыми формальностями. Но потом втянулся, полюбил пожёстче. Ну в смысле a little bit more strict. Включил в проекте опцию strictNullChecks и три дня потратил на устранение возникших ошибок. А потом с удовлетворением радовался, отмечая как легко и непринуждённо проходит теперь рефакторинг.
Но потом хочется чего-то еще большего. И тут уже тайпскрипту нужно объяснить, какие ограничения ты накладываешь сам на себя, а ему делегируешь обязанность следить за соблюдением этих ограничений. Давай, ломай меня полностью.

Пример 1​

Некоторое время назад меня захватила идея использования react в качестве шаблонизатора на сервере. Захватила конечно же возможностью типизации. Да, существуют всякие там pug, mustache и что там ещё. Но разработчику приходится самому держать в голове, а не забыл ли он расширить новыми полями аргумент, передаваемый в шаблон. (Если это не так, поправьте меня. Но мне вобщем то всё равно - слава богу мне не приходится заниматься генерацией шаблонов по роду своей деятельности. Да и пример про другое).
А тут мы можем нормально типизировать пропсы, передаваемые в компонент, и получать соответствующие подсказки IDE при редактировании шаблона. Но это внутри компонента. А теперь давайте проконтролируем, что не передали в этот компонент какую-нибудь левоту.
import { createElement, FunctionComponent, ComponentClass } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

export class Rendered<P> extends String {
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P) {
super('<!DOCTYPE html>' + renderToStaticMarkup(
createElement(component, props),
));
}
}
Теперь если мы попытаемся передать в компонент пользователя пропсы от заказа - нам незамедлительно укажут на это недоразумение. Круто? Круто.
Но это в момент генерации html. Как же дела обстоят с дальнейшим его использованием? Т.к. результатом инстацирования Rendered является просто строка, то typescript не будет ругаться например на такую конструкцию:
const html: Rendered<SomeProps> = 'Typescript cannot into space';
Соответственно, если мы напишем примерно такой контроллер:
@Get()
public index(): Rendered<IHelloWorld> {
return new Rendered(HelloWorldComponent, helloWorldProps);
}
это никак не гарантирует, что из этого метода будет возвращен именно результат компиляции компонента HelloWorldComponent.
Ну так давайте сделаем, чтобы это была не просто строка :)
export class Rendered<P> extends String {
_props: P;
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P)
...
Тут уже 'cannot into space' не прокатит, ведь у строки нет свойства _props. Уже есть прогресс. Вероятность нечаянно вернуть что-то не то сильно уменьшается. Что так же мне нравится - мы никак не используем свойство _props, и соответственно оно не попадает в скомпиллированный js код и не перегружает ответ сервера, т.е. остаётся "виртуальным" усилителем проверки типов.
Но всё еще прокатит вариант
Object.assign('cannot into space', {_props: 42})
Да, это больше похоже на саботаж чем на досадную случайность. Но давайте защитимся и от этого кейса.
export class Rendered<P> extends String {
// @ts-ignore - не случай если у вас включен noUnusedParameters
private readonly _props: P;
constructor(component: FunctionComponent<P> | ComponentClass<P>, props: P)
...
Теперь даже результат вызова Object.assign нам не дадут вернуть из контроллера, т.к. в классе Rendered поле _props приватное, а в самодельном объекте публичное.
Довольно забавное наблюдение, что приватные поля класса, о существовании которых можно и не узнать снаружи, так же участвуют в проверке типов. Не думаю, что это проектировалось для использования в целях, похожих на мой пример. Но данный ход конём может помочь защититься от некоторых досадных ошибок.

Пример 2​

Давайте представим, что у нас есть некий класс, который соотносится с некоторым объектом той предметной области, задачи которой мы автоматизируем. И у этого класса имеется некоторый набор полей - для простоты пусть всё это будут булевые флаги-признаки. Технически значения этих свойств могут сочетаться как угодно. Но для наших бизнес процессов не все такие сочетания имеют смысл или даже просто недопустимы.
Новый человек на проекте может быть очень грамотным техническим специалистом. Но чтобы вникнуть в предметную область, может понадобиться значительное количество времени. А задачи решать нужно будет сразу.
Чтобы защитить проект от создания таких невалидных объектов, можно подготовить некоторое количество классов-наследников, которые уже будут содержать необходимые сочетания описанных свойств.
Давайте рассмотрим такой подход на примере класса ApiResponse. Это конечно не очень похоже на какую-то модель доменной области, но поможет проиллюстрировать некорректные сочетания параметров.
export interface IApiResponse {
readonly scenarioSuccess: boolean;
readonly systemSuccess: boolean;
readonly result: string | null;
readonly error: string | null;
readonly payload: string | null;
}

export class ApiResponse implements IApiResponse {
constructor(
public readonly scenarioSuccess: boolean,
public readonly systemSuccess: boolean,
public readonly result: string | null = null,
public readonly error: string | null = null,
public readonly payload: string | null = null,
) {}
}
При успешном выполнении операции будем ставить scenarioSuccess в true. Если наша логика отработала корректно, но пользователю нужно отказать (например введённый пароль неверен) - будем ставить scenarioSuccess в false. А если мы не смогли проверить пароль например потому что база отвалилась - будем ставить systemSuccess в false. Сообщения об успехе/неудаче будем отдавать в полях result/error. Интерфейс конечно подраздут. Зато хорошо видно, что можно например выставить scenarioSuccess true и непустое значение error.
Чтобы не допускать некорректных сочетаний свойств, давайте пометим класс ApiResponse как абстрактный, и наплодим для него наследников:
export class ScenarioSuccessResponse extends ApiResponse {
constructor(result: string, payload: string | null = null) {
super(true, true, result, null, payload);
}
}
и так далее.
Пометим возвращаемый тип данных для какого-нибудь нашего сервиса из слоя доменной логики как ApiResponse, и чтобы защититься от "ручной сборки" возвращаемых объектов в обход наших классных любовно подготовленных моделек, применим уже знакомый трюк с приватным свойством. Но на этот раз пойдём дальше.
const SECRET_SYMBOL = Symbol('SECRET_SYMBOL');

export abstract class ApiResponse implements IApiResponse {
// @ts-ignore
private readonly [SECRET_SYMBOL]: unknown;

constructor(
public readonly scenarioSuccess: boolean,
public readonly systemSuccess: boolean,
public readonly result: string | null = null,
public readonly error: string | null = null,
public readonly payload: string | null = null,
) {}
}
Если для обхода класса Rendered можно было создать новый класс с приватным полем _props, то теперь приватное свойство является вычисляемым, а обратиться к нему можно через символ, который мы не экспортируем из модуля. И соответственно "повторить" его не получится. По крайней мере в другом файле. (Вам тоже кажется, что это попахивает паранойей?)
Ну что ж. Такое обойти можно, пожалуй, только через any. Но против лома нет приёма.
Тут ещё можно заметить, что общение между компонентами системы должно быть развязано через интерфейсы. Но принимающей стороне вполне можно прописать, что она ожидает IApiResponse, а вот сервис из слоя доменной логики так уж и быть, пусть возвращает конкретную реализацию ApiResponse.

Well...​

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


Источник статьи: https://habr.com/ru/post/556616/
 
Сверху