В докладе Границы Гери Бернхардт рассказывает про типы в качестве границ приложения. А в Type-Driven Design in Swift Alex Ozun показывает конкретную реализацию через паттерны.
Для реализации этих идей на TypeScript есть библиотека True Myth.
Из их документации:
Quote
True Myth предоставляет стандартные, типобезопасные оболочки и вспомогательные функции, которые помогут вам справиться с двумя чрезвычайно распространенными случаями программирования:
- отсутствие значения;
- наличие значения result, в котором имеется дело либо с успехом, либо с неудачей.
Разработчики назвали библиотеку в дань уважения идеи, которую высказал Толкин о мифах.
Я подумал, что хочу последовать их примеру и для демонстрации базовых сценариев, можно реализовать один из артефактов из мира Средиземья. Ведь как сказал Артур Кларк:
Quote
Любая достаточно развитая технология неотличима от магии
Авторизация в Мории
В Морию через врата Дурина могут попасть только друзья. Гномы первой эпохи не особо запаривались над безопасностью, поэтому для прохода нужно просто сказать на эльфийском слово “друг”.
Я спроектировал тип странника так, чтобы он был либо другом, либо чужаком:
type Traveller = Stranger | Friend;
где Stranger
это пустой класс, потому что к нему нет пока никаких требований, им может быть любой:
class Stranger {}
а тип Friend
нельзя создать без передачи в него корректного кода
export class Friend {
code: CorrectCode;
constructor(params: Friend) {
this.code = params.code;
}
}
Убирая ts-синтаксис и оставив только логику можно сказать:
Friend = CorrectCode
Что собой представляет класс верного кода? В докладе Озуна такой класс называется Parsed Wrapper. Его нельзя создать, если не передана корректная строка.
Для этого нужно:
- Сделать приватным конструктор
- Реализовать статический метод, который по нашим условиям либо возвращает результат c экземпляром друга:
Result.ok(CorrectCode)
, либо ошибку:Result.err(AppError)
Result
import Result, { ok, err } from 'true-myth/result';
export class CorrectCode {
public code: Mellon;
static parse(code: string): Result<CorrectCode, AppError> {
if (code === 'mellon') {
return ok(new CorrectCode(code));
} else {
return err(CodeError.InvalidСode);
}
}
private constructor(code: Mellon) {
this.code = code;
}
}
ok(new CorrectCode(code))
- вернёт контейнерResult
с валидным объектом кода;err(error)
- вернёт контейнерResult
с ошибкой;
Но в обоих случаях вернётся Result, который дальше можно обработать.
Убирая синтаксическую мишуру:
CorrectCode = 'mellon'
Получается, что без корректной строки не создать тип валидного кода, а без него не создать друга, без друга не попасть в Морию:
'mellon' -> CorrectCode -> Friend -> entrance
Обработка Result
и создание инстанса друга:
const codeResult = CorrectCode.parse(code);
const traveller: Traveller = codeResult.isOk
? new Friend({ code: codeResult.value })
: new Stranger();
Границы выставлены достаточно чётко и можно удобно работать отдельно над каждой из них, уточняя детали и меняя правила.
Предположим, что у гномов в Мории появился продукт овнер и вот он, попивая смузи, говорит:
Quote
Ребят, всё конечно хорошо, но почему мэллон можно сказать только на латинице? Где вторая по популярности в интернете система письма?
Это легко поправить, заменив условие внутри класса CorrectCode
:
if (code === 'mellon') {
return ok(new CorrectCode(code));
}
на другое:
switch (code) {
case 'mellon':
case 'мэллон':
return ok(new CorrectCode(code));
}
unit-тест будет выглядеть так:
test('На кириллице', () => {
const codeResult = CorrectCode.parse('мэллон');
expect(codeResult.isOk).toBe(true);
expect(
codeResult.isOk && codeResult.value
).toBeInstanceOf(CorrectCode);
});
Снова залетает гном-продукт овнер:
Quote
Ребят, круто работаете, всё классно, на ревью поставлю вам 10 дуринов из 10. Но это вообще норм, что мы своих друзей по именам не знаем?
И мы понимаем, что формально Friend
уже не просто CorrectCode
, теперь он:
Friend = CorrectCode & Name
Но имя это не любая строка. К имени тоже есть требования. Описать их можно, создав уже знакомый ParsedWrapper:
import Result, { ok, err } from 'true-myth/result';
export class Name {
public string: string;
static parse(rawName: string): Result<Name, AppError> {
if (rawName.length === 0) return err(NameError.TooShort);
if (rawName.length > 20) return err(NameError.TooLong);
return ok(new Name(rawName));
}
private constructor(string: string) {
this.string = string;
}
}
Теперь нужно добавить его как правило для создания друга:
export class Friend {
code: CorrectCode;
name: Name;
constructor(params: Friend) {
this.code = params.code;
this.name = params.name;
}
}
В хэндлер входа добавляются соответственно строки с именем:
const codeResult = CorrectCode.parse(event.code);
const nameResult = Name.parse(event.name);
const traveller: Traveller =
codeResult.isOk && nameResult.isOk
? new Friend({ code: codeResult.value, name: nameResult.value })
: new Stranger();
Теперь мы можем гарантировать, что если мы где-то в приложении встретили инстанс друга, то у него точно есть код и имя, причём с валидными данными ещё и этих инстансов.
У Озуна в докладе есть про это отличная красно-зелёная картинка с фазами.
Это можно использовать, создавая приветствие. В презентаторе страницы приветствия следующий код:
export const welcomePresentation = (
friend: Friend
): WelcomeViewState =>
new WelcomeViewState({
greeting: `Wellcome ${friend.name.string}`,
});
Обратите внимание, что в презентатор можно передать только друга, то есть его не создать без правильного инстанса.
Внедрение контейнера Result
позволяет повысить надёжность приложения и снять лишнюю когнитивную нагрузку. Валидации в логике больше нет.
Maybe
На этот раз мы общаемся с гномьим дизайнером, который, под эльфийским модным влиянием, набросал леттеринг, что должен светится только при звёздном и лунном свете, но скрываться при солнечном. Wft?
Техлид начинает умничать, что луна отражает свет солнца, а солнце это тоже звезда и то, что нельзя так просто взять и… В конце концов действуют рестрикции на экспорт магии из кап. стран.
Но дизайнер кидает ссылку на лукошко-экспериментального-энтузиазма.io с прототипом, где с помощью спектрометра, и не очень глубокого анализа можно понять, что сейчас светит на стену.
И вот уже крутится сервис, который может прислать данные с аналитикой, что за небесное тело сейчас освещает стену. Нам нужно только, оперируя абстракциями, принимать решения.
Контракт такой:
type Light = Sunlight | Starlight | Moonlight
type LightDto = Light | null
То есть мы можем по типу понять, что сейчас светит за объект, если объект не классифицирован или свет недостаточен для анализа, придёт null
.
Для таких ситуаций, когда значение может быть, а может и нет, разработан контейнер Maybe
.
Он позволяет обернуть возможное значение. И если оно есть, то обработчики, будут запущены.
import { of } from 'true-myth/maybe';
const isIthildinShining = of(light).map(handleLight).unwrapOr(false)
of(light)
- возвращает объект типаMaybe
;map(handleLight)
- безопасно запускает функциюhandleLight
;unwrapOr(false)
- извлекает значение из контейнера, если значение не валидное, то вернётfalse
.
Это очень похоже на работу функции map
в js. Только в массивах она не запускается, если массив пустой, а тут, если значение null
.
Круто то, что handleLight
вообще не знает о том, что light
может не прийти, его опциональность не прорастает в код:
export const handleLight = (light: Light): boolean => {
switch (light.constructor) {
case Moonlight:
case Starlight:
return true;
default:
return false;
}
};
Думаю, на этом надо остановиться. В голову лезут идеи про гномов безопасников с предложением сделать двухфакторную аутентификцию через рунные камни с устаревающими ключами…
Мысли и комментарии
-
Maybe
иResult
- это классические специальные монады из функционального программирования. Их можно встретить под похожими или такими же именами в фп книжках; -
map
- это функтор. Тоже классическая штука из мира фп. В true-myth предоставлено ещё множество других со своими задачами; -
в книге “Функциональное программирование на JavaScript” в главе 5 приведена своя реализация
Result
иMaybe
. С методами, которые пересекаются с теми, что есть в библиотеке. Они настолько простые, что, как говорят сами создатели: “их может реализовать любой. Но зачем это делать, когда есть готовое протестированное решение?”; -
@tolstoymv скинул библиотеку с функциональными конструкциями для ts, которую использует он. Прикрепляю в качестве альтернативы;
-
описанные контейнеры имеют гораздо больше возможностей, чем я показал. С полным списком методов рекомендую ознакомиться в документации;
-
честно говоря, пока опасаюсь добавлять в рабочий код все конструкции, но например отделение
AnonymousUser
отSignedInUser
уже реализовано и отлично работает по тому же принципу в Ozon Acquiring; -
проблема интеграции функциональных решений не в том, что это сложно. Как раз таки очень просто и выразительно. Порог вхождения повышается. Проект начинает требовать повышения культуры разработки;
-
но всё же, любой джун повсеместно использует конструкции типа:
response.then((data) => { ... })
list.map(item => { ... })
arr.reduce(reducer)
Вдохновляет
-
То, что “Властелин Колец”, “Хоббит”, “Сильмариллион” и др. книги Толкина это побочный продукт от главного - создания языков. Любой язык это отражение производственных отношений, он не отделим от материального базиса. Толкин это прекрасно понимал и написал историю мира, в котором развивались его языки;
-
Отношение Питера Джексона к коллективной работе и творческому процессу. Вместо того, чтобы снять своё видение Средиземья, он обратился к фанбазе толкинистов, у которой уже было огромное множество наработок по тому, как должно всё выглядеть, звучать и работать. Именно поэтому получилось создать такое цельное и глубокое кино. Кстати, в титрах указали вообще всех причастных. Они идут почти 30 минут, Карл;
-
Идея сделать для разрабов то, что делают Awdee для дизайнеров.
Материалы
- Документация True Myth
- Функциональное программирование на JavaScript
- Доклад Boundaries
- Доклад Type-Driven Design in Swift
- Shadcn UI
Ссылки
Тэги
typeDrivenDesign,functionalProgramming,result,maybe,монады,функторы,ddd