Референциальная прозрачность в TypeScript: почему понятный код — это не роскошь, а необходимость
Референциальная прозрачность — это не абстрактное академическое понятие, а практический инструмент, который упрощает жизнь и разработчикам, и ИИ-агентам, работающим с кодовой базой.
Самое ценное свойство функционального программирования — не иммутабельность и не монады, а способность предсказывать поведение кода, зная только его входы.
Проблема, которую мы не замечаем
Представьте типичный ревью пул-реквеста. Вы открываете файл, читаете функцию — и тут начинается археология. Что делает this.repository? Когда был создан объект, который передали аргументом? Какой сейчас час в контейнере? Не упадёт ли этот await в неожиданном месте? Вы вынуждены реконструировать историю состояния из головы, документации и production-логов.
А теперь представьте, что тот же код читает не человек, а ИИ-агент с ограниченным контекстным окном. У него нет интуиции, нет опыта работы с этой конкретной системой. Он может предсказать поведение только на основании того, что видит прямо сейчас. Если смысл строки зависит от невидимой истории — ни человек, ни модель не справятся одинаково плохо.
Именно это утверждает автор обширного разбора на dev.to, опираясь на аудит реального TypeScript-бэкенда — порядка 1500 исходных файлов, с NestJS, Prisma, очередями, внешними API и 75 тестовыми наборами. Система не идеальна, и в этом её ценность: она показывает, где прозрачность работает, а где ломается.
Что такое референциальная прозрачность — без академизма
Формулировка проста: выражение референциально прозрачно, если его можно заменить на вычисленное значение без изменения поведения программы. Звучит математически? Зато тест проверки — прикладной.
Вот минимальный пример:
const normalizeEmail = (email: string): string =>
email.trim().toLowerCase()
Каждое вхождение normalizeEmail(" ADA@EXAMPLE.COM ") можно безопасно заменить на "ada@example.com". Ничего не изменится. Функция не читает глобальные переменные, не смотрит на часы, не мутирует аргумент, не пишет в лог.
Практический вопрос при ревью звучит так: какую информацию, помимо аргументов, я должен знать, чтобы предсказать результат этого выражения? Если ответ «никакую» — выражение понятно локально. Если ответ включает историю объекта, переменные окружения, содержимое базы данных, текущее время или исключение, пойманное тремя слоями ниже — прозрачность нарушена.
При этом синтаксис не гарантирует ничего:
const nextId = () => crypto.randomUUID()
Стрелочная функция? Да. Прозрачная? Нет. Два вызова дадут разные значения — замена на ранее вычисленный результат сломает программу.
И наоборот, метод класса может быть абсолютно чистым:
class Price {
static addTax(amount: number, rate: number): number {
return amount + 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 недостаточно. Нужно знать реализацию репозитория и его мутации. Нужно знать, как работает генератор ID и системные часы. Нужно понимать, что произойдёт при исключении на каждом await. Нужно знать поведение логгера. Нужно понимать, что значит частичное выполнение — если сохранение прошло, а логирование упало.
Конструктор показывает repository и logger, но тип метода по-прежнему скрывает отказ, время, генерацию идентификаторов и политику исполнения. Это не dependency injection — это dependency obfuscation.
Функциональный подход не избавляет от этих зависимостей. Он заставляет вытащить их на свет:
type RegistrationContext = {
users: UserRepository
clock: Clock
ids: IdGenerator
logger: Logger
}
const register = (
dto: RegisterDto,
): ReaderTaskEither<RegistrationContext, RegistrationError, User> =>
pipe(
assertEmailAvailable(dto.email),
chain(() => buildUser(dto)),
chain(UserRepository.save),
tap(logRegistration),
)
Сигнатура теперь — компактное описание всего, что нужно знать: контекст исполнения, асинхронность, ожидаемые ошибки и тип результата. Функция не регистрирует пользователя — она конструирует программу, которая будет выполнена позже.
Описание эффекта — не то же самое, что его выполнение
Бэкенд, который ничего не делает, бесполезен. Он не сохранит данные, не отправит ответ, не спишет деньги. Поэтому цель функционального подхода — не «всё чистое», а «описание эффектов прозрачно, а выполнение происходит на явных границах».
TaskEither<E, A> — это функция, которая возвращает Promise<Either<E, A>>. Замыкание важно. Вот два варианта вызова репозитория:
// Сразу запускает запрос
const result = prisma.user.findUnique({ where: { id } })
// Описывает, как запрос будет запущен потом
const result = () => prisma.user.findUnique({ where: { id } })
Второе выражение можно передавать, комбинировать, повторять, тестировать — ничего не произойдёт, пока вы явно не решите выполнить. База данных по-прежнему побочная. Но её эффект теперь имеет тип, точку конструирования, трансляцию ошибок и интерпретатор.
Четыре уровня прозрачности
Аудит крупного бэкенда показал, что полезнее перестать делить код на «чистый» и «грязный». Вместо этого — четыре уровня:
- Чистая трансформация —
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 образуют малую грамматику. Как только агент определил текущий контейнер и тип следующей функции, множество допустимых трансформаций ограничено. Регулярность ценна для людей — но особенно ценна для систем генерации кода.
Проверяемый выход. Детерминированные функции и простые записи возможностей упрощают генерацию фокусных тестов. Агент может сравнивать значения вместо координации большой среды выполнения. Тесты становятся обратной связью, отвергающей правдоподобный, но семантически неверный код.
Надёжная передача контекста. Агенты часто работают короткими сессиями. Явные пайплайны сохраняют намерение в коде вместо нарратива предыдущего агента. Именованный шаг вроде assertProjectOwned передаёт более устойчивую информацию, чем инлайновое условие в контроллере.
Это не бенчмарк производительности, а архитектурные выводы. Референциальная прозрачность не делает агента правильным. Она убирает классы неоднозначности, которые усложняют рассуждения и людей, и машин.
Где прозрачность ломается: уроки реального аудита
Продакшен-система не была функциональной утопией. Именно отклонения оказались самыми полезными находками.
Время и идентичность внутри парсеров. Несколько доменных схем ставили значения по умолчанию вроде createdAt: z.date().default(() => new Date()). Некоторые конструкторы генерировали ID, если они не были предоставлены. Парсинг одного и того же входа дважды мог дать разные значения. Это ломает подстановку и смешивает две ответственности: валидацию данных и создание идентичности.
Чище — оставить парсинг детерминиров