Нередко в проектах необходимо заводить переменные окружения (environment variables). Существует много способов сделать это. Например, указать переменную "inline", как MY_VAR="my value" node index.js или обозначить источник командой source. Некоторые фреймворки имеют даже целые отдельные пакеты для формирования переменных окружения (прим. nest.js). Но чаще всего за годы работы в сфере фронтенд-разработки мне приходилось работать со способом, который подразумевает содержание .env файлов в проекте. Такие файлы имеют простейший синтаксис вида KEY=VALUE: Для чего вообще нужны эти переменные и какого рода данные в них стоит записывать? Ответ: какие-либо секретные значения, которые не стоит держать в репозитории. Пример: KEY_SECRET от стороннего API или данные для аутентификации БД; значения, которые различаются для разных режимов работы приложения. Пример: BASE_URL для: dev-стенда — https://dev.my-amazing-resource.com  test-стенда — https://stage.my-amazing-resource.com  production — https://my-amazing-resource.com параметры конфигурации или глобальные константы. Пример: PORT, PROXY_URL, FEATURE_TOGGLE и т.д. Таким образом, данные скрыты от чужих глаз и могут конфигурировать наше приложение в (и вне) зависимости от режима его работы. Удобно! А проблема в том, что использование env-переменных не гарантирует нам наличие значения и не дает понимание о его типе данных. Взгляните на следующий конфиг: Во-первых, может показаться, что APP_TITLE, APP_VERSION и SHOW_VERSION — это строка, число и логическое значение соответственно. На самом же деле ВСЕ эти значения являются строками. Во-вторых, имеется закомментированная переменная COMMENTED_REQUIRED_VALUE, которая неспроста названа именно так. Предположим, что это обязательное значение, без которого приложение не может функционировать. Что мы имеем по итогу: Для того, чтобы работать со значениями согласно задуманным им типам данных, необходимо сначала привести их к этим типам: const title = process.env.APP_TITLE; // all is ok const version = Number(process.env.APP_VERSION); const showVersion = JSON.parse(process.env.SHOW_VERSION); На этом этапе приложение может сломать самая малая опечатка в конфиге, например, False вместо false или 1,0 вместо 1. Стоит подумать на счет разнообразных проверок, обработки ошибки парсинга и прочем. Так как .env не дает никакой валидации данных, мы, вызывая переменную COMMENTED_REQUIRED_VALUE, которая считается обязательной и всегда должна быть прописана, НО почему-то осталась закомментированной, получаем ошибку. Окей, просто добавить еще проверок наличия обязательного значения, приведения типов, проверок типов. Добавили все необходимые приведения типов, проверки, и теперь код... Нет. Наверняка, вы уже самостоятельно додумали, что было дальше. Такое решение не очень-то красиво и удобно. Особенно, учитывая гораздо большее количество переменных, появление новых и другие аспекты. В этом разделе я хочу представить 🙌 хорошее 🙌 решение и более подробно разобрать сам подход, а в следующем разделе поговорим о его "доведенной до ума" версии — ✨ отличном ✨ решении. За основу будет взят проект, созданный Vite по шаблону react-swc-ts, что значит React + SWC + TypeScript. На самом деле, описываемый подход не зависит от конкретно этих инструментов и может быть применен практически к любому стеку.  Установил зависимости, поставил prettier, настроил линтинг, отформатировал код, поставил @types/node, поправил package.json, поправил vite.config.ts. Теперь хорошо. Я не буду углубляться в структуру проекта, рассказывать какие-то подробности Vite или React, если они НЕ ИМЕЮТ отношения к делу. Только суть. Чтобы избавиться от "лишнего шума" я удалю весь код, сгенерированный Vite и оставлю только один заголовок h1, который послужит для визуализации значений. Итак, я хочу иметь в приложении конфиг, описанный выше в качестве примера, так что я создам в корне проекта следующий .env файл: Vite предоставляет немного своеобразный способ работы с переменными окружения, в частности с .env. Подробнее об этом можно прочитать в официальной документации, но пока что следует знать только то, что все переменные должны быть указаны с преифксом VITE_, а вызов этих переменных внутри приложения происходит через специальный объект import.meta.env.Таким образом, получаем конфиг такого вида: И в приложении попробуем использовать переменную VITE_APP_TITLE, просто поместив ее внутрь заголовка h1, пользуясь интерполяцией JSX(TSX): Отлично! Это работает. Но если посмотрим в коде на тот самый специальный объект import.meta.env, то увидим, что он вроде как и не содержит нужных нам свойств. Более того, любое его свойство, помимо встроенных BASE_URL, MODE, DEV, PROD, SSR, является значением типа any. Посмотрим на решение, которое предоставляет Vite. Необходимо описать модуль env.d.ts, предварительно добавив его в директорию src или куда вам угодно, например, в src/types: Выглядит неплохо, теперь мы можем просматривать все переменные окружения, которые описали в модуле и опираться на их типы данных. Но вопрос с валидацией данных остается открытым. IDE подсказывает, что переменная VITE_APP_VERSION является значением типа number. Но по факту, проверив значение через typeof, мы получаем string: Такое решение может, скорее, сбить столку. Поэтому приступим к написанию собственного. Для начала добавим следующую структуру файлов: в директории src создаем новую директорию config, в которую помещаем два .ts документа: index.ts buildEnvProxy.ts buildEnvProxy.ts будет содержать в себе и экспортировать одноименную функцию, которая принимает на вход следующие параметры: source — источник, из которого берутся переменные окружения (как вы помните, в нашем случае это import.meta.env) и необязательный transformKey — функция, которая поможет преобразовать ключи (названия переменных) конфига, если это потребуется.  С источником понятно, но зачем преобразовывать ключи? Дело в том, что мне бы не хотелось постоянно использовать префикс VITE_, поэтому через Proxy я буду подставлять его автоматически.  Функция возвращает этот самый Proxy, который имеет только один handler — get. Его и опишем. Так как ключ в объекте — это string | symbol, а наш конфиг, по идее — это объект типа Record<string | unknown>, где ключ — это string, то нужно сначала привести этот ключ к строке с помощью String. Когда ключ в строковом формате имеется, нужно провести проверку на наличие функции transformKey и, если она существует — выполнить преобразование. В конечном итоге get возвращает значение из source по ключу, который был приведен к строке и преобразован при наличии функции transformKey: Внутри index.ts объявляем константу ENV и присваиваем ей результат выполнения описанной выше функции, указав в качестве generic Record<string, unknown>, а в качестве параметров: источник — import.meta.env и функцию преобразователь, которая подставляет к ключу корректный префикс: Проверяем: Получаем результат: Теперь приступим непосредственно к типизации конфига. Первым делом в этой же директории создаем документ config.types.ts, в котором описываем и экспортируем type Config с типами данных значений: Вот на этом моменте получается что-то похожее на решение, предоставленное Vite, т.к. типизация под конфиг имеется, но значения никак не преобразуются и по прежнему остаются типа string.  Для дальнейшего преобразования, внутри директории config заводим новую директорию configGetters, а в ней три документа, для начала: getBoolean.ts getNumber.ts getString.ts Как вы уже догадались, эти документы будут содержать одноименные функции, которые соответствуют типам данных указанных в конфиге переменных. Именно они будут отвечать за преобразование.  Если значение из конфига не соответствует ожиданиям — будет выброшена ошибка. Помимо создания функций, необходимо также указать, какая из функций соответствует конкретной переменной. Для этого поднимемся на уровень выше, т.е. вернемся в директорию config и создадим еще один документ — config.gettersMap.ts, в котором привяжем функции к переменным: Создается и экспортируется объект, обязательными ключами которого являются ключи типа Config, т.е. наши переменные окружения. И каждому ключу присваивается соответствующая функция. Вы могли обратить внимание на то, что до сих пор в данной секции ни разу не упоминалась переменная COMMENTED_REQUIRED_VALUE. Позже я раскрою все карты, а пока что просто присвоим ей функцию getString. Заключительной частью данной секции является сбор всего того, что мы написали вместе. Для этого на том же уровне создаем еще один, последний документ — buildConfigProxy.ts. Документ содержит одноименную функцию, которая принимает:  generic для того, чтобы обозначить тип возвращаемого значения;  два параметра: env — источник переменных окружения, который мы получили ранее с помощью функции buildEnvProxy envGettersMap — наш объект с присвоенными функциями преобразования — CONFIG_GETTERS_MAP Результатом выполнения функции будет полноценный конфиг, который прошел все необходимые проверки на наличие значений и их соответствие указанным типам. Остается только дополнить index.ts вызовом данной функции: В качестве generic указали тип Config, о чем уже подсказывает IDE: Проверяем: Получаем результат: Как видите, все значения были получены и код отработал корректно. Об этом свидетельствует, как отображение заголовка h1, так и выполненный над переменной CONFIG.APP_VERSION метод .toFixed(1), присущий типу number. Теперь в .env закомментируем любую вызываемую переменную, например, VITE_APP_TITLE:  Получаем результат в качестве результата ошибку Config value for "APP_TITLE" is not a string: "undefined" и сразу понимаем, что стоит поправить в конфиге: И для завершения раскомментируем переменную VITE_APP_TITLE и изменим теперь уже значение переменной VITE_APP_VERSION: Результат — ошибка Config value for "APP_VERSION" is not a number: "1,0": Круто! Мы проделали хорошую работу: конфиг теперь типизирован и свалидирован. Помимо, простейших преобразований и проверок, таких как getNumber, getString и getBoolean, вы можете сделать любые другие. Например, enum: Такая функция принимает на вход другую функцию геттер и список допустимых значений, а также имеет generic с указанием этих значений. Вот пример использования: На этом секция про 🙌 хорошее 🙌 решение подошла к концу. Вот такая структура получилась в итоге: Но что же с VITE_COMMENTED_REQUIRED_VALUE? Эта переменная как раз напрямую связана со вторым, ✨ отличным ✨ способом решения проблемы. Итак, что если в конфиге необходимо обозначить объект?  VITE_COMMENTED_REQUIRED_VALUE как раз подразумевалась, как такая переменная. Можно написать еще функций преобразования, подобных getNumber, getBoolean и т.д., но если таких переменных будет много и они все будут содержать объекты разных структур, то это уже не кажется хорошей идеей. Здесь на помощь приходит Zod. На официальном сайте сказано следующее: Zod — это TypeScript-first библиотека объявления и проверки схем. Я использую термин «схема» для широкого обозначения любого типа данных от простого string к сложному вложенному объекту. Проще говоря, мы можем создать какую-либо схему, а затем по этой схеме валидировать значения. Пример (тоже с оф. сайта): И еще немного значимых плюсов (опять же указанных на оф. сайте): 0 зависимостей; работает в Node.js и всех современных браузерах; крошечный: 8kb minified + zipped; работает с JS (TS необязателен) — в данном случае неактуально, но круто! Приступим к внедрению Zod. Первым делом устанавливаем библиотеку, я сделаю это с помощью yarn: А затем удаляем из директории config ВСЕ, что мы написали в секции "Решение v0", кроме index.ts и buildEnvProxy.ts. Да, нам больше не требуются функции преобразования, buildConfigProxy, CONFIG_GETTERS_MAP — Zod все сделает за нас! (почти)  Создаем в директории новый документ — config.schema.ts. В этом документе опишем схему конфига и экспортируем функцию, которая будет принимать на вход наш ENV, парсить его и в случае успешного парсинга возвращать валидный объект конфига. В случае неудачного парсинга из-за неправильно указанного (или вовсе неуказанного) значения переменной окружения, функция будет выбрасывать ошибку. Так как конфиг — это объект, обозначим схему, как z.object({}), а внутри, в фигурных скобках опишем этот объект с помощью других методов Zod'а: Ниже экспортируем ту самую функцию, которая будет парсить конфиг: Внутри вызываем у схемы метод safeParse, в который передаем объект конфига. Результатом выполнения метода будет некий объект, который всегда содержит логическое свойство success, а также data в случае успеха и error в случае неудачи. Мы используем все эти свойства и в случае success возвращаем data, а в случае неудачи выбрасываем error. Далее идем в index.ts и убираем все лишнее, то есть несуществующие импорты (потому что ранее мы удалили экспорты) и значение экспортируемой константы CONFIG, мы его перепишем: Теперь в константу CONFIG будет записан полностью валидный объект со всеми необходимыми переменными окружения, а если возникнет ошибка, Zod скажет об этом еще до отрисовки нашей страницы. Проверяем: И сразу получаем ошибку. А также обратите внимание на белую страницу, она действительно даже не отрисовалась! В ошибке говорится о том, что переменные APP_VERSION и SHOW_VERSION должны быть number и boolean соответственно, но приходит string. Так и есть, как помните все указанные значения в .env являются строками. Следовательно, необходимо выполнить преобразование этих строк, и Zod имеет несколько способов сделать это. Я воспользуюсь способом "принуждение" — .coerce, потому что пока что мы имеем дело с примитивами (сейчас объясню): В данной схеме как бы говорится "возьми переменную APP_VERSION и приведи ее к типу number". Аналогично и с SHOW_VERSION. Если приведение к указанному типу и последующая валидация полученного значения проходит неудачно — получаем ошибку. Проверяем с правильно указанными значениями: Все работает отлично. А теперь исправляем APP_VERSION на 1,0 и получаем ошибку о том, что ожидается значение типа number, но получается NaN (да, я знаю, но вот так — хорошо же!). Если закомментировать в конфиге какую-либо из описанных в схеме переменных — также получим ошибку, но о том, что вместо ожидаемого значения пришло undefined. Все работает, как надо и Zod уже закрыл все изначальные потребности. Наконец займемся переменной COMMENTED_REQUIRED_VALUE. Определимся, что данная переменная должна содержать массив простых объектов, что-то вроде: Сразу становится понятно, что такой массив необходимо передавать в формате JSON, поэтому опишем переменную в конфиге следующим образом: И приступим к описанию данного значения в схеме. Это будет выглядеть, как цепочка проверок и преобразований. Абсолютно точно, изначально значение является строкой, поэтому необходимо указать метод .string(). Когда получаем валидную строку, нужно проверить, что она также является валидным JSON-форматом. У Zod нет решения "из коробки" конкретно для JSON, но зато есть метод .transform(), который принимает в качестве параметра функцию преобразователь. Эта функция в свою очередь в качестве параметра имеет наше дошедшее к этому моменту значение — просто валидную строку. Эту строку нужно распарсить, как JSON — это и есть проверка и преобразование. Получается .transform((value) => JSON.parse(value)). А дальше все, как и в случае со схемой самого конфига: .object(...) внутри которого описываем свойства solution и rate. Но для того, чтобы сделать проверку над преобразованным методом .transform() значением, необходимо обернуть его методом .pipe(), то есть обозначить "пайплайн" проверки. И единственное отличие в том, что данный преобразованный объект все таки является массивом. Для этого указания просто добавляем после метода .object() метод .array(): .object(...).array(). Посмотрим, что получилось: В коде использовался еще один метод, который не упоминался ранее — .enum(). С помощью данного метода можно строго задать значения, которые могут быть приняты на вход. Кстати, помимо таких "enum'ов", Zod умеет также работать с нативными enum. В конце концов слегка дополним компонент и посмотрим, что получилось: Результат: На этом раздел, посвященный ✨ отличному ✨ решению заканчивается, как и статья.Как результат, мы имеем типизированный и валидный конфиг, в который можно поместить самые разнообразные данные, удобно использовать их в приложении и, в случае невалидности, получить подробную и понятную ошибку. Большим плюсом является то, что данное решение подходит для множества окружений: если проект Vite — используем функцию buildEnvProxy, куда передаем источник и transformKey, который подставляет префикс _VITE; если вдруг проект переводится на Webpack, то достаточно просто передать в функцию новый источник и убрать transformKey. А вот так можно улучшить эту функцию: Теперь мы можем передавать сразу несколько источников и указывать или не указывать transformKey для каждого из них. Handler get будет искать запрашиваемую переменную последовательно во всех источниках, а порядок источников можно менять и тем самым настраивать приоритет. Надеюсь большинство останется со мной солидарно, но буду рад обсуждению тех или иных моментов и предложениям по улучшению решения. Если что, задача на рефакторинг не заставит себя ждать :) Здесь гитхаб репозиторий с каждым решением (двумя) в отдельной ветке: https://github.com/bodasooqa/vite-react-config-validation  Frontend developer