Референтная прозрачность в TypeScript: почему чистый код — это не про эстетику, а про выживаемость проекта
Референтная прозрачность в функциональном программировании — это не снобизм. Это архитектурный принцип, который радикально упрощает жизнь как разработчикам, так и ИИ-агентам, заставляя код быть честным о своих зависимостях и побочных эффектах.
Референтная прозрачность — это не про чистоту ради чистоты. Это про то, чтобы смысл строки кода не зависел от невидимой истории, которую невозможно удержать в голове ни человеку, ни нейросети.
Когда мы говорим о функциональном программировании, часто вспоминаем иммутабельность, монады или стрелочные функции. Но есть свойство куда важнее, хотя и звучит академично: референтная прозрачность. Если выражение можно заменить его результатом, не изменив поведение программы, — оно референтно прозрачно. Звучит как определение из учебника, но на практике это один из самых мощных инструментов для управления сложностью в реальных продакшн-системах.
Зачем это нужно? Все просто: чем меньше скрытого контекста нужно знать, чтобы понять фрагмент кода, тем легче его читать, тестировать и менять. Это критично и для людей, которые занимаются ревью пулл-реквестов или отладкой инцидентов, и для ИИ-агентов, которые работают в ограниченном контекстном окне. У людей и моделей разные «мозги», но есть общее ограничение: обеим категориям трудно, когда поведение строки зависит от невидимого состояния.
Практический тест: подстановка вместо умозрительных рассуждений
Как проверить, прозрачно ли выражение? Самый простой способ — мысленный эксперимент с подстановкой. Возьмем функцию нормализации email:
const normalizeEmail = (email: string): string =>
email.trim().toLowerCase();
Любое место, где написано normalizeEmail(" ADA@EXAMPLE.COM "), можно спокойно заменить на строку "ada@example.com". Ничего не сломается. Функция не лезет в глобальные переменные, не смотрит на часы, не мутирует аргументы, не пишет логи и не зависит от количества своих предыдущих вызовов.
А теперь вопрос, который стоит задать при ревью: «Какую информацию, кроме аргументов, мне нужно знать, чтобы предсказать результат этого выражения?». Если честный ответ — «никакую», значит, код локально понятен. Если ответ включает историю объекта, переменные окружения, содержимое БД, текущее время или исключение, брошенное тремя слоями ниже, — референтной прозрачности тут нет.
Важно понимать: это не вопрос синтаксиса. Стрелочная функция () => crypto.randomUUID() не является прозрачной, потому что два ее вызова дадут разные значения. А статический метод Price.addTax(amount, rate) в классе вполне может быть чистым. Проблема не в class, а в невидимых входах и выходах.
Главная беда — скрытые зависимости
Часто в объектно-ориентированном коде зависимости видны в конструкторе, но их реальное влияние на поведение метода остается тайной. Рассмотрим типичный сервис регистрации:
class RegistrationService {
async register(input: RegisterInput) {
const existing = await this.repository.findByEmail(input.email);
if (existing) throw new ConflictException();
const user = { id: crypto.randomUUID(), createdAt: new Date(), ...input };
await this.repository.save(user);
this.logger.info("User registered");
return user;
}
}
Чтобы понять метод register, одного input недостаточно. Нужно знать реализацию репозитория и его историю мутаций, системные часы и генератор случайных чисел, поведение исключений каждого await, поведение логгера, а еще — что произойдет при частичном выполнении (например, если сохранение удастся, а логирование — нет). Конструктор перечислит repository и logger, но тип метода скрывает все остальное.
Функциональный подход не делает эти проблемы исчезнувшими. Он заставляет их проявиться в сигнатуре:
type RegistrationContext = {
users: UserRepository;
clock: Clock;
ids: IdGenerator;
logger: Logger;
};
const register = (
dto: RegisterDto,
): RTE.ReaderTaskEither<RegistrationContext, RegistrationError, User> =>
pipe(
assertEmailAvailable(dto.email),
RTE.chainW(() => buildUser(dto)),
RTE.chainW(UserRepository.save),
RTE.tapRTE(logRegistration),
);
Теперь сигнатура — это компактное описание: нужны RegistrationContext, выполнение асинхронное и ленивое, RegistrationError — ожидаемый результат, успех порождает User. Вызов функции конструирует программу, но еще никого не регистрирует.
Эффекты должны быть описаны, а не выполнены немедленно
Бэкенд, который ничего не делает, бесполезен. Он не может сохранить данные, отправить ответ, списать деньги или вызвать модель. Цель функционального подхода — не «всё должно быть чистым», а: держать описание эффектов референтно прозрачным, а выполнять их на явных границах.
TaskEither<E, A> — это функция, которая возвращает промис с результатом Either. Заглушка (thunk) важна. Сравните два подхода:
// Жадный: запрос стартует прямо сейчас.
const result = prisma.user.findUnique({ where: { id } });
// Ленивый: это значение описывает, КАК запрос начать потом.
const result = () => prisma.user.findUnique({ where: { id } });
Второе выражение можно передавать, комбинировать, ретраить, замерять время или подменять в тесте до того, как что-то реально выполнится. База данных остается сайд-эффектом, но теперь у эффекта есть тип, точка конструирования, преобразование ошибок и интерпретатор.
Четыре уровня прозрачности в реальном проекте
Большие приложения перестают быть просто «чистыми» или «грязными». Аудит реального TypeScript-бэкенда (около 1500 файлов, NestJS, Prisma, Zod, очереди) выявил четыре уровня:
- Чистая трансформация (
calculateScore(profile, criteria)). Одни и те же входы всегда дают один результат. - Конструирование программы (
evaluateProfile(dto): RTE<Context, Error, Evaluation>). Строит ленивое описание без запуска эффектов. - Интерпретатор эффектов (адаптеры Prisma, HTTP, файловой системы, очередей). Выполняет эффекты под явными функциональными контрактами.
- Граница фреймворка (контроллер NestJS, воркер, точка входа). Поставляет контекст, выполняет программу один раз и переводит результат во внешний мир.
Зависимости должны двигаться от границы к программам и чистым значениям. Выполнение идет в обратном направлении: граница снабжает способностями и запускает готовую программу. Это честнее, чем утверждать, что весь бэкенд функционально чист.
Выигрыш для людей: локальное мышление и честные ошибки
Локальное понимание. Ревьюер может понять чистую функцию из ее сигнатуры, реализации и тестов. Ему не нужно искать мутации в конструкторе, хуки жизненного цикла или скрытые синглтоны.
Тесты как документация поведения. Тесты для чистого домена — это обычные проверки «вход-выход». Для прикладных программ нужны «способности», но не контейнер фреймворка. Тестовый сценарий становится исполняемой документацией того, что нужно юз-кейсу.
Ошибки перестают быть фольклором. С исключениями разработчики узнают о них из документации, опыта или инцидентов. Тип TaskEither<DatabaseError | RecordConflict, Profile> делает ожидаемые ошибки частью интерфейса. Это не гарантирует хорошего дизайна, но делает сбои видимыми.
Безопасный рефакторинг. Если две функции реализуют один контракт, одну можно заменить другой. Это основа рефакторинга, и это причина, почему «способности» как записи функций так удобны: реальный адаптер и тестовый дубль удовлетворяют одному интерфейсу.
Отладка с меньшим числом возможных историй. Мутабельные объекты накапливают историю. Если результат неверен, вопрос становится: «какая предыдущая операция изменила этот объект?». Чистые трансформации сужают вопрос до: «какой вход или правило дало этот выход?». Разница колоссальна во время инцидентов.
Выигрыш для ИИ: семантическая компрессия и предсказуемость
ИИ-модель не «понимает» код как человек. Она предсказывает и рассуждает на основе предоставленного контекста. Референтная прозрачность улучшает качество этого контекста.
Семантическая компрессия. Сигнатура ReaderTaskEither<PaymentContext, PaymentError, Receipt> сжимает несколько фактов в одном месте. Агент может вывести, что нужно получить PaymentContext, сохранить ленивое вычисление, обработать PaymentError и получить Receipt. С нетипизированным async-методом эти факты размазаны по конструкторам, исключениям, мокам и точкам вызова.
Меньше пространство для поиска. Когда функция зависит только от аргументов, агент может модифицировать ее, опираясь на локальные свидетельства. Скрытое глобальное состояние вынуждает исследовать весь репозиторий и увеличивает число правдоподобных, но неверных изменений.
Единообразные правила трансформации. map, chain, mapLeft, orElse, provide образуют небольшую грамматику. Как только агент определил текущий контейнер и тип следующей функции, набор допустимых трансформаций оказывается ограниченным.
Проверяемый вывод. Детерминированные функции и записи способностей упрощают генерацию целенаправленных тестов. Тесты становятся механизмом обратной связи, отвергающим семантически неверный код.
Правила для кодовой базы, где работают и люди, и ИИ
Исходя из опыта аудита, можно вывести несколько рабочих правил:
- Спрашивайте, что скрыто. Считайте время, случайность, конфигурацию, логирование и мутабельные кэши зависимостями.
- Отделяйте парсинг от создания. Валидация не должна тайно генерировать идентификаторы или читать часы.
- Возвращайте описания эффектов. Не запускайте промисы при построении программы.
- Моделируйте ожидаемые ошибки как данные. Оставьте исключения для внешних границ.
- Называйте бизнес-шаги. Основной пайплайн должен читаться как рецепт, а не как головоломка.
- Декодируйте на каждой границе доверия. Тела HTTP-запросов, строки из БД, сообщения из очередей, ответы ИИ — всё это ненадежные значения.
- Выполняйте один раз. Снабжайте полным контекстом и запускайте программу в контроллере, воркере, CLI или тесте.
- Тестируйте подстановку. При фиксированных аргументах и способностях повторное выполнение должно давать один наблюдаемый результат.
- Аудируйте лазейки. Каждое
new Date(), случайный ID, глобальное чтение,throwи прямой промис — полезная цель для поиска. - Оптимизируйте для следующего читателя. И люди, и агенты больше выигрывают от явных имен, чем от хитрой плотности комбинаторов.
Цена и смысл: ради чего всё это
У функциональной архитектуры есть цена: команде нужно учиться различать map, chain, апликативную композицию и выполнение. Типовые ошибки с вложенными контекстами и объединениями ошибок могут пугать. Избыточный point-free стиль может убирать полезные имена. Ленивые промисы удивляют привыкших к жадным.
Но ответ — не максимальная абстракция, а минимальная, которая держит важное поведение явным. Используйте обычную функцию для чистого вычисления. Either — когда синхронная валидация может упасть. TaskEither — для ленивого асинхронного провала. Добавляйте Reader, когда вычислению реально нужны способности.
Суть не в том, чтобы каждая строка выглядела функционально. Суть в том, чтобы бизнес-смысл был подстановочным, а эффекты — честными.
Референтная прозрачность не гарантирует, что человек или ИИ напишут правильный код. Она дает кодовую базу, поведение которой меньше зависит от невидимой истории. Это не просто идеал функционального программирования. Это протокол сотрудничества между кодом, человеком, который читает его сегодня, и любой разумной системой, которая будет его менять завтра.